@blamejs/core 0.14.9 → 0.14.11

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.
@@ -28,11 +28,16 @@
28
28
  * C2PA 2.1 content provenance — sign assets with a manifest declaring origin, edits, AI involvement.
29
29
  */
30
30
 
31
+ var nodeCrypto = require("node:crypto");
32
+ var C = require("./constants");
31
33
  var bCrypto = require("./crypto");
32
34
  var canonicalJson = require("./canonical-json");
33
35
  var safeJson = require("./safe-json");
34
36
  var validateOpts = require("./validate-opts");
35
37
  var audit = require("./audit");
38
+ var tsa = require("./tsa");
39
+ var cbor = require("./cbor");
40
+ var redact = require("./redact");
36
41
  var { defineClass } = require("./framework-error");
37
42
  var ContentCredentialsError = defineClass("ContentCredentialsError", { alwaysPermanent: true });
38
43
 
@@ -41,6 +46,7 @@ var ID_LEN_MAX = 128;
41
46
  var SEMVER_RE = /^[0-9]+\.[0-9]+(?:\.[0-9]+)?(?:[-+][A-Za-z0-9.-]+)?$/;
42
47
  var ID_RE = /^[a-zA-Z0-9._:/-]{1,128}$/;
43
48
  var SHA3_HEX_LEN = 128; // SHA3-512 hex length, not bytes
49
+ var C_PAYLOAD_MAX = C.BYTES.mib(1); // C2PA COSE payload / claims parse cap
44
50
 
45
51
  // Required fields per SB-942 §22757(a) — every AI-generated asset
46
52
  // must disclose provider + system + timestamp + contentId.
@@ -413,11 +419,71 @@ function _cborTag(tag) {
413
419
  return Buffer.from([0xDA, (tag >> 24) & 0xFF, (tag >> 16) & 0xFF, (tag >> 8) & 0xFF, tag & 0xFF]);
414
420
  }
415
421
 
422
+ // COSE unprotected-header labels carried on the COSE_Sign1 (IANA COSE
423
+ // header-parameter codepoints, RFC 9360 / RFC 9921 — integer labels,
424
+ // not durations or byte counts).
425
+ var HDR_X5CHAIN = 33; // x5chain (IANA COSE codepoint; see block comment above)
426
+ var HDR_SIG_TST2 = 35; // C2PA sigTst2 timestamp container (IANA COSE codepoint)
427
+
428
+ // C2PA COSE CounterSignature context string (RFC 9921 / C2PA 2.x
429
+ // Technical Spec §"Time-stamps"): the Sig_structure-shaped ToBeSigned a
430
+ // timestamp imprint is computed over is prefixed with this context so a
431
+ // countersignature cannot be confused with a primary COSE_Sign1.
432
+ var COUNTERSIGNATURE_CONTEXT = "CounterSignature";
433
+
434
+ // The countersignature imprint hash. Mirrored on BOTH the sign side
435
+ // (the digest handed to tsa.buildRequest as a pre-hashed imprint) and
436
+ // the verify side (handed to tsa.verifyToken as opts.hash) so the two
437
+ // imprint computations cannot drift. SHA-512 default; SHA-384 / 512 +
438
+ // SHA3 allowed via tsa.IMPRINT_HASHES; SHA-1 / MD5 are absent there.
439
+ var DEFAULT_TST_HASH = "SHA-512";
440
+
441
+ function _resolveTstHashAlg(hashAlg, fnName) {
442
+ var name = hashAlg || DEFAULT_TST_HASH;
443
+ // Reuse the TSA module's imprint-hash allowlist — never re-derive the
444
+ // permitted set here (a second list would be the drift the project's
445
+ // single-source-of-truth rule forbids).
446
+ if (!Object.prototype.hasOwnProperty.call(tsa.IMPRINT_HASHES, name)) {
447
+ throw ContentCredentialsError.factory("content-credentials/bad-tst-hash",
448
+ fnName + ": timestamp.hashAlg must be one of " +
449
+ Object.keys(tsa.IMPRINT_HASHES).join(" / "));
450
+ }
451
+ return name;
452
+ }
453
+
454
+ // Build the C2PA COSE CounterSignature ToBeSigned (RFC 9921 / C2PA 2.x):
455
+ // [ "CounterSignature",
456
+ // body_protected (= the COSE_Sign1 protected-header bstr),
457
+ // sign_protected (= empty bstr — no per-countersignature header),
458
+ // external_aad (= empty bstr),
459
+ // payload (= the COSE_Sign1 payload bstr),
460
+ // other (= the COSE_Sign1 signature bytes) ]
461
+ // CBOR-encoded locally with the same primitives the COSE_Sign1 uses, so
462
+ // the bytes the imprint covers are exactly the signed structure — never
463
+ // a chain-only or signature-only shortcut.
464
+ function _counterSignatureToBeSigned(protectedBstr, payloadBstr, sigBytes) {
465
+ var ctxBytes = Buffer.from(COUNTERSIGNATURE_CONTEXT, "utf8");
466
+ var ctxBstr;
467
+ if (ctxBytes.length < 24) ctxBstr = Buffer.concat([Buffer.from([0x60 | ctxBytes.length]), ctxBytes]); // CBOR text-string threshold
468
+ else ctxBstr = Buffer.concat([Buffer.from([0x78, ctxBytes.length]), ctxBytes]);
469
+ return Buffer.concat([
470
+ _cborArrayHeader(6),
471
+ ctxBstr, // "CounterSignature"
472
+ protectedBstr, // body_protected (already a bstr)
473
+ _cborBytes(Buffer.alloc(0)), // sign_protected (empty)
474
+ _cborBytes(Buffer.alloc(0)), // external_aad (empty)
475
+ payloadBstr, // payload (already a bstr)
476
+ _cborBytes(sigBytes), // other (the COSE_Sign1 signature)
477
+ ]);
478
+ }
479
+
416
480
  /**
417
481
  * @primitive b.contentCredentials.signCose
418
482
  * @signature b.contentCredentials.signCose(manifest, opts)
419
483
  * @since 0.8.77
420
- * @related b.contentCredentials.sign
484
+ * @status stable
485
+ * @compliance soc2
486
+ * @related b.contentCredentials.sign, b.contentCredentials.verifyCose, b.tsa.buildRequest, b.tsa.verifyToken, b.cose.sign
421
487
  *
422
488
  * C2PA 2.x interop sign — wraps the manifest in a COSE_Sign1 CBOR
423
489
  * envelope (RFC 9052) so the result interops with c2patool / JPEG
@@ -425,9 +491,35 @@ function _cborTag(tag) {
425
491
  * primitive ships a blamejs-internal envelope shape; this one ships
426
492
  * COSE bytes.
427
493
  *
428
- * Returns `{ manifest, coseSign1: Buffer, alg }`. Operators embed
429
- * the `coseSign1` Buffer in the image's C2PA box (JPEG XT marker,
430
- * PNG iTXt chunk, MP4 'jumb' box per C2PA §13).
494
+ * An RFC 3161 timestamp countersignature (`sigTst2`, RFC 9921 / C2PA 2.x
495
+ * Technical Spec) is attached when an `opts.timestamp` context is
496
+ * present, proving the manifest was signed before the timestamp
497
+ * authority's asserted time. The countersignature imprint is computed
498
+ * over the CounterSignature ToBeSigned — `[ "CounterSignature",
499
+ * body_protected, sign_protected (empty), external_aad (empty), payload,
500
+ * other (= the COSE_Sign1 signature) ]` — hashed with `timestamp.hashAlg`
501
+ * (default SHA-512; the allowed set is `b.tsa.IMPRINT_HASHES`), and the
502
+ * digest is handed to `b.tsa.buildRequest` as a PRE-HASHED imprint
503
+ * (`{ hashed: true, hashAlg }`). Two modes: pass `timestamp.token` (a DER
504
+ * TimeStampToken already obtained from a TSA) to attach it directly, or
505
+ * omit it to get back a `timestampRequest` (the DER bytes to POST as
506
+ * `application/timestamp-query`, the nonce to keep, and the
507
+ * ToBeSigned imprint) so the operator can fetch a token and re-call with
508
+ * it. Once attached, the token sits under COSE unprotected-header label
509
+ * 35 (`sigTst2`) alongside the x5chain (label 33).
510
+ *
511
+ * Timestamping is fail-closed: when no TSA context is supplied the call
512
+ * does NOT silently emit an un-timestamped signature — set
513
+ * `timestamp: false` with `timestampOptOutReason` to record an audited,
514
+ * deliberate opt-out. An un-timestamped C2PA claim is vulnerable to the
515
+ * key-compromise backdating class
516
+ * ([CVE-2025-52556](https://nvd.nist.gov/vuln/detail/CVE-2025-52556),
517
+ * timestamp-validation bypass) — opting out is an operator decision, not
518
+ * a default.
519
+ *
520
+ * Returns `{ manifest, coseSign1: Buffer, alg, timestamped, timestampRequest? }`.
521
+ * Operators embed the `coseSign1` Buffer in the image's C2PA box (JPEG XT
522
+ * marker, PNG iTXt chunk, MP4 'jumb' box per C2PA §13).
431
523
  *
432
524
  * @opts
433
525
  * {
@@ -436,6 +528,14 @@ function _cborTag(tag) {
436
528
  * "ml-dsa-44" | "ml-dsa-65" | "ml-dsa-87" |
437
529
  * "slh-dsa-shake-256f", // default "ml-dsa-87"
438
530
  * certChain?: Buffer[], // X.509 DER buffers; emitted as x5chain (header label 33)
531
+ * timestamp?: { // RFC 3161 sigTst2 countersignature (default ON when present)
532
+ * token?: Buffer, // a DER TimeStampToken to attach (mode a)
533
+ * signature?: string, // the base64 the request call returned — pins the
534
+ * // randomized COSE signature so the imprint matches
535
+ * trustAnchorsPem?: string|string[], // anchors echoed for later verifyCose
536
+ * hashAlg?: string, // default "SHA-512"; one of b.tsa.IMPRINT_HASHES
537
+ * } | false, // false = explicit opt-out (requires timestampOptOutReason)
538
+ * timestampOptOutReason?: string, // required when timestamp:false — audited
439
539
  * audit?: boolean, // default true
440
540
  * }
441
541
  *
@@ -445,9 +545,15 @@ function _cborTag(tag) {
445
545
  * provider: "Acme AI", system: "acme-v3",
446
546
  * systemVersion: "3.2.1", contentId: "img-001",
447
547
  * });
548
+ * // Request-builder mode: get the TSA query bytes to POST.
549
+ * var req = b.contentCredentials.signCose(manifest, {
550
+ * privateKeyPem: pair.privateKey, alg: "ml-dsa-87", timestamp: {},
551
+ * });
552
+ * // POST req.timestampRequest.der to the TSA, then re-call, re-supplying
553
+ * // the same signature so the countersigned imprint still matches:
448
554
  * var cose = b.contentCredentials.signCose(manifest, {
449
- * privateKeyPem: pair.privateKey,
450
- * alg: "ml-dsa-87",
555
+ * privateKeyPem: pair.privateKey, alg: "ml-dsa-87",
556
+ * timestamp: { token: tsaTokenDer, signature: req.timestampRequest.signature },
451
557
  * });
452
558
  * // cose.coseSign1 is the CBOR bytes to embed in the image's C2PA box.
453
559
  */
@@ -467,6 +573,14 @@ function signCose(manifest, opts) {
467
573
  }
468
574
  var algId = COSE_ALGS[algName];
469
575
 
576
+ // Timestamp posture is resolved up front so the fail-closed contract
577
+ // is decided before any signing work. Three states:
578
+ // - timestamp object present → countersign (attach or request).
579
+ // - timestamp:false → explicit opt-out (reason required).
580
+ // - timestamp absent → fail-closed: refuse to silently emit
581
+ // an un-timestamped C2PA signature.
582
+ var tsState = _resolveTimestampPosture(opts, "contentCredentials.signCose");
583
+
470
584
  // Protected header: map { 1: alg }
471
585
  var protBytes = Buffer.concat([
472
586
  _cborMapHeader(1),
@@ -475,30 +589,6 @@ function signCose(manifest, opts) {
475
589
  ]);
476
590
  var protectedBstr = _cborBytes(protBytes);
477
591
 
478
- // Unprotected header: map { 33: x5chain } when cert chain supplied;
479
- // else empty map {}.
480
- var unprotectedHdr;
481
- if (Array.isArray(opts.certChain) && opts.certChain.length > 0) {
482
- var chainArray;
483
- if (opts.certChain.length === 1) {
484
- // Single-cert form: header value is the DER bytes directly.
485
- chainArray = _cborBytes(opts.certChain[0]);
486
- } else {
487
- var chainBufs = [_cborArrayHeader(opts.certChain.length)];
488
- opts.certChain.forEach(function (der) {
489
- chainBufs.push(_cborBytes(der));
490
- });
491
- chainArray = Buffer.concat(chainBufs);
492
- }
493
- unprotectedHdr = Buffer.concat([
494
- _cborMapHeader(1),
495
- _cborInt(33), // allow:raw-time-literal — RFC 9360 x5chain COSE header label; coincidental multiple-of-60, not a duration, C.TIME N/A
496
- chainArray,
497
- ]);
498
- } else {
499
- unprotectedHdr = _cborMapHeader(0); // empty {}
500
- }
501
-
502
592
  // Payload — canonicalized manifest bytes.
503
593
  var canonicalPayload = Buffer.from(canonicalJson.stringify(manifest), "utf8");
504
594
  var payloadBstr = _cborBytes(canonicalPayload);
@@ -506,7 +596,7 @@ function signCose(manifest, opts) {
506
596
  // Sig_structure per RFC 9052 §4.4: ["Signature1", protected, external_aad="", payload]
507
597
  var sigStructureBufs = [
508
598
  _cborArrayHeader(4),
509
- Buffer.concat([_cborBytes(Buffer.from("Signature1", "utf8"))]),
599
+ null, // "Signature1" text string — filled in below
510
600
  protectedBstr,
511
601
  _cborBytes(Buffer.alloc(0)), // external_aad (empty)
512
602
  payloadBstr,
@@ -519,8 +609,87 @@ function signCose(manifest, opts) {
519
609
  sigStructureBufs[1] = sigTextBstr;
520
610
  var toBeSigned = Buffer.concat(sigStructureBufs);
521
611
 
522
- // Sign with framework's b.crypto.sign algorithm picked from the PEM.
523
- var signature = bCrypto.sign(toBeSigned, opts.privateKeyPem);
612
+ // The COSE_Sign1 signature. ML-DSA / SLH-DSA signatures are
613
+ // RANDOMIZED, so re-signing produces different bytes — and the
614
+ // sigTst2 countersignature binds the EXACT signature bytes. In the
615
+ // two-call request→attach flow the operator must therefore re-supply
616
+ // the same signature (`timestamp.signature`, the base64 the request
617
+ // call returned); when present it is decoded and verified to match
618
+ // this key before reuse, so a stale or foreign signature is refused.
619
+ var signature;
620
+ if (tsState.mode === "attach" && tsState.reuseSignature != null) {
621
+ var reused;
622
+ try { reused = Buffer.from(tsState.reuseSignature, "base64"); }
623
+ catch (_eReuse) {
624
+ throw ContentCredentialsError.factory("content-credentials/bad-reuse-signature",
625
+ "contentCredentials.signCose: timestamp.signature must be base64");
626
+ }
627
+ // The reused signature MUST verify against this manifest under this
628
+ // key — otherwise the embedded signature and the countersigned
629
+ // imprint would not correspond to the bytes actually signed.
630
+ if (!_publicKeyFromPrivatePem(opts.privateKeyPem, reused, toBeSigned)) {
631
+ throw ContentCredentialsError.factory("content-credentials/reuse-signature-mismatch",
632
+ "contentCredentials.signCose: timestamp.signature does not verify against this manifest + key");
633
+ }
634
+ signature = reused;
635
+ } else {
636
+ // Sign with framework's b.crypto.sign — algorithm picked from the PEM.
637
+ signature = bCrypto.sign(toBeSigned, opts.privateKeyPem);
638
+ }
639
+
640
+ // Request-builder mode: when a countersignature was asked for but no
641
+ // token was supplied, compute the CounterSignature ToBeSigned, hash it
642
+ // as the RFC 3161 imprint, and hand the PRE-HASHED digest to
643
+ // b.tsa.buildRequest. The operator POSTs the returned der, obtains a
644
+ // token, and re-calls signCose with timestamp.token + timestamp.signature
645
+ // set (the latter pins the same randomized signature). No token is
646
+ // fabricated here, so no timestamped signature is emitted yet.
647
+ var timestampRequest = null;
648
+ if (tsState.mode === "request") {
649
+ var reqTbs = _counterSignatureToBeSigned(protectedBstr, payloadBstr, signature);
650
+ var reqDigest = nodeCrypto
651
+ .createHash(tsa.IMPRINT_HASHES[tsState.hashAlg].nodeHash).update(reqTbs).digest();
652
+ // tsa.buildRequest opt shape (mirror of the verify side below):
653
+ // { hashed: true, hashAlg: <tsState.hashAlg> } — the digest is the imprint.
654
+ var built = tsa.buildRequest(reqDigest, { hashed: true, hashAlg: tsState.hashAlg });
655
+ timestampRequest = {
656
+ der: built.der,
657
+ nonce: built.nonce,
658
+ hashAlg: tsState.hashAlg,
659
+ toBeSigned: reqTbs,
660
+ messageImprint: built.messageImprint,
661
+ // The exact (randomized) signature this request was built over —
662
+ // re-supply as timestamp.signature on the follow-up attach call.
663
+ signature: signature.toString("base64"),
664
+ };
665
+ }
666
+
667
+ // Unprotected header: a CBOR map carrying x5chain (label 33) when a
668
+ // cert chain is supplied AND the sigTst2 timestamp container (label
669
+ // 35) when a token was attached.
670
+ var unprotEntries = [];
671
+ if (Array.isArray(opts.certChain) && opts.certChain.length > 0) {
672
+ var chainArray;
673
+ if (opts.certChain.length === 1) {
674
+ // Single-cert form: header value is the DER bytes directly.
675
+ chainArray = _cborBytes(opts.certChain[0]);
676
+ } else {
677
+ var chainBufs = [_cborArrayHeader(opts.certChain.length)];
678
+ opts.certChain.forEach(function (der) { chainBufs.push(_cborBytes(der)); });
679
+ chainArray = Buffer.concat(chainBufs);
680
+ }
681
+ unprotEntries.push({ label: HDR_X5CHAIN, value: chainArray });
682
+ }
683
+ if (tsState.mode === "attach") {
684
+ // sigTst2 tstContainer: a single TimeStampToken bstr under label 35.
685
+ unprotEntries.push({ label: HDR_SIG_TST2, value: _cborBytes(tsState.token) });
686
+ }
687
+ var unprotBufs = [_cborMapHeader(unprotEntries.length)];
688
+ for (var ue = 0; ue < unprotEntries.length; ue += 1) {
689
+ unprotBufs.push(_cborInt(unprotEntries[ue].label));
690
+ unprotBufs.push(unprotEntries[ue].value);
691
+ }
692
+ var unprotectedHdr = Buffer.concat(unprotBufs);
524
693
 
525
694
  // COSE_Sign1 = tagged-18 array [protected, unprotected, payload, signature]
526
695
  var coseSign1 = Buffer.concat([
@@ -535,24 +704,662 @@ function signCose(manifest, opts) {
535
704
  if (opts.audit !== false) {
536
705
  audit.safeEmit({
537
706
  action: "contentcredentials.signed_cose",
707
+ outcome: tsState.mode === "optout" ? "warning" : "success",
708
+ metadata: {
709
+ provider: manifest.provider && manifest.provider.name,
710
+ system: manifest.system && manifest.system.id,
711
+ contentId: manifest.content && manifest.content.id,
712
+ alg: algName,
713
+ bytes: coseSign1.length,
714
+ timestamped: tsState.mode === "attach",
715
+ timestampMode: tsState.mode,
716
+ timestampOptOutReason: tsState.mode === "optout" ? tsState.reason : undefined,
717
+ },
718
+ });
719
+ }
720
+
721
+ return {
722
+ manifest: manifest,
723
+ coseSign1: coseSign1,
724
+ alg: algName,
725
+ timestamped: tsState.mode === "attach",
726
+ timestampRequest: timestampRequest,
727
+ };
728
+ }
729
+
730
+ // Resolve the timestamp posture for signCose. THROWS (config-time tier)
731
+ // on a missing-or-malformed timestamp opt — an un-timestamped C2PA
732
+ // signature is a deliberate, audited operator decision, never a silent
733
+ // default. Returns one of:
734
+ // { mode: "attach", token, hashAlg } — a TimeStampToken to embed
735
+ // { mode: "request", hashAlg } — build a TSA query for later
736
+ // { mode: "optout", reason } — explicit, reasoned opt-out
737
+ function _resolveTimestampPosture(opts, fnName) {
738
+ if (opts.timestamp === false) {
739
+ // Explicit opt-out — require a recorded reason so the audit row
740
+ // explains why this C2PA claim carries no trusted time.
741
+ validateOpts.requireNonEmptyString(opts.timestampOptOutReason,
742
+ fnName + ": timestampOptOutReason (required when timestamp:false)",
743
+ ContentCredentialsError, "TIMESTAMP_OPT_OUT_NO_REASON");
744
+ return { mode: "optout", reason: opts.timestampOptOutReason };
745
+ }
746
+ if (opts.timestamp === undefined || opts.timestamp === null) {
747
+ throw ContentCredentialsError.factory("content-credentials/timestamp-required",
748
+ fnName + ": an RFC 3161 timestamp (sigTst2) is required — pass opts.timestamp " +
749
+ "({ token } to attach, or {} to get a TSA request) or opt out explicitly with " +
750
+ "timestamp:false + timestampOptOutReason. An un-timestamped C2PA claim is " +
751
+ "vulnerable to the key-compromise backdating class (CVE-2025-52556).");
752
+ }
753
+ if (typeof opts.timestamp !== "object" || Array.isArray(opts.timestamp)) {
754
+ throw ContentCredentialsError.factory("content-credentials/bad-timestamp",
755
+ fnName + ": opts.timestamp must be an object ({ token?, trustAnchorsPem?, hashAlg? }) or false");
756
+ }
757
+ validateOpts(opts.timestamp, ["token", "trustAnchorsPem", "hashAlg", "signature"], fnName + ".timestamp");
758
+ var hashAlg = _resolveTstHashAlg(opts.timestamp.hashAlg, fnName);
759
+ if (opts.timestamp.token !== undefined && opts.timestamp.token !== null) {
760
+ if (!Buffer.isBuffer(opts.timestamp.token)) {
761
+ throw ContentCredentialsError.factory("content-credentials/bad-timestamp-token",
762
+ fnName + ": timestamp.token must be a DER TimeStampToken Buffer");
763
+ }
764
+ var reuse = null;
765
+ if (opts.timestamp.signature !== undefined && opts.timestamp.signature !== null) {
766
+ if (typeof opts.timestamp.signature !== "string" || opts.timestamp.signature.length === 0) {
767
+ throw ContentCredentialsError.factory("content-credentials/bad-reuse-signature",
768
+ fnName + ": timestamp.signature must be the base64 signature the request call returned");
769
+ }
770
+ reuse = opts.timestamp.signature;
771
+ }
772
+ return { mode: "attach", token: opts.timestamp.token, hashAlg: hashAlg, reuseSignature: reuse };
773
+ }
774
+ return { mode: "request", hashAlg: hashAlg };
775
+ }
776
+
777
+ // Verify a candidate signature over `data` using the public half of the
778
+ // supplied private-key PEM — used to confirm a reused (randomized) COSE
779
+ // signature was produced by this key over this manifest before it is
780
+ // re-embedded. Returns true/false, never throws.
781
+ function _publicKeyFromPrivatePem(privateKeyPem, signature, data) {
782
+ try {
783
+ var pubPem = nodeCrypto.createPublicKey(privateKeyPem)
784
+ .export({ type: "spki", format: "pem" });
785
+ return bCrypto.verify(data, signature, pubPem);
786
+ } catch (_e) {
787
+ return false;
788
+ }
789
+ }
790
+
791
+ // Decode a COSE_Sign1 (tagged-18 or bare 4-element array) through the
792
+ // bounded b.cbor decoder. Returns { protectedBstr, unprotected (Map),
793
+ // payload (Buffer), signature (Buffer) } or throws a ContentCredentials
794
+ // error describing the malformed shape.
795
+ function _decodeCoseSign1(coseSign1) {
796
+ var decoded;
797
+ try {
798
+ decoded = cbor.decode(coseSign1, { allowedTags: [18] }); // COSE_Sign1 CBOR tag
799
+ } catch (e) {
800
+ throw ContentCredentialsError.factory("content-credentials/cose-malformed",
801
+ "verifyCose: not decodable CBOR: " + ((e && e.message) || e));
802
+ }
803
+ var arr = (decoded instanceof cbor.Tag && decoded.tag === 18) ? decoded.value : decoded;
804
+ if (!Array.isArray(arr) || arr.length !== 4) {
805
+ throw ContentCredentialsError.factory("content-credentials/cose-malformed",
806
+ "verifyCose: not a COSE_Sign1 (expected a 4-element array)");
807
+ }
808
+ if (!Buffer.isBuffer(arr[0]) || !Buffer.isBuffer(arr[2]) || !Buffer.isBuffer(arr[3])) {
809
+ throw ContentCredentialsError.factory("content-credentials/cose-malformed",
810
+ "verifyCose: protected header, payload, and signature must be byte strings");
811
+ }
812
+ if (!(arr[1] instanceof Map)) {
813
+ throw ContentCredentialsError.factory("content-credentials/cose-malformed",
814
+ "verifyCose: unprotected header must be a CBOR map");
815
+ }
816
+ return { protectedBstr: arr[0], unprotected: arr[1], payload: arr[2], signature: arr[3] };
817
+ }
818
+
819
+ /**
820
+ * @primitive b.contentCredentials.verifyCose
821
+ * @signature b.contentCredentials.verifyCose(coseSign1, publicKeyPem, opts)
822
+ * @since 0.14.11
823
+ * @status stable
824
+ * @compliance soc2
825
+ * @related b.contentCredentials.signCose, b.tsa.verifyToken, b.cose.verify, b.contentCredentials.verify
826
+ *
827
+ * Verify a COSE_Sign1 produced by `signCose` and, when present, its
828
+ * RFC 3161 `sigTst2` timestamp countersignature. The COSE_Sign1 bytes
829
+ * are decoded through the bounded `b.cbor` codec; the Sig_structure
830
+ * (RFC 9052 §4.4) is reconstructed and the signature verified with
831
+ * `b.crypto.verify` against the operator-supplied public-key PEM. When a
832
+ * timestamp token sits under unprotected-header label 35, its imprint is
833
+ * recomputed over the CounterSignature ToBeSigned with the same
834
+ * `hashAlg` (default SHA-512; the allowed set is `b.tsa.IMPRINT_HASHES`)
835
+ * and the digest is handed to `b.tsa.verifyToken` as a PRE-HASHED imprint
836
+ * (`{ hash, hashAlg }`).
837
+ *
838
+ * The ONLY timestamp-verification path is `b.tsa.verifyToken`, which
839
+ * performs the full RFC 3161 §2.4.2 / §2.3 check — the CMS signature over
840
+ * the signed attributes, the `messageDigest` recompute, and the critical,
841
+ * sole `id-kp-timeStamping` EKU — NOT a chain-only shortcut. A chain-only
842
+ * timestamp check is the
843
+ * [CVE-2025-52556](https://nvd.nist.gov/vuln/detail/CVE-2025-52556) /
844
+ * [CWE-347](https://cwe.mitre.org/data/definitions/347.html) improper-
845
+ * signature-verification class and is never done here. `b.tsa.verifyToken`
846
+ * throws on every failure; this primitive wraps that call and converts a
847
+ * thrown `TsaError` into `{ timestamp: { valid: false, reason } }` so
848
+ * `verifyCose` NEVER throws — it returns
849
+ * `{ valid, reason, claims, alg, timestamp }` fail-closed.
850
+ *
851
+ * `opts.requireTimestamp` (default true) fails closed when the COSE_Sign1
852
+ * carries no `sigTst2` token; set it false only when the operator
853
+ * deliberately accepts un-timestamped claims (mirrors `signCose`'s
854
+ * `timestamp:false` opt-out). `opts.timestampTrustAnchorsPem` enables the
855
+ * timestamp cert-chain + validity check inside `b.tsa.verifyToken`.
856
+ *
857
+ * @opts
858
+ * {
859
+ * requireTimestamp?: boolean, // default true — refuse a token-less COSE_Sign1
860
+ * timestampHashAlg?: string, // default "SHA-512"; one of b.tsa.IMPRINT_HASHES
861
+ * timestampTrustAnchorsPem?: string|string[], // anchors → b.tsa.verifyToken chain check
862
+ * timestampNonce?: Buffer, // require the token nonce to match
863
+ * audit?: boolean, // default true
864
+ * }
865
+ *
866
+ * @example
867
+ * var res = b.contentCredentials.verifyCose(cose.coseSign1, pair.publicKey, {
868
+ * timestampTrustAnchorsPem: tsaRootPem,
869
+ * });
870
+ * res.valid; // → true
871
+ * res.timestamp.valid; // → true
872
+ * res.timestamp.genTime; // → Date (the TSA-asserted signing time)
873
+ */
874
+ function verifyCose(coseSign1, publicKeyPem, opts) {
875
+ opts = opts || {};
876
+ var auditOn = opts.audit !== false;
877
+ if (!Buffer.isBuffer(coseSign1)) {
878
+ return { valid: false, reason: "cose-not-buffer", claims: null, alg: null, timestamp: null };
879
+ }
880
+ if (typeof publicKeyPem !== "string" || publicKeyPem.length === 0) {
881
+ return { valid: false, reason: "public-key-required", claims: null, alg: null, timestamp: null };
882
+ }
883
+ var requireTimestamp = opts.requireTimestamp !== false;
884
+ var hashAlg;
885
+ try { hashAlg = _resolveTstHashAlg(opts.timestampHashAlg, "verifyCose"); }
886
+ catch (e) { return { valid: false, reason: (e && e.code) || "bad-tst-hash", claims: null, alg: null, timestamp: null }; }
887
+
888
+ var parts;
889
+ try { parts = _decodeCoseSign1(coseSign1); }
890
+ catch (e2) { return { valid: false, reason: (e2 && e2.code) || "cose-malformed", claims: null, alg: null, timestamp: null }; }
891
+
892
+ // b.cbor.decode returns a bstr's CONTENT bytes (no CBOR header). The
893
+ // Sig_structure and CounterSignature ToBeSigned both embed the
894
+ // protected header / payload AS bstrs (header + content) — re-wrap the
895
+ // decoded content so the bytes match exactly what signCose signed over.
896
+ var protectedBstrFull = _cborBytes(parts.protectedBstr);
897
+ var payloadBstrFull = _cborBytes(parts.payload);
898
+
899
+ // The protected header is a CBOR map { 1: algId } — decode it for the
900
+ // returned alg name (informational; verify auto-detects from the PEM).
901
+ var algName = null;
902
+ try {
903
+ var protMap = parts.protectedBstr.length === 0 ? new Map() : cbor.decode(parts.protectedBstr);
904
+ if (protMap instanceof Map) {
905
+ var algId = protMap.get(1);
906
+ Object.keys(COSE_ALGS).forEach(function (k) { if (COSE_ALGS[k] === algId) algName = k; });
907
+ }
908
+ } catch (_eAlg) { algName = null; }
909
+
910
+ // Reconstruct Sig_structure (RFC 9052 §4.4) and verify the primary
911
+ // COSE_Sign1 signature with b.crypto.verify (auto-detects the PQC alg
912
+ // from the PEM).
913
+ var sigStructure = Buffer.concat([
914
+ _cborArrayHeader(4),
915
+ (function () {
916
+ var t = Buffer.from("Signature1", "utf8");
917
+ return t.length < 24 ? Buffer.concat([Buffer.from([0x60 | t.length]), t]) // CBOR text-string threshold
918
+ : Buffer.concat([Buffer.from([0x78, t.length]), t]);
919
+ })(),
920
+ protectedBstrFull,
921
+ _cborBytes(Buffer.alloc(0)), // external_aad (empty)
922
+ payloadBstrFull,
923
+ ]);
924
+ var sigOk;
925
+ try { sigOk = bCrypto.verify(sigStructure, parts.signature, publicKeyPem); }
926
+ catch (_eSig) { sigOk = false; }
927
+ if (!sigOk) {
928
+ if (auditOn) {
929
+ audit.safeEmit({ action: "contentcredentials.verified_cose", outcome: "denied",
930
+ metadata: { reason: "signature-mismatch", alg: algName } });
931
+ }
932
+ return { valid: false, reason: "signature-mismatch", claims: null, alg: algName, timestamp: null };
933
+ }
934
+
935
+ // Recover the manifest claims from the verified payload bytes.
936
+ var claims = null;
937
+ try { claims = safeJson.parse(parts.payload.toString("utf8"), { maxBytes: C_PAYLOAD_MAX }); }
938
+ catch (_eClaims) { claims = null; }
939
+
940
+ // sigTst2 (label 35) timestamp countersignature. THE ONLY verification
941
+ // path is b.tsa.verifyToken (full RFC 3161 §2.4.2/§2.3 — CMS signature
942
+ // + messageDigest recompute + critical sole id-kp-timeStamping EKU,
943
+ // NOT a chain-only check). It throws on every failure; we convert a
944
+ // thrown TsaError into { valid:false, reason } so verifyCose stays
945
+ // fail-closed and never throws.
946
+ var tstToken = parts.unprotected.get(HDR_SIG_TST2);
947
+ var timestamp = null;
948
+ if (Buffer.isBuffer(tstToken)) {
949
+ var tbs = _counterSignatureToBeSigned(protectedBstrFull, payloadBstrFull, parts.signature);
950
+ var digest = nodeCrypto.createHash(tsa.IMPRINT_HASHES[hashAlg].nodeHash).update(tbs).digest();
951
+ try {
952
+ // tsa.verifyToken opt shape (mirror of the sign-side buildRequest
953
+ // shape): { hash: <digest>, hashAlg, trustAnchorsPem?, nonce? } —
954
+ // the imprint is the pre-hashed CounterSignature ToBeSigned digest.
955
+ var verifyTokenOpts = { hash: digest, hashAlg: hashAlg };
956
+ if (opts.timestampTrustAnchorsPem !== undefined && opts.timestampTrustAnchorsPem !== null) {
957
+ verifyTokenOpts.trustAnchorsPem = opts.timestampTrustAnchorsPem;
958
+ }
959
+ if (opts.timestampNonce !== undefined && opts.timestampNonce !== null) {
960
+ verifyTokenOpts.nonce = opts.timestampNonce;
961
+ }
962
+ var tstOut = tsa.verifyToken(tstToken, verifyTokenOpts);
963
+ timestamp = {
964
+ valid: true,
965
+ reason: null,
966
+ genTime: tstOut.genTime,
967
+ policy: tstOut.policy,
968
+ serialHex: tstOut.serialHex,
969
+ hashAlg: tstOut.hashAlg,
970
+ };
971
+ } catch (eTst) {
972
+ // A TsaError (imprint-mismatch / bad-eku / untrusted-chain / …) is
973
+ // a failed timestamp, not a crash — record it fail-closed.
974
+ timestamp = { valid: false, reason: (eTst && eTst.code) || "timestamp-verify-failed",
975
+ genTime: null, policy: null, serialHex: null, hashAlg: hashAlg };
976
+ }
977
+ } else if (requireTimestamp) {
978
+ if (auditOn) {
979
+ audit.safeEmit({ action: "contentcredentials.verified_cose", outcome: "denied",
980
+ metadata: { reason: "timestamp-required", alg: algName } });
981
+ }
982
+ return { valid: false, reason: "timestamp-required", claims: claims, alg: algName,
983
+ timestamp: { valid: false, reason: "absent", genTime: null, policy: null, serialHex: null, hashAlg: hashAlg } };
984
+ }
985
+
986
+ // A present-but-invalid timestamp fails the whole verification closed.
987
+ if (timestamp && timestamp.valid === false) {
988
+ if (auditOn) {
989
+ audit.safeEmit({ action: "contentcredentials.verified_cose", outcome: "denied",
990
+ metadata: { reason: "timestamp-invalid:" + timestamp.reason, alg: algName } });
991
+ }
992
+ return { valid: false, reason: "timestamp-invalid:" + timestamp.reason, claims: claims,
993
+ alg: algName, timestamp: timestamp };
994
+ }
995
+
996
+ // SB-942 §22757(a) field-presence check on the verified payload —
997
+ // mirrors verify(). A cryptographically valid COSE_Sign1 over a
998
+ // non-manifest payload (e.g. {foo:"bar"}) or a manifest missing the
999
+ // disclosure fields must NOT verify as a content credential, even for
1000
+ // an opted-out / arbitrary-timestamped signature.
1001
+ var missingCose = required({
1002
+ provider: claims && claims.provider && claims.provider.name,
1003
+ system: claims && claims.system && claims.system.id,
1004
+ systemVersion: claims && claims.system && claims.system.version,
1005
+ contentId: claims && claims.content && claims.content.id,
1006
+ });
1007
+ if (missingCose.length > 0) {
1008
+ if (auditOn) {
1009
+ audit.safeEmit({ action: "contentcredentials.verified_cose", outcome: "denied",
1010
+ metadata: { reason: "missing-required:" + missingCose.join(","), alg: algName } });
1011
+ }
1012
+ return { valid: false, reason: "missing-required:" + missingCose.join(","), claims: claims,
1013
+ alg: algName, timestamp: timestamp };
1014
+ }
1015
+
1016
+ if (auditOn) {
1017
+ audit.safeEmit({
1018
+ action: "contentcredentials.verified_cose",
538
1019
  outcome: "success",
539
1020
  metadata: {
540
- provider: manifest.provider && manifest.provider.name,
541
- system: manifest.system && manifest.system.id,
542
- contentId: manifest.content && manifest.content.id,
543
- alg: algName,
544
- bytes: coseSign1.length,
1021
+ provider: claims && claims.provider && claims.provider.name,
1022
+ system: claims && claims.system && claims.system.id,
1023
+ contentId: claims && claims.content && claims.content.id,
1024
+ alg: algName,
1025
+ timestamped: !!(timestamp && timestamp.valid),
1026
+ },
1027
+ });
1028
+ }
1029
+ return { valid: true, reason: null, claims: claims, alg: algName, timestamp: timestamp };
1030
+ }
1031
+
1032
+ // ---- CAWG identity assertion (Identity Assertion v1.2) -----------
1033
+ //
1034
+ // The Creator Assertions Working Group (CAWG) Identity Assertion binds a
1035
+ // verifiable creator/organization identity to a C2PA manifest. Two
1036
+ // binding paths:
1037
+ // - "x509" — a signed organization identity. verified:true ONLY when
1038
+ // an identityTrustAnchorsPem is supplied AND the leaf chain
1039
+ // verifies to it. Self-presented x509 without a trusted
1040
+ // anchor is reported verified:false.
1041
+ // - "identity-claims-aggregator" — individual identity attested by an
1042
+ // aggregator. Self-asserted; never verified:true here (no
1043
+ // aggregator-key trust root is supplied in v1).
1044
+ // The signer_payload hash-binds the referenced assertions (a SHA3-512
1045
+ // digest over each canonicalized assertion) so the identity assertion
1046
+ // cannot be transplanted onto a different manifest's assertions.
1047
+
1048
+ var IDENTITY_BINDINGS = Object.freeze({ "x509": true, "identity-claims-aggregator": true });
1049
+
1050
+ // Hash-bind a referenced assertion (any JSON-serializable claim object)
1051
+ // to a stable SHA3-512 hex digest over its RFC 8785 canonical form.
1052
+ function _hashAssertion(assertion) {
1053
+ var canonical = canonicalJson.stringify(assertion);
1054
+ return nodeCrypto.createHash("sha3-512").update(Buffer.from(canonical, "utf8")).digest("hex");
1055
+ }
1056
+
1057
+ /**
1058
+ * @primitive b.contentCredentials.attachIdentityAssertion
1059
+ * @signature b.contentCredentials.attachIdentityAssertion(opts)
1060
+ * @since 0.14.11
1061
+ * @status stable
1062
+ * @compliance soc2, gdpr
1063
+ * @related b.contentCredentials.verifyIdentityAssertion, b.contentCredentials.signCose, b.crypto.sign
1064
+ *
1065
+ * Build a CAWG Identity Assertion v1.2 — a signed creator/organization
1066
+ * identity bound to a C2PA manifest's other assertions. The
1067
+ * `signer_payload` hash-binds each referenced assertion (a SHA3-512
1068
+ * digest over its RFC 8785 canonical form) so the identity statement
1069
+ * cannot be transplanted onto a different manifest. Two binding paths:
1070
+ * `"x509"` for a signed organization identity and
1071
+ * `"identity-claims-aggregator"` for an individual whose claims an
1072
+ * aggregator attests. The claim signature is produced with
1073
+ * `b.crypto.sign` (ML-DSA-87 by default).
1074
+ *
1075
+ * Self-asserted identity carries NO trust by itself — verification
1076
+ * (`verifyIdentityAssertion`) only reports `verified:true` for an
1077
+ * `x509` binding when a trust anchor is supplied and the chain verifies.
1078
+ * This matches the CAWG model: the assertion records a claim; trust comes
1079
+ * from the verifier's anchors, never from the claim's own bytes.
1080
+ *
1081
+ * @opts
1082
+ * {
1083
+ * binding: "x509" | "identity-claims-aggregator", // required
1084
+ * subject: object, // required — the asserted identity fields (name, id, org, …)
1085
+ * referencedAssertions: object[], // required — the manifest assertions this identity binds
1086
+ * privateKeyPem: string, // required — claim signing key
1087
+ * audit: boolean, // default true
1088
+ * }
1089
+ *
1090
+ * @example
1091
+ * var pair = b.crypto.generateSigningKeyPair("ml-dsa-87");
1092
+ * var ia = b.contentCredentials.attachIdentityAssertion({
1093
+ * binding: "x509",
1094
+ * subject: { name: "Acme Newsroom", org: "Acme Media", id: "acme-001" },
1095
+ * referencedAssertions: [{ label: "c2pa.actions", data: { action: "c2pa.created" } }],
1096
+ * privateKeyPem: pair.privateKey,
1097
+ * });
1098
+ * ia.signer_payload.referenced_assertions.length; // → 1
1099
+ * typeof ia.signature; // → "string"
1100
+ */
1101
+ function attachIdentityAssertion(opts) {
1102
+ opts = opts || {};
1103
+ validateOpts.requireObject(opts, "contentCredentials.attachIdentityAssertion", ContentCredentialsError);
1104
+ validateOpts(opts, ["binding", "subject", "referencedAssertions", "privateKeyPem", "audit"],
1105
+ "contentCredentials.attachIdentityAssertion");
1106
+ if (typeof opts.binding !== "string" || !IDENTITY_BINDINGS[opts.binding]) {
1107
+ throw ContentCredentialsError.factory("content-credentials/bad-identity-binding",
1108
+ "attachIdentityAssertion: binding must be one of " + Object.keys(IDENTITY_BINDINGS).join(" / "));
1109
+ }
1110
+ if (!opts.subject || typeof opts.subject !== "object" || Array.isArray(opts.subject)) {
1111
+ throw ContentCredentialsError.factory("content-credentials/bad-identity-subject",
1112
+ "attachIdentityAssertion: subject must be a non-empty object");
1113
+ }
1114
+ if (Object.keys(opts.subject).length === 0) {
1115
+ throw ContentCredentialsError.factory("content-credentials/bad-identity-subject",
1116
+ "attachIdentityAssertion: subject must carry at least one identity field");
1117
+ }
1118
+ if (!Array.isArray(opts.referencedAssertions) || opts.referencedAssertions.length === 0) {
1119
+ throw ContentCredentialsError.factory("content-credentials/bad-referenced-assertions",
1120
+ "attachIdentityAssertion: referencedAssertions must be a non-empty array");
1121
+ }
1122
+ validateOpts.requireNonEmptyString(opts.privateKeyPem,
1123
+ "attachIdentityAssertion: privateKeyPem", ContentCredentialsError, "BAD_KEY");
1124
+
1125
+ // signer_payload (CAWG §"Signer payload"): the asserted subject + the
1126
+ // hash-bound list of referenced assertions.
1127
+ var referenced = opts.referencedAssertions.map(function (a) {
1128
+ return { hash: _hashAssertion(a), alg: "sha3-512" };
1129
+ });
1130
+ var signerPayload = {
1131
+ binding: opts.binding,
1132
+ subject: opts.subject,
1133
+ referenced_assertions: referenced,
1134
+ };
1135
+ var canonical = canonicalJson.stringify(signerPayload);
1136
+ var signature = bCrypto.sign(Buffer.from(canonical, "utf8"), opts.privateKeyPem);
1137
+
1138
+ if (opts.audit !== false) {
1139
+ audit.safeEmit({
1140
+ action: "contentcredentials.identity_attached",
1141
+ outcome: "success",
1142
+ // PII minimization (T10): the asserted subject is operator-/self-
1143
+ // supplied identity — pass it through b.redact.redact before it
1144
+ // reaches the audit sink so a name / email / id can't leak raw.
1145
+ metadata: {
1146
+ binding: opts.binding,
1147
+ subject: redact.redact(opts.subject),
1148
+ referencedCount: referenced.length,
545
1149
  },
546
1150
  });
547
1151
  }
548
1152
 
549
1153
  return {
550
- manifest: manifest,
551
- coseSign1: coseSign1,
552
- alg: algName,
1154
+ type: "cawg.identity",
1155
+ version: "1.2",
1156
+ signer_payload: signerPayload,
1157
+ signature: signature.toString("base64"),
1158
+ };
1159
+ }
1160
+
1161
+ /**
1162
+ * @primitive b.contentCredentials.verifyIdentityAssertion
1163
+ * @signature b.contentCredentials.verifyIdentityAssertion(assertion, publicKeyPem, opts)
1164
+ * @since 0.14.11
1165
+ * @status stable
1166
+ * @compliance soc2, gdpr
1167
+ * @related b.contentCredentials.attachIdentityAssertion, b.crypto.verify, b.contentCredentials.verifyCose
1168
+ *
1169
+ * Verify a CAWG Identity Assertion v1.2 produced by
1170
+ * `attachIdentityAssertion`. Re-canonicalizes the `signer_payload`,
1171
+ * checks the claim signature with `b.crypto.verify`, and re-confirms the
1172
+ * hash-binding of every referenced assertion the operator re-supplies in
1173
+ * `opts.referencedAssertions` (so a valid signature over transplanted
1174
+ * assertions still fails closed). Never throws — returns
1175
+ * `{ valid, verified, binding, subject, reason }`.
1176
+ *
1177
+ * `valid` means the signature and assertion hash-binding check out.
1178
+ * `verified` is stricter and applies the CAWG trust model: it is
1179
+ * `true` ONLY for an `x509` binding when `opts.identityTrustAnchorsPem`
1180
+ * is supplied AND the leaf certificate chain verifies to a supplied
1181
+ * anchor. A self-asserted identity (no anchor, or the
1182
+ * `identity-claims-aggregator` path) is reported `verified:false` even
1183
+ * when `valid:true` — self-asserted data never yields `verified:true`
1184
+ * without a verified trust anchor
1185
+ * ([CVE-2026-34677](https://nvd.nist.gov/vuln/detail/CVE-2026-34677),
1186
+ * the unverified-identity-assertion trust-confusion class). Surfaced /
1187
+ * audited identity fields pass through `b.redact.redact` (PII
1188
+ * minimization).
1189
+ *
1190
+ * @opts
1191
+ * {
1192
+ * referencedAssertions: object[], // required — re-confirm the hash-binding
1193
+ * identityTrustAnchorsPem: string|string[], // x509 leaf-chain anchors (enables verified:true)
1194
+ * identityCertChainPem: string|string[], // x509 leaf + intermediates to chain-check
1195
+ * audit: boolean, // default true
1196
+ * }
1197
+ *
1198
+ * @example
1199
+ * var res = b.contentCredentials.verifyIdentityAssertion(ia, pair.publicKey, {
1200
+ * referencedAssertions: [{ label: "c2pa.actions", data: { action: "c2pa.created" } }],
1201
+ * identityTrustAnchorsPem: orgRootPem,
1202
+ * identityCertChainPem: orgLeafPem,
1203
+ * });
1204
+ * res.valid; // → true (signature + hash-binding)
1205
+ * res.verified; // → true (x509 leaf chained to a trusted anchor)
1206
+ */
1207
+ function verifyIdentityAssertion(assertion, publicKeyPem, opts) {
1208
+ opts = opts || {};
1209
+ var auditOn = opts.audit !== false;
1210
+ function _fail(reason) {
1211
+ return { valid: false, verified: false, binding: null, subject: null, reason: reason };
1212
+ }
1213
+ if (!assertion || typeof assertion !== "object" || !assertion.signer_payload || !assertion.signature) {
1214
+ return _fail("assertion-shape");
1215
+ }
1216
+ if (typeof publicKeyPem !== "string" || publicKeyPem.length === 0) {
1217
+ return _fail("public-key-required");
1218
+ }
1219
+ var sp = assertion.signer_payload;
1220
+ if (!sp || typeof sp !== "object" || typeof sp.binding !== "string" || !IDENTITY_BINDINGS[sp.binding] ||
1221
+ !Array.isArray(sp.referenced_assertions)) {
1222
+ return _fail("signer-payload-shape");
1223
+ }
1224
+
1225
+ // (1) signature over the canonicalized signer_payload.
1226
+ var canonical = canonicalJson.stringify(sp);
1227
+ var sigBuf;
1228
+ try { sigBuf = Buffer.from(assertion.signature, "base64"); }
1229
+ catch (_eB64) { return _fail("signature-base64-bad"); }
1230
+ var sigOk;
1231
+ try { sigOk = bCrypto.verify(Buffer.from(canonical, "utf8"), sigBuf, publicKeyPem); }
1232
+ catch (_eV) { sigOk = false; }
1233
+ if (!sigOk) {
1234
+ if (auditOn) {
1235
+ audit.safeEmit({ action: "contentcredentials.identity_verified", outcome: "denied",
1236
+ metadata: { binding: sp.binding, reason: "signature-mismatch" } });
1237
+ }
1238
+ return _fail("signature-mismatch");
1239
+ }
1240
+
1241
+ // (2) re-confirm the hash-binding of every referenced assertion the
1242
+ // caller re-supplies — a valid signature over transplanted
1243
+ // assertions must still fail closed.
1244
+ if (!Array.isArray(opts.referencedAssertions) || opts.referencedAssertions.length === 0) {
1245
+ return _fail("referenced-assertions-required");
1246
+ }
1247
+ var supplied = opts.referencedAssertions.map(_hashAssertion);
1248
+ var bound = sp.referenced_assertions.map(function (r) { return r && r.hash; });
1249
+ if (supplied.length !== bound.length) {
1250
+ return _fail("referenced-assertions-count-mismatch");
1251
+ }
1252
+ for (var i = 0; i < supplied.length; i += 1) {
1253
+ if (bound.indexOf(supplied[i]) === -1) {
1254
+ if (auditOn) {
1255
+ audit.safeEmit({ action: "contentcredentials.identity_verified", outcome: "denied",
1256
+ metadata: { binding: sp.binding, reason: "assertion-hash-mismatch" } });
1257
+ }
1258
+ return _fail("assertion-hash-mismatch");
1259
+ }
1260
+ }
1261
+
1262
+ // (3) trust resolution. verified:true ONLY for x509 with a supplied
1263
+ // anchor that the leaf chain verifies to. Self-asserted data
1264
+ // (no anchor, or the aggregator path) stays verified:false even
1265
+ // though valid:true.
1266
+ var verified = false;
1267
+ var trustReason = null;
1268
+ if (sp.binding === "x509" &&
1269
+ opts.identityTrustAnchorsPem !== undefined && opts.identityTrustAnchorsPem !== null) {
1270
+ var chainRes = _verifyIdentityX509Chain(opts.identityCertChainPem, opts.identityTrustAnchorsPem);
1271
+ verified = chainRes.ok;
1272
+ trustReason = chainRes.reason;
1273
+ } else if (sp.binding === "identity-claims-aggregator") {
1274
+ trustReason = "aggregator-self-asserted";
1275
+ } else {
1276
+ trustReason = "no-trust-anchor";
1277
+ }
1278
+
1279
+ if (auditOn) {
1280
+ audit.safeEmit({
1281
+ action: "contentcredentials.identity_verified",
1282
+ outcome: verified ? "success" : "warning",
1283
+ metadata: {
1284
+ binding: sp.binding,
1285
+ verified: verified,
1286
+ reason: trustReason,
1287
+ // PII minimization (T10) — redact the asserted subject fields.
1288
+ subject: redact.redact(sp.subject),
1289
+ },
1290
+ });
1291
+ }
1292
+
1293
+ return {
1294
+ valid: true,
1295
+ verified: verified,
1296
+ binding: sp.binding,
1297
+ subject: sp.subject,
1298
+ reason: trustReason,
553
1299
  };
554
1300
  }
555
1301
 
1302
+ // Verify an x509 identity leaf chains to a supplied trust anchor and is
1303
+ // currently valid. Returns { ok, reason } — never throws. Accepts a
1304
+ // single PEM string or an array for both the chain and the anchors.
1305
+ function _verifyIdentityX509Chain(certChainPem, trustAnchorsPem) {
1306
+ var chain = typeof certChainPem === "string" ? [certChainPem]
1307
+ : (Array.isArray(certChainPem) ? certChainPem : null);
1308
+ if (!chain || chain.length === 0) {
1309
+ return { ok: false, reason: "no-cert-chain" };
1310
+ }
1311
+ var anchors = typeof trustAnchorsPem === "string" ? [trustAnchorsPem]
1312
+ : (Array.isArray(trustAnchorsPem) ? trustAnchorsPem : null);
1313
+ if (!anchors || anchors.length === 0 ||
1314
+ !anchors.every(function (a) { return typeof a === "string" && a.length > 0; })) {
1315
+ return { ok: false, reason: "bad-trust-anchors" };
1316
+ }
1317
+ var certs, anchorCerts;
1318
+ try { certs = chain.map(function (p) { return new nodeCrypto.X509Certificate(p); }); }
1319
+ catch (_eChain) { return { ok: false, reason: "bad-chain-cert" }; }
1320
+ try { anchorCerts = anchors.map(function (a) { return new nodeCrypto.X509Certificate(a); }); }
1321
+ catch (_eAnchor) { return { ok: false, reason: "bad-anchor-cert" }; }
1322
+
1323
+ var now = Date.now();
1324
+ // Every cert in the presented chain must currently be valid.
1325
+ for (var ci = 0; ci < certs.length; ci += 1) {
1326
+ if (now < certs[ci].validFromDate.getTime() || now > certs[ci].validToDate.getTime()) {
1327
+ return { ok: false, reason: ci === 0 ? "leaf-expired" : "chain-cert-expired" };
1328
+ }
1329
+ }
1330
+ // Walk the presented chain: each cert must be issued AND signed by the
1331
+ // next cert up. A [leaf, intermediate] chain links leaf→intermediate
1332
+ // here, then the top (intermediate) is matched against the anchors
1333
+ // below — so identities signed through an intermediate CA verify, not
1334
+ // only direct-root / self-signed leaves.
1335
+ for (var li = 0; li < certs.length - 1; li += 1) {
1336
+ var child = certs[li], parent = certs[li + 1];
1337
+ var linked = false;
1338
+ try { linked = child.checkIssued(parent) && child.verify(parent.publicKey); }
1339
+ catch (_eLink) { linked = false; }
1340
+ if (!linked) { return { ok: false, reason: "broken-chain" }; }
1341
+ }
1342
+ // The top of the presented chain must chain to (or BE) a trust anchor.
1343
+ var top = certs[certs.length - 1];
1344
+ for (var a = 0; a < anchorCerts.length; a += 1) {
1345
+ var anchor = anchorCerts[a];
1346
+ var chained = false;
1347
+ if (top.fingerprint256 === anchor.fingerprint256) {
1348
+ chained = true; // top of the chain IS the anchor (root-in-chain or self-signed leaf == anchor)
1349
+ } else {
1350
+ try { chained = top.checkIssued(anchor) && top.verify(anchor.publicKey); }
1351
+ catch (_eChk) { chained = false; }
1352
+ }
1353
+ if (chained) {
1354
+ if (now < anchor.validFromDate.getTime() || now > anchor.validToDate.getTime()) {
1355
+ return { ok: false, reason: "anchor-expired" };
1356
+ }
1357
+ return { ok: true, reason: "x509-chain-verified" };
1358
+ }
1359
+ }
1360
+ return { ok: false, reason: "untrusted-chain" };
1361
+ }
1362
+
556
1363
  /**
557
1364
  * @primitive b.contentCredentials.cacImplicitLabel
558
1365
  * @signature b.contentCredentials.cacImplicitLabel(opts)
@@ -694,8 +1501,11 @@ module.exports = {
694
1501
  build: build,
695
1502
  sign: sign,
696
1503
  signCose: signCose,
1504
+ verifyCose: verifyCose,
697
1505
  verify: verify,
698
1506
  required: required,
1507
+ attachIdentityAssertion: attachIdentityAssertion,
1508
+ verifyIdentityAssertion: verifyIdentityAssertion,
699
1509
  cacImplicitLabel: cacImplicitLabel,
700
1510
  cacImplicitLabelRead: cacImplicitLabelRead,
701
1511
  REQUIRED_FIELDS: REQUIRED_FIELDS.slice(),