@sdxc/cbor
CBOR (RFC 8949) decoding and deterministic encoding, bounded for untrusted input, with COSE keys and COSE_Sign1
- Installs with
- @sdxc/crypto@sdxc/result
- Source
- packages/cbor
CBOR (RFC 8949) decoding and deterministic encoding, bounded for untrusted input, with COSE keys and COSE_Sign1.
Installation
npm add @sdxc/cborDecoding and encoding answer a Result from @sdxc/result, which installs alongside this package.
Usage
Decode and encode
import * as CBOR from "@sdxc/cbor";
import { isFailure } from "@sdxc/result";
let decoded = CBOR.decode(Uint8Array.of(0xa1, 0x61, 0x61, 0x83, 0x01, 0x02, 0x03));
if (isFailure(decoded)) throw decoded.error; // decoded.error.code, decoded.error.offset
decoded.data; // Map { "a" => [1, 2, 3] }
let bytes = CBOR.encode(new Map([["a", [1, 2, 3]]])); // success(Uint8Array [0xa1, 0x61, …])CBOR items map to JavaScript values one way only:
| CBOR | JavaScript |
|---|---|
| unsigned, negative integer | number, or bigint beyond Number.MAX_SAFE_INTEGER |
| bignum (tags 2 and 3) | bigint |
| byte string | Uint8Array, copied out of the input |
| text string | string, refused unless valid UTF-8 |
| array | Array |
| map | Map, so integer and byte-string keys keep their type |
| any other tag | Tagged, with tag and value |
| false, true, null, undefined | false, true, null, undefined |
| other simple values | Simple, with value |
| float | number |
Read untrusted input within limits
import * as CBOR from "@sdxc/cbor";
let result = CBOR.decode(body, { maxDepth: 8, maxItems: 1_000 });Every item costs at least one byte, so a document can only claim lengths its own bytes hold; maxDepth (default 64) and maxItems (default 100,000) bound the nesting and the number of values one input allocates. A failure is limit-exceeded.
Require deterministic encoding
import * as CBOR from "@sdxc/cbor";
let result = CBOR.decode(signedPayload, { deterministic: true });The core deterministic encoding requirements: shortest arguments, definite lengths, the shortest float that holds a value, map keys in bytewise order of their encodings, and bignums only beyond 64 bits. Anything else fails not-deterministic. encode always writes this form.
Sign and verify COSE_Sign1
import * as COSE from "@sdxc/cbor/cose";
import { unwrap } from "@sdxc/result";
let message = unwrap(
await COSE.sign1({
protected: new Map([[COSE.HeaderParameter.ALG, COSE.Algorithm.ES256]]),
payload: new TextEncoder().encode("statement"),
key: keyPair.privateKey,
}),
);
let verified = await COSE.verifySign1(message, keyPair.publicKey); // success({ protected, payload, … })The @sdxc/cbor/cose entry signs and verifies COSE_Sign1 messages with WebCrypto keys, under ES256, ES384, ES512, Ed25519 (and the older EdDSA identifier), PS256, PS384 and PS512. Verification takes the algorithm from the protected header and refuses a key imported for any other, so a message cannot pick a weaker algorithm than its key. Pass detached: true to sign1 to leave the payload out of the message, and { payload } to verifySign1 to check it.
API
decode(bytes: Uint8Array, options?: DecodeOptions): Result<CBORValue, CBORError>
The one data item bytes holds. Bytes after it fail trailing-bytes; a map key equal to an earlier one fails duplicate-key, including keys JavaScript cannot tell apart, such as 1 and 1.0.
options.maxDepth: Deepest nesting of arrays, maps and tags. Default 64.options.maxItems: Most data items, nested ones included. Default 100,000.options.deterministic: Refuse any encoding deterministic CBOR rules out. Defaultfalse.
decodeFirst(bytes: Uint8Array, options?: DecodeOptions): Result<DecodedPrefix, CBORError>
The first data item of bytes and the number of bytes it took, for formats that place an item before other data with no length in front of it.
let { value, length } = unwrap(CBOR.decodeFirst(bytes));
let rest = bytes.subarray(length);encode(value: CBORValue): Result<Uint8Array, CBORError>
value as deterministic CBOR, so equal values always produce identical bytes. A safe integer becomes a CBOR integer and any other number the shortest float holding it exactly, so 1.0 encodes as 1 and -0 as a half-precision float. A cycle, a lone surrogate, a plain object or a function fails unencodable; two map keys with the same encoding fail duplicate-key.
Tagged
new Tagged(tag, value): a tag number up to 2^64 − 1 and the item it encloses. The tag's meaning is yours to apply.
Simple
new Simple(value): a simple value with no JavaScript equivalent, 0 to 19 or 32 to 255.
CBORError
The failure every function reports.
code:"truncated","malformed","trailing-bytes","duplicate-key","limit-exceeded","not-deterministic"or"unencodable".offset: the byte where the failing item starts, for a decoding failure;undefinedfor an encoding one.
@sdxc/cbor/cose
sign1({ protected, unprotected?, payload, detached?, externalAad?, key }): a tagged COSE_Sign1 message. The protected header must name the algorithm.verifySign1(message, key, { payload?, externalAad?, algorithms?, understood? }): the decoded message once its signature verifies.understoodlists thecritlabels the caller processes.decodeSign1(bytes)/fromValue(value)/encodeSign1(message): read and write a message without checking it;encodeSign1keeps the signed protected header bytes, so a new unprotected header (an attached receipt) leaves the signature valid.importKey(key, algorithm?): a COSE key as aCryptoKeyfor one algorithm, for signing when the key holdsd.toJWK(key)/fromJWK(jwk): convert between COSE keys and JWKs.thumbprint(key): the RFC 9679 SHA-256 COSE Key Thumbprint.Algorithm,HeaderParameter: the registered numbers, such asAlgorithm.ES256(-7) andHeaderParameter.KID(4).COSEError: every failure, withcode"malformed","unsupported-algorithm","unsupported-key","unknown-critical"or"invalid-signature".
Types
CBORValue
Every value decode returns and encode accepts, as the table above lists.
DecodeOptions
{ maxDepth?, maxItems?, deterministic? }, as decode describes.
DecodedPrefix
{ value, length }: the decoded item and the bytes its encoding took.
Patterns
Pattern: Hash a value the same way every time
Deterministic encoding gives a structured value one byte form, so its digest identifies it.
import * as CBOR from "@sdxc/cbor";
import { isFailure, success } from "@sdxc/result";
async function fingerprint(value: CBOR.CBORValue) {
let bytes = CBOR.encode(value);
if (isFailure(bytes)) return bytes;
let digest = await crypto.subtle.digest("SHA-256", bytes.data);
return success(new Uint8Array(digest));
}new Map([["b", 1], ["a", 2]]) and new Map([["a", 2], ["b", 1]]) produce the same digest, because map keys are written in sorted order.
Pattern: Read a COSE key out of a binary record
A WebAuthn authenticator data record carries a CBOR public key after fixed-length fields, with nothing saying where the key ends.
import * as CBOR from "@sdxc/cbor";
import { isFailure } from "@sdxc/result";
function readCredentialKey(authenticatorData: Uint8Array, keyOffset: number) {
let key = CBOR.decodeFirst(authenticatorData.subarray(keyOffset), { maxDepth: 4 });
if (isFailure(key)) return key;
if (!(key.data.value instanceof Map)) return null;
return {
algorithm: key.data.value.get(3), // COSE "alg" label
bytes: authenticatorData.subarray(keyOffset, keyOffset + key.data.length),
};
}