COSE (RFC 9052), CWT (RFC 8392) and WebAuthn all carry their data as CBOR. This vignette takes structures from their specifications apart with zucbor. It stops where cryptography starts: zucbor gives you the exact bytes to verify, and another package verifies them.
library(zucbor)
hex <- function(h) as.raw(strtoi(substring(h, seq(1, nchar(h), 2), seq(2, nchar(h), 2)), 16L))A WebAuthn attestation object
When a passkey is created, the authenticator returns an attestation object. This one is the WebAuthn Level 3 test vector “ES256 Credential with No Attestation”:
att <- hex(paste0(
"a363666d74646e6f6e656761747453746d74a068617574684461746158a4bfabc374",
"32958b063360d3ad6461c9c4735ae7f8edd46592a5e0f01452b2e4b5590000000084",
"46ccb9ab1db374750b2367ff6f3a1f0020f91f391db4c9b2fde0ea70189cba3fb63f",
"579ba6122b33ad94ff3ec330084be4a5010203262001215820afefa16f97ca9b2d23",
"eb86ccb64098d20db90856062eb249c33a9b672f26df61225820930a56b87a2fca66",
"334b03458abf879717c12cc68ed73290af2e2664796b9220"))
ao <- cbor_decode(att)
str(ao)
#> List of 3
#> $ fmt : chr "none"
#> $ attStmt : Named list()
#> $ authData: raw [1:164] bf ab c3 74 ...authData is a byte string with its own binary layout: a
32-byte hash of the relying party’s ID, a flags byte, a 4-byte counter,
a 16-byte AAGUID, a 2-byte credential ID length and the ID, then the
credential’s public key as a COSE_Key, which is CBOR again.
ad <- ao$authData
id_len <- as.integer(ad[54]) * 256L + as.integer(ad[55])
rest <- ad[-seq_len(55 + id_len)]
key <- cbor_decode_prefix(rest)
key$value
#> <cbor_map: 5 entries>
#> [[1]]
#> [1] 2
#> [[3]]
#> [1] -7
#> [[-1]]
#> [1] 1
#> [[-2]]
#> [1] af ef a1 6f 97 ca 9b 2d 23 eb 86 cc b6 40 98 d2 0d b9 08 56 06 2e b2 49 c3
#> [26] 3a 9b 67 2f 26 df 61
#> [[-3]]
#> [1] 93 0a 56 b8 7a 2f ca 66 33 4b 03 45 8a bf 87 97 17 c1 2c c6 8e d7 32 90 af
#> [26] 2e 26 64 79 6b 92 20cbor_decode_prefix() decodes the one item at the start
of rest and says how many bytes it took, without reading
past it. That matters here: the key’s length is not written anywhere,
and what follows it, if anything, is up to the flags byte, not up to
CBOR. A COSE_Key uses integer map keys, so it is a
cbor_map. Key 1 is the key type (2, EC2), 3 the algorithm
(-7, ES256), -1 the curve (1, P-256), and -2 and -3 the coordinates.
cbor_diagnose() shows the same thing the way the
specifications write it:
cbor_diagnose(rest[seq_len(key$consumed)])
#> [1] "{1: 2, 3: -7, -1: 1, -2: h'afefa16f97ca9b2d23eb86ccb64098d20db90856062eb249c33a9b672f26df61', -3: h'930a56b87a2fca66334b03458abf879717c12cc68ed73290af2e2664796b9220'}"Bit 7 of the flags byte (ED) says whether extensions
follow the key, as one more CBOR map. Here it is clear, and nothing
follows:
flags <- as.integer(ad[33])
bitwAnd(flags, 0x80L) != 0L # extensions?
#> [1] FALSE
length(rest) - key$consumed # bytes after the key
#> [1] 0Had it been set,
cbor_decode(rest[-seq_len(key$consumed)]) would read them,
and would refuse anything after the extensions, as the layout
requires.
Re-encoding what was signed
Signature checks run over exact bytes. zucbor’s encoder is deterministic, and decoding then re-encoding deterministic input reproduces it exactly, so a structure can be taken apart, inspected and put back:
identical(cbor_encode(ao), att)
#> [1] TRUEThat holds because one-element arrays decode as I()
values and re-encode as arrays, booleans never turn into numbers, and
whole numbers stay integers. Across all 651 messages and signed
structures in the COSE working group’s examples, every one in
deterministic form survives the round trip byte for byte.
A signed CWT
A CBOR Web Token is a COSE_Sign1 message, tag 18, whose payload is a map of claims. This is RFC 8392’s example:
cwt <- hex(paste0(
"d28443a10126a104524173796d6d657472696345434453413235365850a70175636f",
"61703a2f2f61732e6578616d706c652e636f6d02656572696b77037818636f61703a",
"2f2f6c696768742e6578616d706c652e636f6d041a5612aeb0051a5610d9f0061a56",
"10d9f007420b7158405427c1ff28d23fbad1f29c4c7c6a555e601d6fa29f9179bc3d",
"7438bacaca5acd08c8d4d4f96131680c429a01f85951ecee743a52b9b63632c57209",
"120e1c9e30"))
msg <- cbor_decode(cwt)
msg$tag
#> [1] 18
str(msg$value, max.level = 1)
#> List of 4
#> $ : raw [1:3] a1 01 26
#> $ :Class 'cbor_map' hidden list of 2
#> $ : raw [1:80] a7 01 75 63 ...
#> $ : raw [1:64] 54 27 c1 ff ...The protected header and the payload are byte strings holding more CBOR. Decoding them is explicit, so each gets the same checks and limits:
cbor_decode(msg$value[[1]]) # protected header: {1: -7}, ES256
#> <cbor_map: 1 entry>
#> [[1]]
#> [1] -7
claims <- cbor_decode(msg$value[[3]])
claims
#> <cbor_map: 7 entries>
#> [[1]]
#> [1] "coap://as.example.com"
#> [[2]]
#> [1] "erikw"
#> [[3]]
#> [1] "coap://light.example.com"
#> [[4]]
#> [1] 1444064944
#> [[5]]
#> [1] 1443944944
#> [[6]]
#> [1] 1443944944
#> [[7]]
#> [1] 0b 71To verify the signature, a verifier builds the
Sig_structure of RFC 9052 section 4.4 and checks the
signature over its bytes. With zucbor that is one call, and the bytes
are exactly the ones the signer produced, because the encoding is
deterministic:
to_be_signed <- cbor_encode(list("Signature1", msg$value[[1]], raw(), msg$value[[3]]))
cbor_diagnose(to_be_signed)
#> [1] "[\"Signature1\", h'a10126', h'', h'a70175636f61703a2f2f61732e6578616d706c652e636f6d02656572696b77037818636f61703a2f2f6c696768742e6578616d706c652e636f6d041a5612aeb0051a5610d9f0061a5610d9f007420b71']"