@blamejs/core 0.12.31 → 0.12.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.12.x
10
10
 
11
+ - v0.12.33 (2026-05-24) — **`b.cose` — COSE_Sign1 sign / verify (RFC 9052) over the in-tree CBOR codec.** COSE is the signed-statement substrate under SCITT, CWT, and C2PA — the CBOR-native counterpart to JWS. `b.cose` ships COSE_Sign1 signing and verification composing the v0.12.32 `b.cbor` codec for the deterministic Sig_structure encoding. It signs with the classical COSE algorithms that interoperate today — ES256 / ES384 / ES512 (ECDSA) and EdDSA (Ed25519), all with final IANA algorithm ids (RFC 9053) — and with ML-DSA-87 (FIPS 204) for PQC-forward deployments. Verification accepts the same set, so the framework both produces COSE other implementations read today and consumes third-party COSE. There is no classical default: the caller names the algorithm and supplies the key. **Added:** *`b.cose.sign(payload, opts)` / `b.cose.verify(coseSign1, opts)`* — `sign` produces a tagged COSE_Sign1 with `alg` in the integrity-protected header; `verify` returns `{ payload, alg, protectedHeaders, unprotectedHeaders }`. The Sig_structure (`["Signature1", protected, external_aad, payload]`) is deterministically CBOR-encoded; ECDSA signatures use the IEEE-P1363 fixed-width encoding COSE mandates (RFC 9053 §2.1), not ASN.1 DER. `external_aad` is bound into the signature. v1 is single-signer with an attached payload; detached payload, COSE_Sign (multi-signer), COSE_Mac0, and COSE_Encrypt are deferred-with-condition (operator demand). **Security:** *Bounded, alg-allowlisted, crit-checked verification* — `verify` decodes the COSE_Sign1 bytes AND the protected-header bstr through the bounded `b.cbor.decode` (depth + size caps, indefinite-length / tag / duplicate-key refusal). `opts.algorithms` is a required allowlist (no defaults — name the accepted algorithms). A `crit` header (label 2) listing a header label the verifier does not understand is refused (RFC 9052 §3.1 crit-bypass defense), as is a `crit` label absent from the protected header. The COSE algorithm switch refuses any unrecognized id at the default branch. · *ML-DSA-87 COSE algorithm id is a non-final draft* — ML-DSA-87 uses COSE algorithm id `-50`, a requested (non-final) IANA assignment from draft-ietf-cose-dilithium — an ML-DSA-87 COSE_Sign1 is not yet broadly interoperable and the id may change; it is pinned deliberately with the re-open condition being IANA finalization. SLH-DSA-SHAKE-256f has no registered COSE algorithm id at all and cannot be represented in COSE. The COSE_Sign1 mechanism and the classical algorithms are stable; ML-DSA-87 is the forward-looking opt-in.
12
+
13
+ - v0.12.32 (2026-05-24) — **`b.cbor` — bounded, deterministic in-tree CBOR codec (RFC 8949).** CBOR is the binary serialization underneath COSE (RFC 9052), CWT, SCITT, and WebAuthn attestation — a foundational substrate the framework needs in-tree to build signed-statement primitives without a third-party parser. `b.cbor` is that codec, bounded by default like every parser the framework ships: a binary decoder is attack surface, so the defaults refuse the shapes a hostile encoder uses to exhaust memory or stack. The encoder emits Deterministically Encoded CBOR (RFC 8949 §4.2) — shortest-form heads, definite lengths, map keys sorted by encoded bytes, no indefinite-length items — so two semantically-equal values encode to byte-identical output, the property COSE signatures and SCITT receipts depend on. **Added:** *`b.cbor.encode(value, opts?)` / `b.cbor.decode(buffer, opts?)` / `b.cbor.Tag`* — `encode` produces deterministic CBOR from numbers (integers + float64), bigint (64-bit range), strings, `Buffer` / `Uint8Array`, arrays, `Map` or plain objects, `b.cbor.Tag`, and the simple values. `decode` returns the value with maps decoded to a `Map` (CBOR keys may be integers — COSE header labels are) and byte strings to `Buffer`. `b.cbor.Tag(tag, value)` carries a major-type-6 tagged item. `decode(buf, { requireDeterministic: true })` additionally asserts the input was itself canonically encoded (decode → re-encode → byte-compare), refusing a non-canonical re-encoding on a signature-verify path where it would be a malleability vector. **Security:** *Bounded-by-default decoder* — `maxDepth` (default 64, ceiling 256) caps nesting against stack exhaustion; `maxBytes` (default 16 MiB, ceiling 64 MiB) caps total input, and a declared string / array / map length exceeding the remaining bytes is refused before any allocation (no length-prefix memory bomb). Indefinite-length items (additional-info 31) are refused — a streaming-complexity / DoS vector forbidden by deterministic encoding. Reserved additional-info (28–30) is refused. Tags are refused unless allowlisted via `allowedTags` (a tag triggers semantic reprocessing — an un-vetted tag is a confused-deputy vector). Duplicate map keys (RFC 8949 §5.6) and trailing bytes after the data item are refused.
14
+
11
15
  - v0.12.31 (2026-05-24) — **`b.auth.jar.parse` — verify RFC 9101 JWT-Secured Authorization Requests (server side).** A plain OAuth authorization request carries its parameters in the URL query string, where a browser, proxy, or referer log can tamper with or leak them. RFC 9101 JAR packs those parameters into a JWT the client signs — the request object — so the authorization server can confirm they arrived exactly as sent. `b.auth.jar.parse(jar, opts)` is the server-side verifier and the request-side counterpart to the existing JARM response handling (`b.auth.oauth.parseJarmResponse`). It delegates the signature check to `b.auth.jwt.verifyExternal` — which already enforces a mandatory `algorithms` allowlist and refuses the alg-confusion (`alg: "none"`, HMAC-vs-RSA) and JWE-on-a-JWS-verifier shapes against a JWKS public-key trust source — then pins `iss` and the `client_id` claim to the expected client, pins `aud` to this server's issuer identifier, refuses a nested `request` / `request_uri` (RFC 9101 §6.3 recursion / confused-deputy vector), and returns the authorization parameters with the JWT envelope claims stripped. **Added:** *`b.auth.jar.parse(jar, opts)` — request-object verification* — `opts.clientId` (the expected client — pins `iss` + the `client_id` claim), `opts.audience` (this server's issuer identifier — pins `aud`), `opts.algorithms` (required signature allowlist — no defaults, the alg-confusion defense), and one of `opts.jwks` / `opts.jwksUri` / `opts.keyResolver` (the client's verification key). Returns `{ params, claims }` where `params` is the authorization parameters (`response_type`, `redirect_uri`, `scope`, `state`, `nonce`, …) with the JWT envelope claims (`iss`, `aud`, `exp`, `iat`, `nbf`, `jti`) removed. A request object whose `client_id` claim disagrees with `opts.clientId`, or that nests a `request` / `request_uri`, is refused. Emitting a request object (the client side) is deferred-with-condition: it requires signing with the client's key under a classical JWS algorithm, and the framework's own JWT signer is PQC-only for the tokens it issues — a PQC-signed request object would not interoperate with a standard authorization server; client-side emission re-opens when a classical JWS signer lands or operators surface the need. Until then clients sign request objects with their existing JOSE tooling.
12
16
 
13
17
  - v0.12.30 (2026-05-24) — **`bundleAdapterStorage.keyRotation(opts)` — verified whole-repository envelope key rotation.** Rotating the key that wraps a backup repository is only safe if you can prove every bundle still reads under the new key — a rotation that silently corrupts one bundle is a time-bomb the operator discovers at restore time, exactly when they can least afford it. `storage.keyRotation(opts)` rotates every bundle's envelope from the old key to the new key (composing `rewrapAllBundles`) and then re-reads every bundle under the NEW key (composing `verifyAllBundles`), so a bad rotation surfaces as `verifyFailed > 0` immediately instead of at restore. It emits a `backup/key-rotated` audit event with the rotation id + per-status counts — a key-rotation event is a compliance record (SOC 2 CC6.1, PCI DSS 3.6.4) operators wire into their signed audit chain. Works for both `recipient` (hybrid PQC envelope) and `passphrase` (Argon2id) storage; refused cleanly on plaintext (`cryptoStrategy: "none"`) storage and when the new key is missing. **Added:** *`bundleAdapterStorage.keyRotation(opts)` — rotate then prove* — `opts.newRecipient` / `opts.newPassphrase` is the key bundles rotate TO (matched to the storage's `cryptoStrategy`); `opts.oldRecipient` / `opts.oldPassphrase` unwraps the current envelope when it differs from the configured key. Returns `{ rotationId, rotatedAt, total, rotated, skipped, failed, verified, verifyFailed, rotateResults, verifyResults }`. `opts.verify` (default true) runs the post-rotation read-back under the new key; `opts.concurrency` / `opts.stopOnFirstFailure` forward to the batch passes. Plaintext bundles + non-wrappable formats are skipped cleanly; a rotation that leaves any bundle unreadable reports `verifyFailed > 0` and emits the audit event with `outcome: "failure"`. A true overlap window where BOTH the old and new key decrypt a bundle (`dualWrap: true`) is refused with `backup/dual-wrap-unsupported` — it needs multi-recipient archive envelopes `b.archive.wrap` does not yet emit, and re-opens when the wrap layer gains them; until then stage a rotation by keeping the old key available to readers until `keyRotation` reports `failed: 0` + `verifyFailed: 0`, then retire it.
package/README.md CHANGED
@@ -125,6 +125,8 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
125
125
 
126
126
  - **JSON / SQL / schema** — `b.safeJson` (with `maxKeys` cap defending CVE-2026-21717 V8 HashDoS), `b.safeBuffer`, `b.safeSql`, `b.safeSchema`
127
127
  - **URL + path** — `b.safeUrl` (IDN mixed-script / homograph refuse); `b.safeJsonPath` (refuses filter `?(...)`, deep-scan `$..`, script-shape `(@.x)` for safe Postgres JSONB ops)
128
+ - **Binary codec** — `b.cbor` bounded deterministic CBOR (RFC 8949 §4.2): depth/size caps, indefinite-length + reserved-info + tag + duplicate-key refusal, `requireDeterministic` canonical-form check; the in-tree substrate under COSE / CWT / SCITT / WebAuthn attestation
129
+ - **COSE signing** — `b.cose` COSE_Sign1 sign/verify (RFC 9052) over `b.cbor`: classical ES256/384/512 + EdDSA (final COSE ids, interoperable today) plus ML-DSA-87 (PQC-forward, draft id); bounded + alg-allowlisted + crit-bypass-checked verification; the signed-statement substrate under SCITT / CWT / C2PA
128
130
  - **Document parsers** — `b.parsers` (XML / TOML / YAML / .env); `b.config` (schema-validated env)
129
131
  - **File-type detection** — `b.fileType` magic-byte content classification with deny-on-upload categories (image / document / archive / executable / etc.)
130
132
  ### Content-safety gates
package/index.js CHANGED
@@ -455,6 +455,8 @@ module.exports = {
455
455
  // b.jose.jwe.experimental — see lib/jose-jwe-experimental.js for
456
456
  // the codepoint-stability contract.
457
457
  jose: { jwe: { experimental: require("./lib/jose-jwe-experimental") } },
458
+ cbor: require("./lib/cbor"),
459
+ cose: require("./lib/cose"),
458
460
  queue: queue,
459
461
  logStream: logStream,
460
462
  redact: redact,
package/lib/cbor.js ADDED
@@ -0,0 +1,478 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.cbor
4
+ * @nav Tools
5
+ * @title CBOR codec
6
+ *
7
+ * @intro
8
+ * A bounded, deterministic CBOR codec (RFC 8949). CBOR is the binary
9
+ * serialization underneath COSE (RFC 9052), CWT, SCITT, and WebAuthn
10
+ * attestation — a foundational substrate the framework needs in-tree
11
+ * to build signed-statement primitives without a third-party parser.
12
+ * Like every parser the framework ships, it is bounded by default:
13
+ * a binary decoder is attack surface, so the defaults refuse the
14
+ * shapes a hostile encoder uses to exhaust memory or stack.
15
+ *
16
+ * <strong>Decoder defences</strong> (all on by default):
17
+ * - <code>maxDepth</code> — nesting cap (refuses stack exhaustion).
18
+ * - <code>maxBytes</code> — total input cap; a declared string /
19
+ * array / map length that exceeds the remaining bytes is refused
20
+ * before any allocation (no length-prefix memory bomb).
21
+ * - <strong>Indefinite-length items refused</strong> (major-type
22
+ * additional-info 31) — they are a streaming-complexity / DoS
23
+ * vector and are forbidden by deterministic encoding (§4.2.1).
24
+ * - <strong>Reserved additional-info (28–30) refused.</strong>
25
+ * - <strong>Tags refused unless allowlisted</strong>
26
+ * (<code>allowedTags</code>) — a tag triggers semantic
27
+ * reprocessing; an un-vetted tag is a confused-deputy vector.
28
+ * - <strong>Duplicate map keys refused</strong> (§5.6 — ambiguous).
29
+ * - <strong>Trailing bytes refused</strong> — the buffer must be
30
+ * exactly one CBOR data item.
31
+ *
32
+ * <strong>Encoder</strong> emits Deterministically Encoded CBOR
33
+ * (§4.2): shortest-form integer / length heads, definite lengths,
34
+ * map keys sorted by their encoded bytes (bytewise lexicographic),
35
+ * no indefinite-length items. Two semantically equal values encode
36
+ * to byte-identical output — the property COSE signatures and SCITT
37
+ * receipts depend on.
38
+ *
39
+ * <code>decode(buf, { requireDeterministic: true })</code> additionally
40
+ * asserts the input was itself deterministically encoded (it decodes,
41
+ * re-encodes, and refuses on any byte difference) — use it on the
42
+ * verify side of a signature where a non-canonical re-encoding would
43
+ * otherwise be a malleability vector.
44
+ *
45
+ * Maps decode to a <code>Map</code> (CBOR map keys may be integers,
46
+ * not just strings — COSE header labels are integers); encode accepts
47
+ * a <code>Map</code> or a plain object (string keys). Tagged items
48
+ * are produced / consumed via <code>b.cbor.Tag</code>.
49
+ *
50
+ * @card
51
+ * Bounded, deterministic in-tree CBOR codec (RFC 8949 §4.2) —
52
+ * depth / size caps, indefinite-length + tag + duplicate-key
53
+ * refusal. The substrate under COSE / CWT / SCITT.
54
+ */
55
+
56
+ var C = require("./constants");
57
+ var { defineClass } = require("./framework-error");
58
+
59
+ var CborError = defineClass("CborError", { alwaysPermanent: true });
60
+
61
+ var DEFAULT_MAX_DEPTH = 64; // allow:raw-byte-literal — nesting depth, not a size
62
+ var ABSOLUTE_MAX_DEPTH = 256; // allow:raw-byte-literal — nesting depth ceiling, not a size
63
+ var DEFAULT_MAX_BYTES = C.BYTES.mib(16);
64
+ var ABSOLUTE_MAX_BYTES = C.BYTES.mib(64);
65
+
66
+ // CBOR / IEEE-754 wire constants (not byte sizes — protocol values).
67
+ var CBOR_AI_1BYTE = 24; // allow:raw-byte-literal — RFC 8949 §3 additional-info boundary (inline vs 1-byte argument)
68
+ var BYTES_64BIT = 8; // allow:raw-byte-literal — width of a CBOR uint64 / float64 argument, not a cap
69
+ var FLOAT16_MANT_DIV = 1024; // allow:raw-byte-literal — IEEE 754 half-precision mantissa scale (2^10), not a size
70
+
71
+ /**
72
+ * @primitive b.cbor.Tag
73
+ * @signature b.cbor.Tag(tag, value)
74
+ * @since 0.12.32
75
+ * @status stable
76
+ * @related b.cbor.encode, b.cbor.decode
77
+ *
78
+ * A tagged CBOR item (major type 6) — <code>tag</code> is the
79
+ * non-negative integer tag number, <code>value</code> the tagged
80
+ * content. <code>encode</code> accepts a <code>Tag</code>;
81
+ * <code>decode</code> returns one when the tag number is in
82
+ * <code>allowedTags</code>. Construct with or without <code>new</code>.
83
+ *
84
+ * @example
85
+ * var dt = new b.cbor.Tag(0, "2026-05-24T00:00:00Z"); // RFC 8949 §3.4.1
86
+ * var bytes = b.cbor.encode(dt);
87
+ * var back = b.cbor.decode(bytes, { allowedTags: [0] });
88
+ * // → b.cbor.Tag { tag: 0, value: "2026-05-24T00:00:00Z" }
89
+ */
90
+ function Tag(tag, value) {
91
+ if (!(this instanceof Tag)) return new Tag(tag, value);
92
+ if (typeof tag !== "number" || !Number.isInteger(tag) || tag < 0) {
93
+ throw new CborError("cbor/bad-tag", "cbor.Tag: tag must be a non-negative integer");
94
+ }
95
+ this.tag = tag;
96
+ this.value = value;
97
+ }
98
+
99
+ function _capInt(v, dflt, absolute) {
100
+ if (v == null) return dflt;
101
+ if (typeof v !== "number" || !isFinite(v) || v < 1) return dflt;
102
+ var n = Math.floor(v);
103
+ return n > absolute ? absolute : n;
104
+ }
105
+
106
+ // ---- encoder (deterministic, RFC 8949 §4.2) ----
107
+
108
+ // Preferred float serialization (RFC 8949 §4.2.1): the shortest of
109
+ // float16 / float32 / float64 that round-trips the value exactly. COSE
110
+ // + SCITT depend on this — emitting float64 for a value representable
111
+ // in float16 is non-canonical and trips requireDeterministic.
112
+ function _encodeFloat(value) {
113
+ if (Number.isNaN(value)) return Buffer.from([0xf9, 0x7e, 0x00]); // allow:raw-byte-literal — canonical half NaN (RFC 8949 §4.2.1)
114
+ if (value === Infinity) return Buffer.from([0xf9, 0x7c, 0x00]); // allow:raw-byte-literal — half +Inf
115
+ if (value === -Infinity) return Buffer.from([0xf9, 0xfc, 0x00]); // allow:raw-byte-literal — half -Inf
116
+ var half = _doubleToHalfBits(value);
117
+ if (half >= 0) { var hb = Buffer.alloc(3); hb[0] = 0xf9; hb.writeUInt16BE(half, 1); return hb; }
118
+ var f4 = Buffer.alloc(5); f4[0] = 0xfa; f4.writeFloatBE(value, 1);
119
+ if (f4.readFloatBE(1) === value) return f4; // exactly representable in float32
120
+ var f8 = Buffer.alloc(9); f8[0] = 0xfb; f8.writeDoubleBE(value, 1); return f8;
121
+ }
122
+
123
+ // Returns the 16-bit half-precision representation of a FINITE double
124
+ // if it is exactly representable, else -1. Goes via float32: a value
125
+ // not exact in float32 cannot be exact in float16; then the float32
126
+ // exponent must fit the half range and the low 13 mantissa bits must
127
+ // be zero (half has a 10-bit mantissa vs float32's 23).
128
+ function _doubleToHalfBits(value) {
129
+ var fbuf = Buffer.alloc(4);
130
+ fbuf.writeFloatBE(value, 0);
131
+ if (fbuf.readFloatBE(0) !== value) return -1; // not exact in float32 → not in float16
132
+ var f = fbuf.readUInt32BE(0);
133
+ var sign = (f >>> 16) & 0x8000;
134
+ var exp = (f >>> 23) & 0xff;
135
+ var mant = f & 0x7fffff;
136
+ var unbiased = exp - 127 + 15;
137
+ if (unbiased >= 0x1f) return -1; // overflow half's exponent range
138
+ if (unbiased <= 0) {
139
+ // subnormal half (or zero / underflow).
140
+ if (unbiased < -10) return -1; // too small for a half subnormal
141
+ var fullMant = mant | 0x800000; // restore implicit leading 1
142
+ var shift = 14 - unbiased;
143
+ if (fullMant & ((1 << shift) - 1)) return -1; // would drop set bits → inexact
144
+ return sign | (fullMant >>> shift);
145
+ }
146
+ if (mant & 0x1fff) return -1; // low 13 bits set → not exact in half
147
+ return sign | (unbiased << 10) | (mant >>> 13);
148
+ }
149
+
150
+ function _head(major, argument) {
151
+ // argument is a non-negative integer (Number or BigInt). Emit the
152
+ // shortest form: inline (<24), 1/2/4/8 byte. major is 0..7.
153
+ var mt = major << 5;
154
+ var big = (typeof argument === "bigint") ? argument : BigInt(argument);
155
+ if (big < 24n) return Buffer.from([mt | Number(big)]);
156
+ if (big < 256n) return Buffer.from([mt | 24, Number(big)]);
157
+ if (big < 65536n) {
158
+ var b2 = Buffer.alloc(3); b2[0] = mt | 25; b2.writeUInt16BE(Number(big), 1); return b2;
159
+ }
160
+ if (big < 4294967296n) {
161
+ var b4 = Buffer.alloc(5); b4[0] = mt | 26; b4.writeUInt32BE(Number(big), 1); return b4;
162
+ }
163
+ if (big < 18446744073709551616n) {
164
+ var b8 = Buffer.alloc(9); b8[0] = mt | 27; b8.writeBigUInt64BE(big, 1); return b8;
165
+ }
166
+ throw new CborError("cbor/int-overflow", "cbor.encode: integer exceeds 64-bit CBOR range");
167
+ }
168
+
169
+ function _encodeValue(value, opts) {
170
+ if (value === null) return Buffer.from([0xf6]); // allow:raw-byte-literal — CBOR null simple value
171
+ if (value === undefined) return Buffer.from([0xf7]); // allow:raw-byte-literal — CBOR undefined simple value
172
+ if (value === true) return Buffer.from([0xf5]); // allow:raw-byte-literal — CBOR true simple value
173
+ if (value === false) return Buffer.from([0xf4]); // allow:raw-byte-literal — CBOR false simple value
174
+
175
+ if (typeof value === "number") {
176
+ // Exact integers within the safe range encode as CBOR integers;
177
+ // an integer-VALUED number beyond 2^53 (e.g. 1e300) has lost
178
+ // integer precision and is a float — encode it as a float (use a
179
+ // bigint for exact 64-bit CBOR integers).
180
+ if (Number.isInteger(value) && Math.abs(value) <= Number.MAX_SAFE_INTEGER) {
181
+ return value >= 0 ? _head(0, value) : _head(1, -1 - value);
182
+ }
183
+ if (!isFinite(value) && !opts.allowNonFinite) {
184
+ throw new CborError("cbor/non-finite", "cbor.encode: NaN / Infinity refused (set allowNonFinite to emit them)");
185
+ }
186
+ return _encodeFloat(value);
187
+ }
188
+ if (typeof value === "bigint") {
189
+ return value >= 0n ? _head(0, value) : _head(1, -1n - value);
190
+ }
191
+ if (typeof value === "string") {
192
+ var u = Buffer.from(value, "utf8");
193
+ return Buffer.concat([_head(3, u.length), u]);
194
+ }
195
+ if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
196
+ var bs = Buffer.isBuffer(value) ? value : Buffer.from(value);
197
+ return Buffer.concat([_head(2, bs.length), bs]);
198
+ }
199
+ if (Array.isArray(value)) {
200
+ var parts = [_head(4, value.length)];
201
+ for (var i = 0; i < value.length; i++) parts.push(_encodeValue(value[i], opts));
202
+ return Buffer.concat(parts);
203
+ }
204
+ if (value instanceof Tag) {
205
+ return Buffer.concat([_head(6, value.tag), _encodeValue(value.value, opts)]);
206
+ }
207
+ if (value instanceof Map || (typeof value === "object")) {
208
+ return _encodeMap(value, opts);
209
+ }
210
+ throw new CborError("cbor/unencodable",
211
+ "cbor.encode: value of type " + (typeof value) + " is not CBOR-encodable");
212
+ }
213
+
214
+ function _encodeMap(value, opts) {
215
+ // Build [encodedKey, encodedValue] pairs, then sort by encoded-key
216
+ // bytes (bytewise lexicographic) per §4.2.1 so the output is
217
+ // deterministic regardless of insertion order.
218
+ var entries = [];
219
+ if (value instanceof Map) {
220
+ value.forEach(function (v, k) { entries.push([_encodeValue(k, opts), _encodeValue(v, opts)]); });
221
+ } else {
222
+ var keys = Object.keys(value);
223
+ for (var i = 0; i < keys.length; i++) {
224
+ entries.push([_encodeValue(keys[i], opts), _encodeValue(value[keys[i]], opts)]);
225
+ }
226
+ }
227
+ entries.sort(function (a, b) { return Buffer.compare(a[0], b[0]); });
228
+ // Reject duplicate keys (equal encoded-key bytes) — ambiguous + a
229
+ // canonical-form violation.
230
+ for (var j = 1; j < entries.length; j++) {
231
+ if (Buffer.compare(entries[j - 1][0], entries[j][0]) === 0) {
232
+ throw new CborError("cbor/duplicate-key", "cbor.encode: duplicate map key");
233
+ }
234
+ }
235
+ var out = [_head(5, entries.length)];
236
+ for (var k = 0; k < entries.length; k++) { out.push(entries[k][0]); out.push(entries[k][1]); }
237
+ return Buffer.concat(out);
238
+ }
239
+
240
+ /**
241
+ * @primitive b.cbor.encode
242
+ * @signature b.cbor.encode(value, opts?)
243
+ * @since 0.12.32
244
+ * @status stable
245
+ * @related b.cbor.decode, b.cbor.Tag
246
+ *
247
+ * Encode a JavaScript value to Deterministically Encoded CBOR
248
+ * (RFC 8949 §4.2): shortest-form integer / length heads, definite
249
+ * lengths, map keys sorted by their encoded bytes, no indefinite-
250
+ * length items. Two semantically-equal values produce byte-identical
251
+ * output. Accepts numbers (integers + float64), bigint (64-bit
252
+ * range), strings, <code>Buffer</code> / <code>Uint8Array</code>,
253
+ * arrays, <code>Map</code> or plain objects, <code>b.cbor.Tag</code>,
254
+ * and <code>true</code> / <code>false</code> / <code>null</code> /
255
+ * <code>undefined</code>.
256
+ *
257
+ * @opts
258
+ * {
259
+ * allowNonFinite?: boolean, // default false — NaN / Infinity refused
260
+ * }
261
+ *
262
+ * @example
263
+ * b.cbor.encode({ b: 2, a: 1 }).toString("hex"); // → "a2616101616202" (keys sorted)
264
+ */
265
+ function encode(value, opts) {
266
+ opts = opts || {};
267
+ return _encodeValue(value, opts);
268
+ }
269
+
270
+ // ---- decoder (bounded) ----
271
+
272
+ /**
273
+ * @primitive b.cbor.decode
274
+ * @signature b.cbor.decode(buffer, opts?)
275
+ * @since 0.12.32
276
+ * @status stable
277
+ * @related b.cbor.encode, b.cbor.Tag
278
+ *
279
+ * Decode one CBOR data item from a buffer, bounded by default. Maps
280
+ * decode to a <code>Map</code> (CBOR keys may be integers); byte
281
+ * strings to <code>Buffer</code>. Refuses indefinite-length items,
282
+ * reserved additional-info (28–30), tags not in
283
+ * <code>allowedTags</code>, duplicate map keys, and trailing bytes.
284
+ *
285
+ * @opts
286
+ * {
287
+ * maxDepth?: number, // default 64, ceiling 256 — nesting cap
288
+ * maxBytes?: number, // default 16 MiB, ceiling 64 MiB
289
+ * allowedTags?: number[], // default [] — tag numbers permitted
290
+ * requireDeterministic?: boolean, // default false — assert canonical encoding
291
+ * }
292
+ *
293
+ * @example
294
+ * var m = b.cbor.decode(bytes, { allowedTags: [0], requireDeterministic: true });
295
+ */
296
+ function decode(buffer, opts) {
297
+ opts = opts || {};
298
+ if (!Buffer.isBuffer(buffer) && !(buffer instanceof Uint8Array)) {
299
+ throw new CborError("cbor/bad-input", "cbor.decode: input must be a Buffer / Uint8Array");
300
+ }
301
+ var buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
302
+ var maxBytes = _capInt(opts.maxBytes, DEFAULT_MAX_BYTES, ABSOLUTE_MAX_BYTES);
303
+ if (buf.length > maxBytes) {
304
+ throw new CborError("cbor/too-large",
305
+ "cbor.decode: input " + buf.length + " bytes exceeds maxBytes " + maxBytes);
306
+ }
307
+ var maxDepth = _capInt(opts.maxDepth, DEFAULT_MAX_DEPTH, ABSOLUTE_MAX_DEPTH);
308
+ var allowedTags = Array.isArray(opts.allowedTags) ? opts.allowedTags : [];
309
+
310
+ var state = { buf: buf, pos: 0, maxDepth: maxDepth, allowedTags: allowedTags };
311
+ var value = _decodeItem(state, 0);
312
+ if (state.pos !== buf.length) {
313
+ throw new CborError("cbor/trailing-bytes",
314
+ "cbor.decode: " + (buf.length - state.pos) + " trailing byte(s) after the data item");
315
+ }
316
+
317
+ if (opts.requireDeterministic === true) {
318
+ // Round-trip: a deterministically-encoded input re-encodes to the
319
+ // identical bytes. Any difference is a non-canonical encoding
320
+ // (long-form head, unsorted keys, indefinite length) — a
321
+ // malleability vector on a signature-verify path.
322
+ var reencoded = _encodeValue(value, {});
323
+ if (Buffer.compare(reencoded, buf) !== 0) {
324
+ throw new CborError("cbor/not-deterministic",
325
+ "cbor.decode: input is not deterministically encoded (requireDeterministic)");
326
+ }
327
+ }
328
+ return value;
329
+ }
330
+
331
+ function _need(state, n) {
332
+ if (state.pos + n > state.buf.length) {
333
+ throw new CborError("cbor/truncated", "cbor.decode: unexpected end of input");
334
+ }
335
+ }
336
+
337
+ function _readArgument(state, ai) {
338
+ // ai is the low-5-bits additional info. Returns the argument as a
339
+ // Number (or BigInt for 8-byte values beyond Number range).
340
+ if (ai < CBOR_AI_1BYTE) return ai;
341
+ if (ai === CBOR_AI_1BYTE) { _need(state, 1); var v1 = state.buf[state.pos]; state.pos += 1; return v1; }
342
+ if (ai === 25) { _need(state, 2); var v2 = state.buf.readUInt16BE(state.pos); state.pos += 2; return v2; }
343
+ if (ai === 26) { _need(state, 4); var v4 = state.buf.readUInt32BE(state.pos); state.pos += 4; return v4; }
344
+ if (ai === 27) {
345
+ _need(state, BYTES_64BIT);
346
+ var big = state.buf.readBigUInt64BE(state.pos); state.pos += BYTES_64BIT;
347
+ return big <= 9007199254740991n ? Number(big) : big; // safe-int → Number, else BigInt
348
+ }
349
+ if (ai === 31) {
350
+ throw new CborError("cbor/indefinite-refused",
351
+ "cbor.decode: indefinite-length items are refused (deterministic-encoding violation)");
352
+ }
353
+ throw new CborError("cbor/reserved-ai",
354
+ "cbor.decode: reserved additional-information value " + ai + " (28-30) refused");
355
+ }
356
+
357
+ function _lenOf(arg) {
358
+ // A length / count must be a Number within array bounds — a BigInt
359
+ // length means a >2^53 declared size, which exceeds maxBytes anyway.
360
+ if (typeof arg === "bigint") {
361
+ throw new CborError("cbor/length-too-large", "cbor.decode: declared length exceeds addressable range");
362
+ }
363
+ return arg;
364
+ }
365
+
366
+ function _decodeItem(state, depth) {
367
+ if (depth > state.maxDepth) {
368
+ throw new CborError("cbor/max-depth", "cbor.decode: nesting exceeds maxDepth " + state.maxDepth);
369
+ }
370
+ _need(state, 1);
371
+ var ib = state.buf[state.pos]; state.pos += 1;
372
+ var major = ib >> 5;
373
+ var ai = ib & 0x1f;
374
+
375
+ switch (major) {
376
+ case 0: return _readArgument(state, ai); // unsigned int
377
+ case 1: { // negative int
378
+ var n = _readArgument(state, ai);
379
+ return (typeof n === "bigint") ? (-1n - n) : (-1 - n);
380
+ }
381
+ case 2: { // byte string
382
+ var blen = _lenOf(_readArgument(state, ai));
383
+ _need(state, blen);
384
+ var bytes = buf_slice(state, blen);
385
+ return bytes;
386
+ }
387
+ case 3: { // text string
388
+ var slen = _lenOf(_readArgument(state, ai));
389
+ _need(state, slen);
390
+ var sb = buf_slice(state, slen);
391
+ // CBOR text strings are defined as valid UTF-8 (RFC 8949 §3.1).
392
+ // Buffer.toString("utf8") silently substitutes U+FFFD for
393
+ // malformed bytes — that changes data and can slip an invalid
394
+ // payload past a canonicalization / signature check. Decode
395
+ // fatally so malformed UTF-8 is refused.
396
+ try {
397
+ return new TextDecoder("utf-8", { fatal: true }).decode(sb);
398
+ } catch (_e) {
399
+ throw new CborError("cbor/invalid-utf8",
400
+ "cbor.decode: text string is not valid UTF-8 (RFC 8949 §3.1)");
401
+ }
402
+ }
403
+ case 4: { // array
404
+ var alen = _lenOf(_readArgument(state, ai));
405
+ var arr = [];
406
+ for (var i = 0; i < alen; i++) arr.push(_decodeItem(state, depth + 1));
407
+ return arr;
408
+ }
409
+ case 5: { // map
410
+ var mlen = _lenOf(_readArgument(state, ai));
411
+ var m = new Map();
412
+ var seen = [];
413
+ for (var j = 0; j < mlen; j++) {
414
+ var keyStart = state.pos;
415
+ var key = _decodeItem(state, depth + 1);
416
+ var keyBytes = state.buf.slice(keyStart, state.pos);
417
+ for (var s = 0; s < seen.length; s++) {
418
+ if (Buffer.compare(seen[s], keyBytes) === 0) {
419
+ throw new CborError("cbor/duplicate-key", "cbor.decode: duplicate map key (RFC 8949 §5.6)");
420
+ }
421
+ }
422
+ seen.push(keyBytes);
423
+ var val = _decodeItem(state, depth + 1);
424
+ m.set(key, val);
425
+ }
426
+ return m;
427
+ }
428
+ case 6: { // tag
429
+ var tag = _lenOf(_readArgument(state, ai));
430
+ if (state.allowedTags.indexOf(tag) === -1) {
431
+ throw new CborError("cbor/tag-refused",
432
+ "cbor.decode: tag " + tag + " refused (add it to allowedTags to permit)");
433
+ }
434
+ return new Tag(tag, _decodeItem(state, depth + 1));
435
+ }
436
+ default: return _decodeSimpleOrFloat(state, ai); // major 7
437
+ }
438
+ }
439
+
440
+ function buf_slice(state, n) {
441
+ var out = state.buf.slice(state.pos, state.pos + n);
442
+ state.pos += n;
443
+ // Copy so the returned buffer doesn't pin the (larger) input buffer.
444
+ return Buffer.from(out);
445
+ }
446
+
447
+ function _decodeSimpleOrFloat(state, ai) {
448
+ if (ai === 20) return false;
449
+ if (ai === 21) return true;
450
+ if (ai === 22) return null;
451
+ if (ai === 23) return undefined;
452
+ if (ai === 25) { _need(state, 2); var h = _readFloat16(state); return h; }
453
+ if (ai === 26) { _need(state, 4); var f = state.buf.readFloatBE(state.pos); state.pos += 4; return f; }
454
+ if (ai === 27) { _need(state, BYTES_64BIT); var d = state.buf.readDoubleBE(state.pos); state.pos += BYTES_64BIT; return d; }
455
+ if (ai === 31) {
456
+ throw new CborError("cbor/indefinite-refused", "cbor.decode: indefinite-length break refused");
457
+ }
458
+ throw new CborError("cbor/bad-simple",
459
+ "cbor.decode: unsupported simple value " + ai + " (only false/true/null/undefined + float16/32/64)");
460
+ }
461
+
462
+ function _readFloat16(state) {
463
+ // IEEE 754 half-precision → Number (RFC 8949 Appendix D).
464
+ var half = state.buf.readUInt16BE(state.pos); state.pos += 2;
465
+ var exp = (half >> 10) & 0x1f;
466
+ var mant = half & 0x3ff;
467
+ var sign = (half & 0x8000) ? -1 : 1;
468
+ if (exp === 0) return sign * Math.pow(2, -14) * (mant / FLOAT16_MANT_DIV);
469
+ if (exp === 31) return mant ? NaN : sign * Infinity;
470
+ return sign * Math.pow(2, exp - 25) * (FLOAT16_MANT_DIV + mant);
471
+ }
472
+
473
+ module.exports = {
474
+ encode: encode,
475
+ decode: decode,
476
+ Tag: Tag,
477
+ CborError: CborError,
478
+ };
package/lib/cose.js ADDED
@@ -0,0 +1,339 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.cose
4
+ * @nav Crypto
5
+ * @title COSE signing (RFC 9052)
6
+ *
7
+ * @intro
8
+ * COSE_Sign1 signing and verification (RFC 9052 / 9053), composing
9
+ * the in-tree <code>b.cbor</code> codec for the deterministic
10
+ * Sig_structure encoding. COSE is the signed-statement substrate
11
+ * under SCITT, CWT, and C2PA — a CBOR-native counterpart to JWS.
12
+ *
13
+ * <strong>Signing</strong> supports the classical COSE signature
14
+ * algorithms that are interoperable today — ES256 / ES384 / ES512
15
+ * (ECDSA) and EdDSA (Ed25519), all with final IANA algorithm ids
16
+ * (RFC 9053) — alongside ML-DSA-87 (FIPS 204) for PQC-forward
17
+ * deployments. There is no classical <em>default</em>: the caller
18
+ * names the algorithm and supplies the key. <strong>Verification</strong>
19
+ * accepts the same set, so the framework both produces COSE other
20
+ * implementations can read today and consumes third-party COSE.
21
+ *
22
+ * <strong>Standards-maturity caveat on the PQC algorithm:</strong>
23
+ * the COSE algorithm identifier for ML-DSA-87 is <code>-50</code>, a
24
+ * <em>requested</em> (non-final) IANA assignment from
25
+ * draft-ietf-cose-dilithium; it may change before that draft is
26
+ * published, so an ML-DSA-87 COSE_Sign1 is not yet broadly
27
+ * interoperable — pin the identifier deliberately, re-open on IANA
28
+ * finalization. SLH-DSA-SHAKE-256f (the framework's default PQC
29
+ * signature elsewhere) has <strong>no</strong> COSE algorithm
30
+ * identifier registered at all (the COSE SPHINCS+ draft registers
31
+ * only the Category-1 'small' sets), so it cannot be represented in
32
+ * COSE and is not offered here. The COSE_Sign1 mechanism itself, and
33
+ * the classical algorithms, are stable; ML-DSA-87 is the forward-
34
+ * looking opt-in.
35
+ *
36
+ * <strong>Verify is bounded.</strong> The COSE_Sign1 bytes and the
37
+ * protected-header bstr are decoded through <code>b.cbor.decode</code>
38
+ * (depth + size caps, indefinite-length / tag / duplicate-key
39
+ * refusal). The protected header is the integrity-protected one;
40
+ * <code>alg</code> (label 1) lives there. A <code>crit</code> (label
41
+ * 2) listing a header label the verifier does not understand is
42
+ * refused (RFC 9052 §3.1) — a crit-bypass defense.
43
+ *
44
+ * v1 ships COSE_Sign1 (single-signer) with an attached payload.
45
+ * Detached payload, COSE_Sign (multi-signer), COSE_Mac0, and
46
+ * COSE_Encrypt are deferred-with-condition (operator demand).
47
+ *
48
+ * @card
49
+ * COSE_Sign1 sign / verify (RFC 9052) over the in-tree CBOR codec —
50
+ * ML-DSA-87 signing (experimental, draft alg id) + classical verify,
51
+ * bounded + crit-checked. The substrate under SCITT / CWT / C2PA.
52
+ */
53
+
54
+ var nodeCrypto = require("node:crypto");
55
+ var cbor = require("./cbor");
56
+ var validateOpts = require("./validate-opts");
57
+ var { defineClass } = require("./framework-error");
58
+
59
+ var CoseError = defineClass("CoseError", { alwaysPermanent: true });
60
+
61
+ var COSE_SIGN1_TAG = 18; // allow:raw-byte-literal — RFC 9052 COSE_Sign1 CBOR tag
62
+ var HDR_ALG = 1; // RFC 9052 §3.1 header label: alg
63
+ var HDR_CRIT = 2; // header label: crit
64
+ var HDR_CONTENT_TYPE = 3; // header label: content type
65
+ var HDR_KID = 4; // header label: kid
66
+
67
+ // COSE algorithm identifiers. ML-DSA-87 is a NON-FINAL requested
68
+ // assignment (draft-ietf-cose-dilithium) — pinned deliberately, re-open
69
+ // on IANA finalization. The classical ECDSA / EdDSA ids are final
70
+ // (RFC 9053). SLH-DSA is intentionally absent (no registered COSE id).
71
+ var ALG_NAME_TO_ID = {
72
+ "ML-DSA-87": -50,
73
+ "ES256": -7, "ES384": -35, "ES512": -36, "EdDSA": -8, // allow:raw-byte-literal — COSE algorithm identifiers (RFC 9053), not byte sizes
74
+ };
75
+ var ALG_ID_TO_NAME = {};
76
+ Object.keys(ALG_NAME_TO_ID).forEach(function (k) { ALG_ID_TO_NAME[ALG_NAME_TO_ID[k]] = k; });
77
+
78
+ // Signable algorithms: the classical ECDSA / EdDSA set (final COSE
79
+ // ids, interoperable today) plus ML-DSA-87 (draft id, PQC-forward).
80
+ // All are accepted for VERIFY as well. There is no classical default —
81
+ // the caller names the algorithm explicitly.
82
+ var SIGNABLE = ["ML-DSA-87", "ES256", "ES384", "ES512", "EdDSA"];
83
+
84
+ // Header labels this verifier understands — a `crit` entry naming any
85
+ // other label is refused (RFC 9052 §3.1 crit-bypass defense).
86
+ var UNDERSTOOD_LABELS = [HDR_ALG, HDR_CRIT, HDR_CONTENT_TYPE, HDR_KID];
87
+
88
+ function _toKeyObject(key, kind) {
89
+ if (key && typeof key === "object" && typeof key.asymmetricKeyType === "string") return key;
90
+ try {
91
+ return kind === "private" ? nodeCrypto.createPrivateKey(key) : nodeCrypto.createPublicKey(key);
92
+ } catch (e) {
93
+ throw new CoseError("cose/bad-key", "cose: could not load " + kind + " key: " + e.message);
94
+ }
95
+ }
96
+
97
+ function _algParamsFor(algId) {
98
+ switch (algId) {
99
+ case -50: return { nodeAlg: null }; // ML-DSA-87 (KeyObject specifies the hash)
100
+ case -8: return { nodeAlg: null }; // allow:raw-byte-literal — EdDSA COSE alg id (RFC 9053), not a size
101
+ case -7: return { nodeAlg: "sha256", dsaEncoding: "ieee-p1363" }; // ES256
102
+ case -35: return { nodeAlg: "sha384", dsaEncoding: "ieee-p1363" }; // ES384
103
+ case -36: return { nodeAlg: "sha512", dsaEncoding: "ieee-p1363" }; // ES512
104
+ default:
105
+ throw new CoseError("cose/unknown-alg", "cose: unrecognized COSE algorithm id " + algId);
106
+ }
107
+ }
108
+
109
+ function _bstr(x) {
110
+ if (Buffer.isBuffer(x)) return x;
111
+ if (x instanceof Uint8Array) return Buffer.from(x);
112
+ if (typeof x === "string") return Buffer.from(x, "utf8");
113
+ throw new CoseError("cose/bad-bytes", "cose: expected bytes (Buffer / Uint8Array / string)");
114
+ }
115
+
116
+ // Sig_structure (RFC 9052 §4.4) for COSE_Sign1:
117
+ // [ "Signature1", body_protected (bstr), external_aad (bstr), payload (bstr) ]
118
+ // deterministically CBOR-encoded — the bytes that are signed / verified.
119
+ function _toBeSigned(protectedBstr, externalAad, payload) {
120
+ return cbor.encode(["Signature1", protectedBstr, externalAad, payload]);
121
+ }
122
+
123
+ /**
124
+ * @primitive b.cose.sign
125
+ * @signature b.cose.sign(payload, opts)
126
+ * @since 0.12.33
127
+ * @status stable
128
+ * @related b.cose.verify, b.cbor.encode
129
+ *
130
+ * Produce a tagged COSE_Sign1 (RFC 9052) over <code>payload</code>
131
+ * (bytes). <code>alg</code> is one of the classical ECDSA / EdDSA
132
+ * algorithms (final COSE ids, interoperable today) or
133
+ * <code>"ML-DSA-87"</code> (draft id <code>-50</code>, PQC-forward).
134
+ * <code>alg</code> is placed in the integrity-protected header.
135
+ *
136
+ * @opts
137
+ * {
138
+ * alg: string, // "ES256" | "ES384" | "ES512" | "EdDSA" | "ML-DSA-87"
139
+ * privateKey: object, // matching KeyObject or PEM
140
+ * kid?: string, // → unprotected header label 4
141
+ * contentType?: number, // → protected header label 3
142
+ * externalAad?: Buffer, // default empty — bound into the signature
143
+ * unprotectedHeaders?: object, // extra unprotected map entries (numeric keys)
144
+ * }
145
+ *
146
+ * @example
147
+ * var coseSign1 = await b.cose.sign(Buffer.from("statement"), {
148
+ * alg: "ES256", privateKey: ecKey, kid: "key-1",
149
+ * });
150
+ */
151
+ async function sign(payload, opts) {
152
+ validateOpts.requireObject(opts, "cose.sign", CoseError);
153
+ validateOpts(opts, ["alg", "privateKey", "kid", "contentType", "externalAad", "unprotectedHeaders"], "cose.sign");
154
+ if (SIGNABLE.indexOf(opts.alg) === -1) {
155
+ throw new CoseError("cose/unsignable-alg",
156
+ "cose.sign: alg must be one of " + SIGNABLE.join(" / ") +
157
+ " (SLH-DSA has no COSE algorithm id and is not offered)");
158
+ }
159
+ if (!opts.privateKey) {
160
+ throw new CoseError("cose/no-key", "cose.sign: opts.privateKey is required");
161
+ }
162
+ var algId = ALG_NAME_TO_ID[opts.alg];
163
+ var params = _algParamsFor(algId);
164
+ var key = _toKeyObject(opts.privateKey, "private");
165
+
166
+ var protMap = new Map();
167
+ protMap.set(HDR_ALG, algId);
168
+ if (typeof opts.contentType === "number") protMap.set(HDR_CONTENT_TYPE, opts.contentType);
169
+ var protectedBstr = cbor.encode(protMap);
170
+
171
+ var unprot = new Map();
172
+ if (typeof opts.kid === "string") unprot.set(HDR_KID, Buffer.from(opts.kid, "utf8"));
173
+ if (opts.unprotectedHeaders && typeof opts.unprotectedHeaders === "object") {
174
+ var uk = Object.keys(opts.unprotectedHeaders);
175
+ for (var i = 0; i < uk.length; i++) unprot.set(Number(uk[i]), opts.unprotectedHeaders[uk[i]]);
176
+ }
177
+
178
+ var payloadBytes = _bstr(payload);
179
+ var externalAad = opts.externalAad == null ? Buffer.alloc(0) : _bstr(opts.externalAad);
180
+ var toBeSigned = _toBeSigned(protectedBstr, externalAad, payloadBytes);
181
+
182
+ // ML-DSA-87 + EdDSA: the KeyObject specifies the algorithm, so a
183
+ // null digest name is correct. ECDSA: a digest + the IEEE-P1363
184
+ // fixed-width signature encoding COSE mandates (RFC 9053 §2.1, not
185
+ // ASN.1 DER).
186
+ var signature = (params.nodeAlg === null)
187
+ ? nodeCrypto.sign(null, toBeSigned, key)
188
+ : nodeCrypto.sign(params.nodeAlg, toBeSigned, { key: key, dsaEncoding: params.dsaEncoding });
189
+
190
+ var sign1 = [protectedBstr, unprot, payloadBytes, signature];
191
+ return cbor.encode(new cbor.Tag(COSE_SIGN1_TAG, sign1));
192
+ }
193
+
194
+ /**
195
+ * @primitive b.cose.verify
196
+ * @signature b.cose.verify(coseSign1, opts)
197
+ * @since 0.12.33
198
+ * @status experimental
199
+ * @related b.cose.sign, b.cbor.decode
200
+ *
201
+ * Verify a COSE_Sign1 (RFC 9052) and return its payload + headers.
202
+ * The bytes are decoded through the bounded <code>b.cbor</code> codec;
203
+ * <code>alg</code> is read from the integrity-protected header and must
204
+ * be in <code>opts.algorithms</code>; a <code>crit</code> header naming
205
+ * a label the verifier does not understand is refused. Accepts ML-DSA-87
206
+ * plus the classical ECDSA / EdDSA COSE algorithms.
207
+ *
208
+ * @opts
209
+ * {
210
+ * algorithms: string[], // required — accepted alg names (allowlist)
211
+ * publicKey?: object, // the verification key (KeyObject / PEM)
212
+ * keyResolver?: function, // (protectedHeaders, unprotectedHeaders) → key
213
+ * externalAad?: Buffer, // must match what was signed
214
+ * maxBytes?: number, // forwarded to b.cbor.decode
215
+ * maxDepth?: number,
216
+ * }
217
+ *
218
+ * @example
219
+ * var out = await b.cose.verify(coseSign1, { algorithms: ["ML-DSA-87"], publicKey: pub });
220
+ * // → { payload: <Buffer>, alg: "ML-DSA-87", protectedHeaders: Map, unprotectedHeaders: Map }
221
+ */
222
+ async function verify(coseSign1, opts) {
223
+ validateOpts.requireObject(opts, "cose.verify", CoseError);
224
+ validateOpts(opts, ["algorithms", "publicKey", "keyResolver", "externalAad", "maxBytes", "maxDepth"], "cose.verify");
225
+ if (!Array.isArray(opts.algorithms) || opts.algorithms.length === 0) {
226
+ throw new CoseError("cose/algorithms-required",
227
+ "cose.verify: opts.algorithms is required (no defaults — name the accepted algorithms)");
228
+ }
229
+ for (var ai = 0; ai < opts.algorithms.length; ai++) {
230
+ if (!(opts.algorithms[ai] in ALG_NAME_TO_ID)) {
231
+ throw new CoseError("cose/unknown-alg", "cose.verify: unknown algorithm '" + opts.algorithms[ai] + "'");
232
+ }
233
+ }
234
+ if (!opts.publicKey && typeof opts.keyResolver !== "function") {
235
+ throw new CoseError("cose/no-key", "cose.verify: pass publicKey or keyResolver");
236
+ }
237
+
238
+ var decoded = cbor.decode(_bstr(coseSign1), {
239
+ allowedTags: [COSE_SIGN1_TAG],
240
+ maxBytes: opts.maxBytes,
241
+ maxDepth: opts.maxDepth,
242
+ });
243
+ // Accept tagged (18) or bare COSE_Sign1 array.
244
+ var arr = (decoded instanceof cbor.Tag && decoded.tag === COSE_SIGN1_TAG) ? decoded.value : decoded;
245
+ if (!Array.isArray(arr) || arr.length !== 4) {
246
+ throw new CoseError("cose/malformed", "cose.verify: not a COSE_Sign1 (expected a 4-element array)");
247
+ }
248
+ var protectedBstr = arr[0];
249
+ var unprotected = arr[1];
250
+ var payload = arr[2];
251
+ var signature = arr[3];
252
+ if (!Buffer.isBuffer(protectedBstr) || !Buffer.isBuffer(signature)) {
253
+ throw new CoseError("cose/malformed", "cose.verify: protected header and signature must be byte strings");
254
+ }
255
+ if (payload === null || payload === undefined) {
256
+ throw new CoseError("cose/detached-unsupported",
257
+ "cose.verify: detached payload (nil) is not supported in v1 — attached payload only");
258
+ }
259
+ // COSE_Sign1 payload is a bstr (RFC 9052 §4.2) — refuse a non-byte
260
+ // payload rather than return a value that violates the documented
261
+ // { payload: Buffer } shape.
262
+ if (!Buffer.isBuffer(payload)) {
263
+ throw new CoseError("cose/malformed", "cose.verify: payload must be a byte string (bstr)");
264
+ }
265
+ // The unprotected header is a CBOR map — refuse a non-map rather
266
+ // than silently coerce it to empty (callers read kid etc. from it).
267
+ if (!(unprotected instanceof Map)) {
268
+ throw new CoseError("cose/malformed", "cose.verify: unprotected header must be a CBOR map");
269
+ }
270
+
271
+ // Decode the protected header (bounded) — empty bstr means no protected headers.
272
+ var protMap = protectedBstr.length === 0 ? new Map()
273
+ : cbor.decode(protectedBstr, { maxBytes: opts.maxBytes, maxDepth: opts.maxDepth });
274
+ if (!(protMap instanceof Map)) {
275
+ throw new CoseError("cose/malformed", "cose.verify: protected header is not a CBOR map");
276
+ }
277
+
278
+ // crit-bypass defense: every label in a crit array must be one the
279
+ // verifier understands AND must be present in the protected header.
280
+ if (protMap.has(HDR_CRIT)) {
281
+ var crit = protMap.get(HDR_CRIT);
282
+ if (!Array.isArray(crit)) {
283
+ throw new CoseError("cose/bad-crit", "cose.verify: crit (label 2) must be an array");
284
+ }
285
+ for (var ci = 0; ci < crit.length; ci++) {
286
+ if (UNDERSTOOD_LABELS.indexOf(crit[ci]) === -1) {
287
+ throw new CoseError("cose/crit-unknown",
288
+ "cose.verify: crit lists header label " + crit[ci] + " which is not understood (RFC 9052 §3.1)");
289
+ }
290
+ if (!protMap.has(crit[ci])) {
291
+ throw new CoseError("cose/crit-absent",
292
+ "cose.verify: crit lists label " + crit[ci] + " not present in the protected header");
293
+ }
294
+ }
295
+ }
296
+
297
+ var algId = protMap.get(HDR_ALG);
298
+ var algName = ALG_ID_TO_NAME[algId];
299
+ if (algName === undefined) {
300
+ throw new CoseError("cose/unknown-alg", "cose.verify: unrecognized protected alg id " + algId);
301
+ }
302
+ if (opts.algorithms.indexOf(algName) === -1) {
303
+ throw new CoseError("cose/alg-not-allowed",
304
+ "cose.verify: alg '" + algName + "' is not in the allowlist");
305
+ }
306
+ var params = _algParamsFor(algId); // throws cose/unknown-alg on an unrecognized id
307
+
308
+ var key = opts.publicKey
309
+ ? _toKeyObject(opts.publicKey, "public")
310
+ : _toKeyObject(opts.keyResolver(protMap, unprotected), "public");
311
+
312
+ var externalAad = opts.externalAad == null ? Buffer.alloc(0) : _bstr(opts.externalAad);
313
+ var toBeSigned = _toBeSigned(protectedBstr, externalAad, payload);
314
+
315
+ var ok;
316
+ if (params.nodeAlg === null) {
317
+ ok = nodeCrypto.verify(null, toBeSigned, key, signature);
318
+ } else {
319
+ ok = nodeCrypto.verify(params.nodeAlg, toBeSigned,
320
+ { key: key, dsaEncoding: params.dsaEncoding }, signature);
321
+ }
322
+ if (!ok) {
323
+ throw new CoseError("cose/bad-signature", "cose.verify: signature verification failed");
324
+ }
325
+ return {
326
+ payload: payload,
327
+ alg: algName,
328
+ protectedHeaders: protMap,
329
+ unprotectedHeaders: unprotected,
330
+ };
331
+ }
332
+
333
+ module.exports = {
334
+ sign: sign,
335
+ verify: verify,
336
+ ALGORITHMS: ALG_NAME_TO_ID,
337
+ COSE_SIGN1_TAG: COSE_SIGN1_TAG,
338
+ CoseError: CoseError,
339
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.12.31",
3
+ "version": "0.12.33",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:db45490d-0a47-4980-8047-83be6cb8f384",
5
+ "serialNumber": "urn:uuid:fc9aad21-216e-4059-88da-1a9dc8373559",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-24T19:33:14.548Z",
8
+ "timestamp": "2026-05-24T21:32:33.865Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.12.31",
22
+ "bom-ref": "@blamejs/core@0.12.33",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.12.31",
25
+ "version": "0.12.33",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.12.31",
29
+ "purl": "pkg:npm/%40blamejs/core@0.12.33",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.12.31",
57
+ "ref": "@blamejs/core@0.12.33",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]