SearchCtrlK

  • ↑↓navigate
  • ↵open
  • escclose

Can’t find it? Contact us

@sdxc/cbor

CBOR (RFC 8949) decoding and deterministic encoding, bounded for untrusted input, with COSE keys and COSE_Sign1

Terminal
npm install @sdxc/cbor
Terminal
pnpm add @sdxc/cbor
Terminal
yarn add @sdxc/cbor
Terminal
bun add @sdxc/cbor

CBOR (RFC 8949) decoding and deterministic encoding, bounded for untrusted input, with COSE keys and COSE_Sign1.

Installation

Terminal
npm add @sdxc/cbor

Decoding and encoding answer a Result from @sdxc/result, which installs alongside this package.

Usage

Decode and encode

typescript
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:

CBORJavaScript
unsigned, negative integernumber, or bigint beyond Number.MAX_SAFE_INTEGER
bignum (tags 2 and 3)bigint
byte stringUint8Array, copied out of the input
text stringstring, refused unless valid UTF-8
arrayArray
mapMap, so integer and byte-string keys keep their type
any other tagTagged, with tag and value
false, true, null, undefinedfalse, true, null, undefined
other simple valuesSimple, with value
floatnumber

Read untrusted input within limits

typescript
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

typescript
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

typescript
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. Default false.

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.

typescript
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; undefined for 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. understood lists the crit labels the caller processes.

  • decodeSign1(bytes) / fromValue(value) / encodeSign1(message): read and write a message without checking it; encodeSign1 keeps the signed protected header bytes, so a new unprotected header (an attached receipt) leaves the signature valid.

  • importKey(key, algorithm?): a COSE key as a CryptoKey for one algorithm, for signing when the key holds d.

  • 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 as Algorithm.ES256 (-7) and HeaderParameter.KID (4).

  • COSEError: every failure, with code "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.

typescript
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.

typescript
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),
	};
}
API

Can’t find it? Contact us