@blamejs/core 0.14.10 → 0.14.12
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 +4 -0
- package/README.md +6 -3
- package/index.js +4 -0
- package/lib/agent-idempotency.js +113 -0
- package/lib/agent-orchestrator.js +108 -0
- package/lib/agent-snapshot.js +137 -0
- package/lib/agent-tenant.js +193 -17
- package/lib/ai-input.js +167 -3
- package/lib/ai-output.js +463 -0
- package/lib/ai-prompt.js +304 -0
- package/lib/archive-wrap.js +234 -1
- package/lib/archive.js +1 -0
- package/lib/audit.js +2 -0
- package/lib/cluster.js +186 -14
- package/lib/codepoint-class.js +18 -0
- package/lib/compliance-ai-act.js +446 -0
- package/lib/content-credentials.js +851 -41
- package/lib/crypto-field.js +5 -0
- package/lib/db.js +15 -0
- package/lib/framework-error.js +16 -0
- package/lib/validate-opts.js +24 -0
- package/lib/vault/rotate.js +175 -15
- package/lib/vault-aad.js +84 -33
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -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
|
-
* @
|
|
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
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
523
|
-
|
|
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:
|
|
541
|
-
system:
|
|
542
|
-
contentId:
|
|
543
|
-
alg:
|
|
544
|
-
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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(),
|