@svta/cml-c2pa 1.0.0 → 1.1.0

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/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { decode } from "cbor-x/decode";
1
+ import { decode, decodeMultiple } from "cbor-x/decode";
2
2
  import { encode } from "cbor-x/encode";
3
3
  import { findIsoBox, readEmsg, readIsoBoxes } from "@svta/cml-iso-bmff";
4
4
 
@@ -19,6 +19,7 @@ const LiveVideoStatusCode = {
19
19
  SEGMENT_INVALID: "livevideo.segment.invalid",
20
20
  ASSERTION_INVALID: "livevideo.assertion.invalid",
21
21
  CONTINUITY_METHOD_INVALID: "livevideo.continuityMethod.invalid",
22
+ CONTINUITY_METHOD_UNSUPPORTED: "livevideo.continuityMethod.unsupported",
22
23
  SESSIONKEY_INVALID: "livevideo.sessionkey.invalid"
23
24
  };
24
25
 
@@ -56,7 +57,7 @@ function toUint8Array$1(value) {
56
57
  * @example
57
58
  * {@includeCode ../../test/cose/decodeCoseSign1.test.ts#example}
58
59
  *
59
- * @public
60
+ * @internal
60
61
  */
61
62
  function decodeCoseSign1(coseBytes) {
62
63
  try {
@@ -109,7 +110,7 @@ function parseJumbfBoxes(bytes) {
109
110
 
110
111
  //#endregion
111
112
  //#region src/jumbf/parseJumbfLabel.ts
112
- const TEXT_DECODER$2 = new TextDecoder();
113
+ const TEXT_DECODER$3 = /* @__PURE__ */ new TextDecoder();
113
114
  const JUMD_UUID_SIZE = 16;
114
115
  const JUMD_TOGGLES_OFFSET = 16;
115
116
  const JUMD_LABEL_START = 17;
@@ -135,7 +136,7 @@ function parseJumbfLabel(jumdData) {
135
136
  let end = JUMD_LABEL_START;
136
137
  while (end < jumdData.length && jumdData[end] !== 0) end++;
137
138
  if (end >= jumdData.length) return null;
138
- return TEXT_DECODER$2.decode(jumdData.subarray(JUMD_LABEL_START, end));
139
+ return TEXT_DECODER$3.decode(jumdData.subarray(JUMD_LABEL_START, end));
139
140
  }
140
141
 
141
142
  //#endregion
@@ -152,7 +153,7 @@ const SHA_ALGORITHM_PATTERN = /^sha(\d+)$/i;
152
153
  function normalizeAlgorithmName(rawAlg) {
153
154
  return (rawAlg ?? "SHA-256").replace(SHA_ALGORITHM_PATTERN, "SHA-$1");
154
155
  }
155
- const HEX_TABLE = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0"));
156
+ const HEX_TABLE = /* @__PURE__ */ Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0"));
156
157
  /**
157
158
  * Converts a Uint8Array to a lowercase hex string.
158
159
  *
@@ -208,6 +209,7 @@ const C2PA_MANIFEST_UUID = [
208
209
  16,
209
210
  234
210
211
  ];
212
+ /** @internal */
211
213
  const JUMBF_UUID = [
212
214
  216,
213
215
  254,
@@ -226,9 +228,20 @@ const JUMBF_UUID = [
226
228
  196,
227
229
  129
228
230
  ];
231
+ /** @internal */
229
232
  function matchesUuid(usertype, expected) {
230
233
  return usertype.length === expected.length && expected.every((b, i) => b === usertype[i]);
231
234
  }
235
+ /** @internal */
236
+ function toUint8Array(value) {
237
+ if (value instanceof Uint8Array) return value;
238
+ if (Array.isArray(value)) return new Uint8Array(value);
239
+ return null;
240
+ }
241
+ /** @internal */
242
+ function asInteger(value) {
243
+ return typeof value === "number" && Number.isInteger(value) ? value : null;
244
+ }
232
245
  function isC2paUuid(usertype) {
233
246
  return matchesUuid(usertype, C2PA_MANIFEST_UUID) || matchesUuid(usertype, JUMBF_UUID);
234
247
  }
@@ -245,6 +258,18 @@ function findC2paUuidBox(boxes) {
245
258
  }
246
259
  const FULLBOX_HEADER_SIZE = 4;
247
260
  const AUX_UUID_OFFSET_SIZE = 8;
261
+ const TEXT_DECODER$2 = new TextDecoder();
262
+ /** @internal */
263
+ function readUuidBoxPurpose(payload) {
264
+ if (payload.length < FULLBOX_HEADER_SIZE) return null;
265
+ let offset = FULLBOX_HEADER_SIZE;
266
+ while (offset < payload.length && payload[offset] !== 0) offset++;
267
+ if (offset >= payload.length) return null;
268
+ return {
269
+ purpose: TEXT_DECODER$2.decode(payload.subarray(FULLBOX_HEADER_SIZE, offset)),
270
+ rest: payload.subarray(offset + 1)
271
+ };
272
+ }
248
273
  /**
249
274
  * Strips the JUMBF UUID box prefix (fullbox header, purpose string, aux offset)
250
275
  * to return only the JUMBF manifest data.
@@ -257,14 +282,9 @@ const AUX_UUID_OFFSET_SIZE = 8;
257
282
  * @internal
258
283
  */
259
284
  function stripJumbfUuidPrefix(payload) {
260
- if (payload.length < FULLBOX_HEADER_SIZE) return null;
261
- let offset = FULLBOX_HEADER_SIZE;
262
- while (offset < payload.length && payload[offset] !== 0) offset++;
263
- if (offset >= payload.length) return null;
264
- offset++;
265
- offset += AUX_UUID_OFFSET_SIZE;
266
- if (offset > payload.length) return null;
267
- return payload.subarray(offset);
285
+ const prefix = readUuidBoxPurpose(payload);
286
+ if (!prefix || prefix.rest.length < AUX_UUID_OFFSET_SIZE) return null;
287
+ return prefix.rest.subarray(AUX_UUID_OFFSET_SIZE);
268
288
  }
269
289
 
270
290
  //#endregion
@@ -327,7 +347,7 @@ function readElementRaw(bytes, offset) {
327
347
 
328
348
  //#endregion
329
349
  //#region src/x509/extractCertificateInfo.ts
330
- const TEXT_DECODER$1 = new TextDecoder();
350
+ const TEXT_DECODER$1 = /* @__PURE__ */ new TextDecoder();
331
351
  const OID_COMMON_NAME = new Uint8Array([
332
352
  85,
333
353
  4,
@@ -389,7 +409,7 @@ function findRDNValue(issuerValue, targetOID) {
389
409
  * @example
390
410
  * {@includeCode ../../test/x509/extractCertificateInfo.test.ts#example}
391
411
  *
392
- * @public
412
+ * @internal
393
413
  */
394
414
  function extractCertificateInfo(certDER) {
395
415
  try {
@@ -431,7 +451,7 @@ function extractCertificateInfo(certDER) {
431
451
 
432
452
  //#endregion
433
453
  //#region src/readC2paManifest.ts
434
- const TEXT_DECODER = new TextDecoder();
454
+ const TEXT_DECODER = /* @__PURE__ */ new TextDecoder();
435
455
  const MAX_JUMBF_NESTING_DEPTH = 20;
436
456
  const X5CHAIN_HEADER_LABEL = 33;
437
457
  function resolveManifestBoxes(jumbfBoxes, depth = 0) {
@@ -735,7 +755,7 @@ function buildHashInput(bytes, exclusions, offsetPrefixSize) {
735
755
  * @example
736
756
  * {@includeCode ../../test/bmff/computeBmffHash.test.ts#example}
737
757
  *
738
- * @public
758
+ * @internal
739
759
  */
740
760
  async function computeBmffHash(segmentBytes, options) {
741
761
  const exclusions = options?.exclusions ?? [];
@@ -745,35 +765,6 @@ async function computeBmffHash(segmentBytes, options) {
745
765
  return new Uint8Array(hashBuffer);
746
766
  }
747
767
 
748
- //#endregion
749
- //#region src/bmff/validateBmffHash.ts
750
- const OFFSET_PREFIX_SIZES_TO_TRY = [8, 0];
751
- /**
752
- * Validates the C2PA BMFF content hash (`c2pa.hash.bmff.v3`) for a DASH segment.
753
- *
754
- * Tries both offset-prefix modes — 8-byte file offset prefix and no prefix — to handle
755
- * inter-signer interoperability until a standard signaling mechanism is defined in the spec.
756
- *
757
- * Use {@link computeBmffHash} directly when the offset prefix size is known.
758
- *
759
- * @param segmentBytes - Raw segment bytes to validate
760
- * @param expectedHash - Expected hash bytes from the VSI CBOR map
761
- * @param options - Validation options (exclusions, algorithm)
762
- * @returns `true` if the computed hash matches the expected hash in either offset mode
763
- *
764
- * @example
765
- * {@includeCode ../../test/bmff/validateBmffHash.test.ts#example}
766
- *
767
- * @public
768
- */
769
- async function validateBmffHash(segmentBytes, expectedHash, options) {
770
- for (const offsetPrefixSize of OFFSET_PREFIX_SIZES_TO_TRY) if (hashesEqual(await computeBmffHash(segmentBytes, {
771
- ...options,
772
- offsetPrefixSize
773
- }), expectedHash)) return true;
774
- return false;
775
- }
776
-
777
768
  //#endregion
778
769
  //#region src/C2paStatusCode.ts
779
770
  /**
@@ -790,7 +781,9 @@ const C2paStatusCode = {
790
781
  ASSERTION_HASHEDURI_MISMATCH: "assertion.hashedURI.mismatch",
791
782
  ASSERTION_MISSING: "assertion.missing",
792
783
  ASSERTION_ACTION_INGREDIENT_MISMATCH: "assertion.action.ingredientMismatch",
793
- CLAIM_SIGNATURE_MISMATCH: "claim.signature.mismatch"
784
+ CLAIM_SIGNATURE_MISMATCH: "claim.signature.mismatch",
785
+ ASSERTION_BMFFHASH_MALFORMED: "assertion.bmffHash.malformed",
786
+ ASSERTION_BMFFHASH_MISMATCH: "assertion.bmffHash.mismatch"
794
787
  };
795
788
 
796
789
  //#endregion
@@ -948,7 +941,7 @@ const CBOR_UINT32_LENGTH_INDICATOR = 90;
948
941
  const CBOR_INLINE_MAX = 23;
949
942
  const CBOR_UINT8_MAX = 255;
950
943
  const CBOR_UINT16_MAX = 65535;
951
- const COSE_SIG1_CONTEXT_BYTES = new TextEncoder().encode("Signature1");
944
+ const COSE_SIG1_CONTEXT_BYTES = /* @__PURE__ */ new TextEncoder().encode("Signature1");
952
945
  function byteStringHeaderSize(length) {
953
946
  if (length <= CBOR_INLINE_MAX) return 1;
954
947
  if (length <= CBOR_UINT8_MAX) return 2;
@@ -1423,6 +1416,117 @@ async function verifySignerBinding(signerBindingBytes, sessionCoseKey, signerCer
1423
1416
  return verifyCoseSign1(coseSign1, encode(signerCertBytes), publicKey);
1424
1417
  }
1425
1418
 
1419
+ //#endregion
1420
+ //#region src/bmff/parseExclusions.ts
1421
+ function parseConstraints(rawConstraints) {
1422
+ if (!Array.isArray(rawConstraints)) return [];
1423
+ const constraints = [];
1424
+ for (const c of rawConstraints) {
1425
+ if (!c || typeof c !== "object") continue;
1426
+ const record = c;
1427
+ if (typeof record["offset"] !== "number") continue;
1428
+ const value = toUint8Array(record["value"]);
1429
+ if (value) constraints.push({
1430
+ offset: record["offset"],
1431
+ value
1432
+ });
1433
+ }
1434
+ return constraints;
1435
+ }
1436
+ /**
1437
+ * Parses the `exclusions` array of a `c2pa.hash.bmff.v3` assertion.
1438
+ *
1439
+ * @internal
1440
+ */
1441
+ function parseExclusions(rawExclusions) {
1442
+ if (!Array.isArray(rawExclusions)) return [];
1443
+ const exclusions = [];
1444
+ for (const exc of rawExclusions) {
1445
+ if (!exc || typeof exc !== "object") continue;
1446
+ const record = exc;
1447
+ if (typeof record["xpath"] !== "string") continue;
1448
+ const constraints = parseConstraints(record["data"]);
1449
+ exclusions.push(constraints.length > 0 ? {
1450
+ xpath: record["xpath"],
1451
+ data: constraints
1452
+ } : { xpath: record["xpath"] });
1453
+ }
1454
+ return exclusions;
1455
+ }
1456
+
1457
+ //#endregion
1458
+ //#region src/merkle/validateMerkleMaps.ts
1459
+ function parseMerkleRow(rawHashes) {
1460
+ if (!Array.isArray(rawHashes) || rawHashes.length === 0) return null;
1461
+ const row = [];
1462
+ for (const entry of rawHashes) {
1463
+ const bytes = toUint8Array(entry);
1464
+ if (!bytes) return null;
1465
+ row.push(bytes);
1466
+ }
1467
+ return row;
1468
+ }
1469
+ function extractMerkleMaps(bmffHashAssertionData) {
1470
+ const rawMerkle = bmffHashAssertionData["merkle"];
1471
+ if (!Array.isArray(rawMerkle) || rawMerkle.length === 0) return null;
1472
+ const exclusions = parseExclusions(bmffHashAssertionData["exclusions"]);
1473
+ const assertionAlg = bmffHashAssertionData["alg"];
1474
+ const offsetPrefixSize = bmffHashAssertionData["hash"] == null ? 8 : 0;
1475
+ const maps = [];
1476
+ for (const entry of rawMerkle) {
1477
+ if (!entry || typeof entry !== "object") return null;
1478
+ const record = entry;
1479
+ const uniqueId = asInteger(record["uniqueId"]);
1480
+ const localId = asInteger(record["localId"]);
1481
+ const count = asInteger(record["count"]);
1482
+ const hashes = parseMerkleRow(record["hashes"]);
1483
+ if (uniqueId === null || localId === null || count === null || !hashes) return null;
1484
+ const initHash = toUint8Array(record["initHash"]);
1485
+ if (!initHash) return null;
1486
+ const rawAlg = record["alg"] ?? assertionAlg;
1487
+ if (typeof rawAlg !== "string") return null;
1488
+ maps.push({
1489
+ uniqueId,
1490
+ localId,
1491
+ count,
1492
+ hashes,
1493
+ initHash,
1494
+ alg: normalizeAlgorithmName(rawAlg),
1495
+ exclusions,
1496
+ offsetPrefixSize
1497
+ });
1498
+ }
1499
+ return maps;
1500
+ }
1501
+ async function validateMerkleMaps(bytes, assertion, codes) {
1502
+ const data = assertion?.data;
1503
+ if (data === null || typeof data !== "object" || Array.isArray(data)) return null;
1504
+ if (data["merkle"] === void 0) return null;
1505
+ const merkleMaps = extractMerkleMaps(data);
1506
+ if (!merkleMaps) {
1507
+ codes.add(C2paStatusCode.ASSERTION_BMFFHASH_MALFORMED);
1508
+ return [];
1509
+ }
1510
+ const initHashByAlg = /* @__PURE__ */ new Map();
1511
+ for (const merkleMap of merkleMaps) {
1512
+ if (!merkleMap.initHash) continue;
1513
+ let computed = initHashByAlg.get(merkleMap.alg);
1514
+ if (!computed) {
1515
+ computed = await computeBmffHash(bytes, {
1516
+ offsetPrefixSize: merkleMap.offsetPrefixSize,
1517
+ exclusions: merkleMap.exclusions,
1518
+ alg: merkleMap.alg ?? void 0
1519
+ });
1520
+ initHashByAlg.set(merkleMap.alg, computed);
1521
+ }
1522
+ if (!hashesEqual(computed, merkleMap.initHash)) {
1523
+ codes.add(LiveVideoStatusCode.INIT_INVALID);
1524
+ codes.add(C2paStatusCode.ASSERTION_BMFFHASH_MISMATCH);
1525
+ }
1526
+ }
1527
+ return merkleMaps;
1528
+ }
1529
+
1426
1530
  //#endregion
1427
1531
  //#region src/init/validateC2paInitSegment.ts
1428
1532
  const BMFF_HASH_ASSERTION_LABEL$1 = "c2pa.hash.bmff.v3";
@@ -1485,10 +1589,11 @@ async function validateBmffHashAssertion(bytes, assertion) {
1485
1589
  if (!rawHash) return true;
1486
1590
  const expectedHash = rawHash instanceof Uint8Array ? rawHash : new Uint8Array(rawHash);
1487
1591
  const alg = normalizeAlgorithmName(data["alg"]);
1488
- return validateBmffHash(bytes, expectedHash, {
1592
+ return hashesEqual(await computeBmffHash(bytes, {
1489
1593
  exclusions: data["exclusions"] ?? [],
1490
- alg
1491
- });
1594
+ alg,
1595
+ offsetPrefixSize: 8
1596
+ }), expectedHash);
1492
1597
  }
1493
1598
  function extractSessionKeyFields(entry) {
1494
1599
  const keyData = entry;
@@ -1565,19 +1670,22 @@ async function validateC2paInitSegment(bytes) {
1565
1670
  certificate: null,
1566
1671
  manifestId: null,
1567
1672
  sessionKeys: [],
1673
+ merkleMaps: [],
1568
1674
  isValid: false,
1569
1675
  errorCodes: [LiveVideoStatusCode.INIT_INVALID]
1570
1676
  };
1571
1677
  const internalData = readC2paManifest(bytes, boxes);
1572
1678
  const { manifest } = internalData;
1573
1679
  const certificate = internalData.signatureBytes ? extractCertificateFromSignatureBytes(internalData.signatureBytes) : null;
1574
- const bmffHashValid = await validateBmffHashAssertion(bytes, manifest.assertions.find((a) => a.label === BMFF_HASH_ASSERTION_LABEL$1) ?? null);
1680
+ const bmffHashAssertion = manifest.assertions.find((a) => a.label === BMFF_HASH_ASSERTION_LABEL$1) ?? null;
1681
+ const bmffHashValid = await validateBmffHashAssertion(bytes, bmffHashAssertion);
1575
1682
  const sessionKeysAssertion = manifest.assertions.find((a) => a.label === SESSION_KEYS_ASSERTION_LABEL);
1576
1683
  const sessionKeys = sessionKeysAssertion && certificate ? await validateSessionKeys(sessionKeysAssertion, certificate) : [];
1577
1684
  const integrityCodes = await validateManifestIntegrity(internalData, certificate);
1578
1685
  const codes = /* @__PURE__ */ new Set();
1686
+ const merkleMaps = await validateMerkleMaps(bytes, bmffHashAssertion, codes);
1579
1687
  if (!bmffHashValid) codes.add(LiveVideoStatusCode.INIT_INVALID);
1580
- if (sessionKeys.length === 0) codes.add(LiveVideoStatusCode.SESSIONKEY_INVALID);
1688
+ if (sessionKeys.length === 0 && merkleMaps === null) codes.add(LiveVideoStatusCode.SESSIONKEY_INVALID);
1581
1689
  for (const code of integrityCodes) codes.add(code);
1582
1690
  const errorCodes = [...codes];
1583
1691
  return {
@@ -1585,6 +1693,7 @@ async function validateC2paInitSegment(bytes) {
1585
1693
  certificate,
1586
1694
  manifestId: manifest.instanceId,
1587
1695
  sessionKeys,
1696
+ merkleMaps: merkleMaps ?? [],
1588
1697
  isValid: errorCodes.length === 0,
1589
1698
  errorCodes
1590
1699
  };
@@ -1783,6 +1892,36 @@ function validateSequenceNumber(state, sequenceNumber, minSequenceNumber) {
1783
1892
  };
1784
1893
  }
1785
1894
 
1895
+ //#endregion
1896
+ //#region src/bmff/validateBmffHash.ts
1897
+ const OFFSET_PREFIX_SIZES_TO_TRY = [8, 0];
1898
+ /**
1899
+ * Validates the C2PA BMFF content hash (`c2pa.hash.bmff.v3`) for a DASH segment.
1900
+ *
1901
+ * Tries both offset-prefix modes — 8-byte file offset prefix and no prefix — to handle
1902
+ * inter-signer interoperability for hashes carried in the VSI CBOR map (§19.7.3), where
1903
+ * the spec does not signal the prefix mode. Flat hashes from a `c2pa.hash.bmff.v3`
1904
+ * assertion always use the 8-byte prefix (§18.6.2); verify those with
1905
+ * {@link computeBmffHash} and `offsetPrefixSize: 8` instead.
1906
+ *
1907
+ * @param segmentBytes - Raw segment bytes to validate
1908
+ * @param expectedHash - Expected hash bytes from the VSI CBOR map
1909
+ * @param options - Validation options (exclusions, algorithm)
1910
+ * @returns `true` if the computed hash matches the expected hash in either offset mode
1911
+ *
1912
+ * @example
1913
+ * {@includeCode ../../test/bmff/validateBmffHash.test.ts#example}
1914
+ *
1915
+ * @public
1916
+ */
1917
+ async function validateBmffHash(segmentBytes, expectedHash, options) {
1918
+ for (const offsetPrefixSize of OFFSET_PREFIX_SIZES_TO_TRY) if (hashesEqual(await computeBmffHash(segmentBytes, {
1919
+ ...options,
1920
+ offsetPrefixSize
1921
+ }), expectedHash)) return true;
1922
+ return false;
1923
+ }
1924
+
1786
1925
  //#endregion
1787
1926
  //#region src/segment/validateC2paSegment.ts
1788
1927
  function findSessionKey(sessionKeys, kidHex) {
@@ -1864,7 +2003,6 @@ const LIVE_VIDEO_ASSERTION_LABEL = "c2pa.livevideo.segment";
1864
2003
  const BMFF_HASH_ASSERTION_LABEL = "c2pa.hash.bmff.v3";
1865
2004
  const MANIFEST_ID_PREFIX_PATTERN = /^(xmp:iid:|urn:uuid:)/i;
1866
2005
  const CONTINUITY_METHOD_MANIFEST_ID = "c2pa.manifestId";
1867
- const SUPPORTED_CONTINUITY_METHODS = new Set([CONTINUITY_METHOD_MANIFEST_ID]);
1868
2006
  function normalizeManifestId(id) {
1869
2007
  if (!id) return null;
1870
2008
  return id.replace(MANIFEST_ID_PREFIX_PATTERN, "").toLowerCase();
@@ -1873,11 +2011,6 @@ function extractAssertionData(data) {
1873
2011
  if (data !== null && typeof data === "object" && !Array.isArray(data)) return data;
1874
2012
  return null;
1875
2013
  }
1876
- function toUint8Array(value) {
1877
- if (value instanceof Uint8Array) return value;
1878
- if (Array.isArray(value)) return new Uint8Array(value);
1879
- return null;
1880
- }
1881
2014
  function parseLiveVideoAssertion(assertions) {
1882
2015
  const assertion = assertions.find((a) => a.label === LIVE_VIDEO_ASSERTION_LABEL);
1883
2016
  if (!assertion) return null;
@@ -1890,7 +2023,8 @@ function parseLiveVideoAssertion(assertions) {
1890
2023
  sequenceNumber: typeof rawSeq === "number" ? rawSeq : null,
1891
2024
  previousManifestId: typeof rawPrev === "string" ? rawPrev : null,
1892
2025
  streamId: typeof rawStreamId === "string" ? rawStreamId : null,
1893
- continuityMethod: typeof rawContinuity === "string" ? rawContinuity : null
2026
+ continuityMethod: typeof rawContinuity === "string" ? rawContinuity : null,
2027
+ data: data ?? {}
1894
2028
  };
1895
2029
  }
1896
2030
  const EMPTY_BMFF_HASH = {
@@ -1899,36 +2033,6 @@ const EMPTY_BMFF_HASH = {
1899
2033
  exclusions: [],
1900
2034
  alg: null
1901
2035
  };
1902
- function parseConstraints(rawConstraints) {
1903
- if (!Array.isArray(rawConstraints)) return [];
1904
- const constraints = [];
1905
- for (const c of rawConstraints) {
1906
- if (!c || typeof c !== "object") continue;
1907
- const record = c;
1908
- if (typeof record["offset"] !== "number") continue;
1909
- const value = toUint8Array(record["value"]);
1910
- if (value) constraints.push({
1911
- offset: record["offset"],
1912
- value
1913
- });
1914
- }
1915
- return constraints;
1916
- }
1917
- function parseExclusions(rawExclusions) {
1918
- if (!Array.isArray(rawExclusions)) return [];
1919
- const exclusions = [];
1920
- for (const exc of rawExclusions) {
1921
- if (!exc || typeof exc !== "object") continue;
1922
- const record = exc;
1923
- if (typeof record["xpath"] !== "string") continue;
1924
- const constraints = parseConstraints(record["data"]);
1925
- exclusions.push(constraints.length > 0 ? {
1926
- xpath: record["xpath"],
1927
- data: constraints
1928
- } : { xpath: record["xpath"] });
1929
- }
1930
- return exclusions;
1931
- }
1932
2036
  function parseBmffHashAssertion(assertions) {
1933
2037
  const assertion = assertions.find((a) => a.label === BMFF_HASH_ASSERTION_LABEL);
1934
2038
  if (!assertion) return EMPTY_BMFF_HASH;
@@ -1944,43 +2048,54 @@ function parseBmffHashAssertion(assertions) {
1944
2048
  }
1945
2049
  function parseManifest(bytes) {
1946
2050
  try {
1947
- const { manifest } = readC2paManifest(bytes);
2051
+ const internalData = readC2paManifest(bytes);
2052
+ const { manifest } = internalData;
1948
2053
  if (!manifest) return {
1949
2054
  manifest: null,
1950
2055
  issuer: null,
1951
2056
  liveVideo: null,
1952
- bmff: EMPTY_BMFF_HASH
2057
+ bmff: EMPTY_BMFF_HASH,
2058
+ internalData: null
1953
2059
  };
1954
2060
  return {
1955
2061
  manifest,
1956
2062
  issuer: manifest.signatureInfo?.issuer ?? null,
1957
2063
  liveVideo: parseLiveVideoAssertion(manifest.assertions),
1958
- bmff: parseBmffHashAssertion(manifest.assertions)
2064
+ bmff: parseBmffHashAssertion(manifest.assertions),
2065
+ internalData
1959
2066
  };
1960
2067
  } catch {
1961
2068
  return {
1962
2069
  manifest: null,
1963
2070
  issuer: null,
1964
2071
  liveVideo: null,
1965
- bmff: EMPTY_BMFF_HASH
2072
+ bmff: EMPTY_BMFF_HASH,
2073
+ internalData: null
1966
2074
  };
1967
2075
  }
1968
2076
  }
1969
- function collectErrorCodes(hasManifest, hasLiveVideo, streamIdValid, sequenceNumberValid, bmffHashMatches, continuityMethod, previousManifestId, lastManifestId) {
2077
+ function collectErrorCodes(hasManifest, hasLiveVideo, streamIdValid, sequenceNumberValid, bmffHashMatches) {
1970
2078
  const codes = /* @__PURE__ */ new Set();
1971
2079
  if (!hasManifest) codes.add(LiveVideoStatusCode.MANIFEST_INVALID);
1972
2080
  if (!hasLiveVideo) codes.add(LiveVideoStatusCode.ASSERTION_INVALID);
1973
2081
  if (!streamIdValid) codes.add(LiveVideoStatusCode.ASSERTION_INVALID);
1974
2082
  if (!sequenceNumberValid) codes.add(LiveVideoStatusCode.ASSERTION_INVALID);
1975
2083
  if (!bmffHashMatches) codes.add(LiveVideoStatusCode.SEGMENT_INVALID);
1976
- if (!continuityMethod) codes.add(LiveVideoStatusCode.CONTINUITY_METHOD_INVALID);
1977
- else if (!SUPPORTED_CONTINUITY_METHODS.has(continuityMethod)) codes.add(LiveVideoStatusCode.CONTINUITY_METHOD_INVALID);
1978
- else if (continuityMethod === CONTINUITY_METHOD_MANIFEST_ID) {
1979
- if (!previousManifestId) codes.add(LiveVideoStatusCode.CONTINUITY_METHOD_INVALID);
1980
- else if (lastManifestId && normalizeManifestId(previousManifestId) !== normalizeManifestId(lastManifestId)) codes.add(LiveVideoStatusCode.SEGMENT_INVALID);
1981
- }
1982
2084
  return [...codes];
1983
2085
  }
2086
+ async function validateContinuity(parsed, lastManifestId, options) {
2087
+ const { manifest, liveVideo } = parsed;
2088
+ const method = liveVideo?.continuityMethod ?? null;
2089
+ if (!method) return [LiveVideoStatusCode.CONTINUITY_METHOD_INVALID];
2090
+ if (method === CONTINUITY_METHOD_MANIFEST_ID) {
2091
+ const prev = liveVideo?.previousManifestId ?? null;
2092
+ if (!prev) return [LiveVideoStatusCode.CONTINUITY_METHOD_INVALID];
2093
+ return !!lastManifestId && normalizeManifestId(prev) !== normalizeManifestId(lastManifestId) ? [LiveVideoStatusCode.SEGMENT_INVALID] : [];
2094
+ }
2095
+ const custom = options?.continuityValidator;
2096
+ if (custom?.method !== method || !manifest || !liveVideo) return [LiveVideoStatusCode.CONTINUITY_METHOD_INVALID, LiveVideoStatusCode.CONTINUITY_METHOD_UNSUPPORTED];
2097
+ return await Promise.resolve().then(() => custom.validate(liveVideo.data, manifest)).catch(() => false) ? [] : [LiveVideoStatusCode.SEGMENT_INVALID];
2098
+ }
1984
2099
  /**
1985
2100
  * Validates a C2PA manifest-box live stream segment.
1986
2101
  *
@@ -1988,6 +2103,8 @@ function collectErrorCodes(hasManifest, hasLiveVideo, streamIdValid, sequenceNum
1988
2103
  * Recomputes the `c2pa.hash.bmff.v3` content hash from the raw segment bytes and compares
1989
2104
  * it against the expected hash in the manifest assertion. Checks live-video assertions
1990
2105
  * (sequenceNumber, streamId, continuityMethod) and manifest-ID chain continuity.
2106
+ * A validator for an implementer-defined continuity method can be registered
2107
+ * via {@link ManifestBoxValidationOptions}.
1991
2108
  *
1992
2109
  * This function is **pure** — it does not access any external state. The
1993
2110
  * caller is responsible for persisting `nextManifestId` and `nextState`
@@ -1996,6 +2113,7 @@ function collectErrorCodes(hasManifest, hasLiveVideo, streamIdValid, sequenceNum
1996
2113
  * @param bytes - Raw segment bytes
1997
2114
  * @param lastManifestId - Manifest ID from the previous segment, or null for the first segment
1998
2115
  * @param state - Optional state from the previous segment for streamId/sequenceNumber checks
2116
+ * @param options - Optional custom continuity method validator
1999
2117
  * @returns Validation result, the manifest ID, and state to persist for the next call
2000
2118
  *
2001
2119
  * @example
@@ -2003,8 +2121,8 @@ function collectErrorCodes(hasManifest, hasLiveVideo, streamIdValid, sequenceNum
2003
2121
  *
2004
2122
  * @public
2005
2123
  */
2006
- async function validateC2paManifestBoxSegment(bytes, lastManifestId, state) {
2007
- const { manifest, issuer, liveVideo, bmff } = parseManifest(bytes);
2124
+ async function validateC2paManifestBoxSegment(bytes, lastManifestId, state, options) {
2125
+ const { manifest, issuer, liveVideo, bmff, internalData } = parseManifest(bytes);
2008
2126
  const sequenceNumber = liveVideo?.sequenceNumber ?? null;
2009
2127
  const previousManifestId = liveVideo?.previousManifestId ?? null;
2010
2128
  const streamId = liveVideo?.streamId ?? null;
@@ -2012,11 +2130,21 @@ async function validateC2paManifestBoxSegment(bytes, lastManifestId, state) {
2012
2130
  const streamIdValid = state?.lastStreamId == null || streamId === state.lastStreamId;
2013
2131
  const sequenceNumberValid = state?.lastSequenceNumber == null || sequenceNumber !== null && sequenceNumber > state.lastSequenceNumber;
2014
2132
  let bmffHashMatches = true;
2015
- if (bmff.hashBytes !== null) bmffHashMatches = await validateBmffHash(bytes, bmff.hashBytes, {
2133
+ if (bmff.hashBytes !== null) bmffHashMatches = hashesEqual(await computeBmffHash(bytes, {
2016
2134
  exclusions: bmff.exclusions,
2017
- alg: bmff.alg ?? void 0
2018
- });
2019
- const errorCodes = [...collectErrorCodes(manifest !== null, liveVideo !== null, streamIdValid, sequenceNumberValid, bmffHashMatches, continuityMethod, previousManifestId, lastManifestId)];
2135
+ alg: bmff.alg ?? void 0,
2136
+ offsetPrefixSize: 8
2137
+ }), bmff.hashBytes);
2138
+ const continuityCodes = await validateContinuity({
2139
+ manifest,
2140
+ issuer,
2141
+ liveVideo,
2142
+ bmff,
2143
+ internalData
2144
+ }, lastManifestId, options);
2145
+ const liveVideoCodes = [...new Set([...collectErrorCodes(manifest !== null, liveVideo !== null, streamIdValid, sequenceNumberValid, bmffHashMatches), ...continuityCodes])];
2146
+ const integrityCodes = internalData ? await validateManifestIntegrity(internalData, internalData.signatureBytes ? extractCertificateFromSignatureBytes(internalData.signatureBytes) : null) : [];
2147
+ const errorCodes = [...liveVideoCodes, ...integrityCodes];
2020
2148
  const currentManifestId = manifest?.instanceId ?? null;
2021
2149
  return {
2022
2150
  result: {
@@ -2039,5 +2167,187 @@ async function validateC2paManifestBoxSegment(bytes, lastManifestId, state) {
2039
2167
  }
2040
2168
 
2041
2169
  //#endregion
2042
- export { C2paStatusCode, LiveVideoStatusCode, SequenceValidationReason, validateC2paInitSegment, validateC2paManifestBoxSegment, validateC2paSegment };
2170
+ //#region src/merkle/validateC2paMerkleSegment.ts
2171
+ const MERKLE_BOX_PURPOSE = "merkle";
2172
+ function readAuxPayload(rawPayload) {
2173
+ const prefix = readUuidBoxPurpose(rawPayload);
2174
+ if (!prefix || prefix.purpose !== MERKLE_BOX_PURPOSE) return "other";
2175
+ return prefix.rest;
2176
+ }
2177
+ function extractMerkleAuxBoxes(segmentBytes) {
2178
+ const payloads = [];
2179
+ for (const box of readIsoBoxes(segmentBytes)) {
2180
+ const uuidBox = box;
2181
+ if (uuidBox.type !== "uuid") continue;
2182
+ if (!matchesUuid(uuidBox.usertype ?? [], JUMBF_UUID)) continue;
2183
+ const payload = readAuxPayload(uuidBox.view.readData(uuidBox.view.bytesRemaining));
2184
+ if (payload !== "other") payloads.push(payload);
2185
+ }
2186
+ return payloads;
2187
+ }
2188
+ function readMapField(map, name) {
2189
+ if (map instanceof Map) return map.get(name);
2190
+ return map[name];
2191
+ }
2192
+ function decodeFirstCbor(payload) {
2193
+ let first;
2194
+ let found = false;
2195
+ try {
2196
+ decodeMultiple(payload, (value) => {
2197
+ first = value;
2198
+ found = true;
2199
+ return false;
2200
+ });
2201
+ } catch {
2202
+ return;
2203
+ }
2204
+ return found ? first : void 0;
2205
+ }
2206
+ function parseBmffMerkleMap(payload) {
2207
+ const decoded = decodeFirstCbor(payload);
2208
+ if (decoded === null || decoded === void 0 || typeof decoded !== "object") return null;
2209
+ const uniqueId = asInteger(readMapField(decoded, "uniqueId"));
2210
+ const localId = asInteger(readMapField(decoded, "localId"));
2211
+ const location = asInteger(readMapField(decoded, "location"));
2212
+ if (uniqueId === null || localId === null || location === null) return null;
2213
+ const rawHashes = readMapField(decoded, "hashes");
2214
+ if (rawHashes == null) return {
2215
+ uniqueId,
2216
+ localId,
2217
+ location,
2218
+ hashes: null
2219
+ };
2220
+ if (!Array.isArray(rawHashes)) return null;
2221
+ const hashes = [];
2222
+ for (const entry of rawHashes) {
2223
+ if (entry == null) {
2224
+ hashes.push(null);
2225
+ continue;
2226
+ }
2227
+ const bytes = toUint8Array(entry);
2228
+ if (!bytes) return null;
2229
+ hashes.push(bytes);
2230
+ }
2231
+ return {
2232
+ uniqueId,
2233
+ localId,
2234
+ location,
2235
+ hashes
2236
+ };
2237
+ }
2238
+ async function hashPair(left, right, alg) {
2239
+ const input = new Uint8Array(left.length + right.length);
2240
+ input.set(left, 0);
2241
+ input.set(right, left.length);
2242
+ return new Uint8Array(await crypto.subtle.digest(alg, input));
2243
+ }
2244
+ function treeLayout(leafCount) {
2245
+ const layers = [leafCount];
2246
+ while (layers[layers.length - 1] > 1) layers.push(Math.ceil(layers[layers.length - 1] / 2));
2247
+ return layers;
2248
+ }
2249
+ async function verifyMerkleProof(leafHash, location, proofPath, merkleMap) {
2250
+ const { count, hashes: manifestRow } = merkleMap;
2251
+ if (location < 0 || location >= count) return C2paStatusCode.ASSERTION_BMFFHASH_MALFORMED;
2252
+ const layers = treeLayout(count);
2253
+ const committedLevel = layers.indexOf(manifestRow.length);
2254
+ if (committedLevel === -1) return C2paStatusCode.ASSERTION_BMFFHASH_MALFORMED;
2255
+ const rawPath = proofPath ?? [];
2256
+ if (rawPath.length > committedLevel) return C2paStatusCode.ASSERTION_BMFFHASH_MALFORMED;
2257
+ const path = rawPath.filter((node) => node !== null);
2258
+ const alg = normalizeAlgorithmName(merkleMap.alg ?? void 0);
2259
+ let currentHash = leafHash;
2260
+ let currentIndex = location;
2261
+ let proofIndex = 0;
2262
+ for (const layer of layers) {
2263
+ if (layer === manifestRow.length) break;
2264
+ const siblingIndex = currentIndex % 2 === 0 ? currentIndex + 1 : currentIndex - 1;
2265
+ if (siblingIndex >= 0 && siblingIndex < layer) {
2266
+ const sibling = path[proofIndex++];
2267
+ if (!sibling) return C2paStatusCode.ASSERTION_BMFFHASH_MALFORMED;
2268
+ currentHash = currentIndex % 2 === 0 ? await hashPair(currentHash, sibling, alg) : await hashPair(sibling, currentHash, alg);
2269
+ }
2270
+ currentIndex = Math.floor(currentIndex / 2);
2271
+ }
2272
+ if (proofIndex !== path.length) return C2paStatusCode.ASSERTION_BMFFHASH_MALFORMED;
2273
+ const expected = manifestRow[currentIndex];
2274
+ if (!expected) return C2paStatusCode.ASSERTION_BMFFHASH_MALFORMED;
2275
+ return hashesEqual(currentHash, expected) ? "ok" : C2paStatusCode.ASSERTION_BMFFHASH_MISMATCH;
2276
+ }
2277
+ function findMerkleMap(merkleMaps, uniqueId, localId) {
2278
+ return merkleMaps.find((m) => m.uniqueId === uniqueId && m.localId === localId) ?? null;
2279
+ }
2280
+ async function computeLeafHash(segmentBytes, merkleMap, cache) {
2281
+ const cached = cache.find((entry) => entry.alg === merkleMap.alg && entry.offsetPrefixSize === merkleMap.offsetPrefixSize && entry.exclusions === merkleMap.exclusions);
2282
+ if (cached) return cached.hash;
2283
+ const hash = await computeBmffHash(segmentBytes, {
2284
+ offsetPrefixSize: merkleMap.offsetPrefixSize,
2285
+ exclusions: merkleMap.exclusions,
2286
+ alg: merkleMap.alg ?? void 0
2287
+ });
2288
+ cache.push({
2289
+ alg: merkleMap.alg,
2290
+ offsetPrefixSize: merkleMap.offsetPrefixSize,
2291
+ exclusions: merkleMap.exclusions,
2292
+ hash
2293
+ });
2294
+ return hash;
2295
+ }
2296
+ /**
2297
+ * Validates a fragmented MP4 VOD media segment against the init manifest's
2298
+ * merkle maps (§15.12.2.2). Pure: the caller persists `nextState` and resets
2299
+ * `state` after a seek.
2300
+ *
2301
+ * `merkleMaps` must be non-empty; the caller is responsible for only calling
2302
+ * this in VOD Merkle mode (i.e. after `InitSegmentValidation.merkleMaps` came
2303
+ * back non-empty).
2304
+ *
2305
+ * @example
2306
+ * {@includeCode ../../test/merkle/validateC2paMerkleSegment.test.ts#example}
2307
+ *
2308
+ * @public
2309
+ */
2310
+ async function validateC2paMerkleSegment(segmentBytes, merkleMaps, state) {
2311
+ const previousLocations = state?.lastLocations ?? /* @__PURE__ */ new Map();
2312
+ const nextLocations = new Map(previousLocations);
2313
+ const codes = /* @__PURE__ */ new Set();
2314
+ const leafHashCache = [];
2315
+ let location = null;
2316
+ let bmffHashHex = null;
2317
+ const payloads = extractMerkleAuxBoxes(segmentBytes);
2318
+ if (payloads.length === 0) codes.add(C2paStatusCode.ASSERTION_BMFFHASH_MALFORMED);
2319
+ for (const payload of payloads) {
2320
+ const segmentMap = parseBmffMerkleMap(payload);
2321
+ if (!segmentMap) {
2322
+ codes.add(C2paStatusCode.ASSERTION_BMFFHASH_MALFORMED);
2323
+ continue;
2324
+ }
2325
+ const merkleMap = findMerkleMap(merkleMaps, segmentMap.uniqueId, segmentMap.localId);
2326
+ if (!merkleMap) {
2327
+ codes.add(C2paStatusCode.ASSERTION_BMFFHASH_MALFORMED);
2328
+ continue;
2329
+ }
2330
+ if (location === null) location = segmentMap.location;
2331
+ const trackKey = `${segmentMap.uniqueId}:${segmentMap.localId}`;
2332
+ const lastLocation = previousLocations.get(trackKey);
2333
+ if (!(lastLocation === void 0 || segmentMap.location === lastLocation + 1)) codes.add(LiveVideoStatusCode.ASSERTION_INVALID);
2334
+ const leafHash = await computeLeafHash(segmentBytes, merkleMap, leafHashCache);
2335
+ if (bmffHashHex === null) bmffHashHex = bytesToHex(leafHash);
2336
+ const proofResult = await verifyMerkleProof(leafHash, segmentMap.location, segmentMap.hashes, merkleMap);
2337
+ if (proofResult === "ok") nextLocations.set(trackKey, segmentMap.location);
2338
+ else codes.add(proofResult);
2339
+ }
2340
+ return {
2341
+ result: {
2342
+ location,
2343
+ bmffHashHex,
2344
+ isValid: codes.size === 0,
2345
+ errorCodes: [...codes]
2346
+ },
2347
+ nextState: { lastLocations: nextLocations }
2348
+ };
2349
+ }
2350
+
2351
+ //#endregion
2352
+ export { C2paStatusCode, LiveVideoStatusCode, SequenceValidationReason, validateC2paInitSegment, validateC2paManifestBoxSegment, validateC2paMerkleSegment, validateC2paSegment };
2043
2353
  //# sourceMappingURL=index.js.map