@svta/cml-c2pa 1.0.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/NOTICE.md +2 -0
- package/README.md +68 -0
- package/dist/index.d.ts +339 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2043 -0
- package/dist/index.js.map +1 -0
- package/dist/tsdoc-metadata.json +11 -0
- package/package.json +63 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2043 @@
|
|
|
1
|
+
import { decode } from "cbor-x/decode";
|
|
2
|
+
import { encode } from "cbor-x/encode";
|
|
3
|
+
import { findIsoBox, readEmsg, readIsoBoxes } from "@svta/cml-iso-bmff";
|
|
4
|
+
|
|
5
|
+
//#region src/LiveVideoStatusCode.ts
|
|
6
|
+
/**
|
|
7
|
+
* Standard C2PA failure status codes for live video validation,
|
|
8
|
+
* as defined in the C2PA specification section 19.7.
|
|
9
|
+
*
|
|
10
|
+
* @see {@link https://c2pa.org/specifications/specifications/2.3/specs/C2PA_Specification.html#_live_video_validation_process | C2PA Spec §19.7}
|
|
11
|
+
*
|
|
12
|
+
* @enum
|
|
13
|
+
*
|
|
14
|
+
* @public
|
|
15
|
+
*/
|
|
16
|
+
const LiveVideoStatusCode = {
|
|
17
|
+
INIT_INVALID: "livevideo.init.invalid",
|
|
18
|
+
MANIFEST_INVALID: "livevideo.manifest.invalid",
|
|
19
|
+
SEGMENT_INVALID: "livevideo.segment.invalid",
|
|
20
|
+
ASSERTION_INVALID: "livevideo.assertion.invalid",
|
|
21
|
+
CONTINUITY_METHOD_INVALID: "livevideo.continuityMethod.invalid",
|
|
22
|
+
SESSIONKEY_INVALID: "livevideo.sessionkey.invalid"
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
//#endregion
|
|
26
|
+
//#region src/cose/decodeCoseSign1.ts
|
|
27
|
+
const COSE_SIGN1_TAG_SINGLE_BYTE = 210;
|
|
28
|
+
const COSE_SIGN1_TAG_TWO_BYTE_FIRST = 216;
|
|
29
|
+
const COSE_SIGN1_TAG_TWO_BYTE_SECOND = 18;
|
|
30
|
+
const COSE_SIGN1_ARRAY_LENGTH = 4;
|
|
31
|
+
const COSE_KEY_KID = 4;
|
|
32
|
+
const COSE_KEY_ALG = 1;
|
|
33
|
+
function coseGet$1(header, key) {
|
|
34
|
+
if (header instanceof Map) return header.get(key);
|
|
35
|
+
return header[key];
|
|
36
|
+
}
|
|
37
|
+
function stripCoseTag(bytes) {
|
|
38
|
+
if (bytes[0] === COSE_SIGN1_TAG_SINGLE_BYTE) return bytes.subarray(1);
|
|
39
|
+
if (bytes[0] === COSE_SIGN1_TAG_TWO_BYTE_FIRST && bytes[1] === COSE_SIGN1_TAG_TWO_BYTE_SECOND) return bytes.subarray(2);
|
|
40
|
+
return bytes;
|
|
41
|
+
}
|
|
42
|
+
function toUint8Array$1(value) {
|
|
43
|
+
if (value instanceof Uint8Array) return value;
|
|
44
|
+
if (Array.isArray(value)) return new Uint8Array(value);
|
|
45
|
+
throw new Error(`Expected Uint8Array or number[], got ${typeof value}`);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Decodes a `COSE_Sign1` structure from raw bytes (RFC 9052).
|
|
49
|
+
*
|
|
50
|
+
* Handles CBOR tag 18 in both single-byte (0xD2) and two-byte (0xD8 0x12) form.
|
|
51
|
+
*
|
|
52
|
+
* @param coseBytes - Raw COSE_Sign1 bytes, optionally prefixed with CBOR tag 18
|
|
53
|
+
* @returns The decoded COSE_Sign1 structure
|
|
54
|
+
* @throws If the bytes do not represent a valid COSE_Sign1 structure
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* {@includeCode ../../test/cose/decodeCoseSign1.test.ts#example}
|
|
58
|
+
*
|
|
59
|
+
* @public
|
|
60
|
+
*/
|
|
61
|
+
function decodeCoseSign1(coseBytes) {
|
|
62
|
+
try {
|
|
63
|
+
const coseArray = decode(stripCoseTag(coseBytes));
|
|
64
|
+
if (!Array.isArray(coseArray) || coseArray.length !== COSE_SIGN1_ARRAY_LENGTH) throw new Error("Invalid COSE_Sign1 structure: expected array with 4 elements");
|
|
65
|
+
const [protectedRaw, unprotectedRaw, payloadRaw, signatureRaw] = coseArray;
|
|
66
|
+
const protectedBytes = toUint8Array$1(protectedRaw);
|
|
67
|
+
let protectedHeader = {};
|
|
68
|
+
if (protectedBytes.length > 0) protectedHeader = decode(protectedBytes);
|
|
69
|
+
const unprotectedHeader = unprotectedRaw ?? {};
|
|
70
|
+
const kidRaw = coseGet$1(protectedHeader, COSE_KEY_KID) ?? coseGet$1(unprotectedHeader, COSE_KEY_KID) ?? null;
|
|
71
|
+
const kid = kidRaw != null ? toUint8Array$1(kidRaw) : null;
|
|
72
|
+
const alg = coseGet$1(protectedHeader, COSE_KEY_ALG) ?? coseGet$1(unprotectedHeader, COSE_KEY_ALG) ?? null;
|
|
73
|
+
return {
|
|
74
|
+
protectedBytes,
|
|
75
|
+
protectedHeader,
|
|
76
|
+
unprotectedHeader,
|
|
77
|
+
payload: payloadRaw == null ? null : toUint8Array$1(payloadRaw),
|
|
78
|
+
signature: toUint8Array$1(signatureRaw),
|
|
79
|
+
kid,
|
|
80
|
+
alg
|
|
81
|
+
};
|
|
82
|
+
} catch (error) {
|
|
83
|
+
throw new Error(`Failed to decode COSE_Sign1: ${error.message}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
//#endregion
|
|
88
|
+
//#region src/jumbf/parseJumbfBoxes.ts
|
|
89
|
+
/**
|
|
90
|
+
* Parses JUMBF boxes (ISO 19566-5) from raw bytes.
|
|
91
|
+
*
|
|
92
|
+
* JUMBF boxes share the same 4-byte size + 4-byte type structure as ISO BMFF boxes,
|
|
93
|
+
* so they can be parsed with the same reader.
|
|
94
|
+
*
|
|
95
|
+
* @param bytes - Raw bytes containing JUMBF box content
|
|
96
|
+
* @returns Array of parsed JUMBF boxes with their payloads
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* {@includeCode ../../test/jumbf/parseJumbfBoxes.test.ts#example}
|
|
100
|
+
*
|
|
101
|
+
* @internal
|
|
102
|
+
*/
|
|
103
|
+
function parseJumbfBoxes(bytes) {
|
|
104
|
+
return readIsoBoxes(bytes).map((box) => ({
|
|
105
|
+
type: box.type,
|
|
106
|
+
data: box.view.readData(box.view.bytesRemaining)
|
|
107
|
+
}));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
//#endregion
|
|
111
|
+
//#region src/jumbf/parseJumbfLabel.ts
|
|
112
|
+
const TEXT_DECODER$2 = new TextDecoder();
|
|
113
|
+
const JUMD_UUID_SIZE = 16;
|
|
114
|
+
const JUMD_TOGGLES_OFFSET = 16;
|
|
115
|
+
const JUMD_LABEL_START = 17;
|
|
116
|
+
const LABEL_FLAG_MASK = 3;
|
|
117
|
+
const LABEL_FLAG_EXPECTED = 3;
|
|
118
|
+
/**
|
|
119
|
+
* Extracts the label from a JUMBF Description Box (`jumd`) data payload.
|
|
120
|
+
*
|
|
121
|
+
* The `jumd` format is: 16-byte UUID + 1-byte toggles + null-terminated label string.
|
|
122
|
+
* A label is only present when bits 0 and 1 of the toggles byte are both set.
|
|
123
|
+
*
|
|
124
|
+
* @param jumdData - Raw bytes of the `jumd` box data (payload after the box header)
|
|
125
|
+
* @returns The label string, or `null` if no label is present
|
|
126
|
+
*
|
|
127
|
+
* @example
|
|
128
|
+
* {@includeCode ../../test/jumbf/parseJumbfLabel.test.ts#example}
|
|
129
|
+
*
|
|
130
|
+
* @internal
|
|
131
|
+
*/
|
|
132
|
+
function parseJumbfLabel(jumdData) {
|
|
133
|
+
if (jumdData.length < JUMD_UUID_SIZE + 1) return null;
|
|
134
|
+
if ((jumdData[JUMD_TOGGLES_OFFSET] & LABEL_FLAG_MASK) !== LABEL_FLAG_EXPECTED) return null;
|
|
135
|
+
let end = JUMD_LABEL_START;
|
|
136
|
+
while (end < jumdData.length && jumdData[end] !== 0) end++;
|
|
137
|
+
if (end >= jumdData.length) return null;
|
|
138
|
+
return TEXT_DECODER$2.decode(jumdData.subarray(JUMD_LABEL_START, end));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
//#endregion
|
|
142
|
+
//#region src/utils.ts
|
|
143
|
+
const MILLISECONDS_PER_SECOND = 1e3;
|
|
144
|
+
const SHA_ALGORITHM_PATTERN = /^sha(\d+)$/i;
|
|
145
|
+
/**
|
|
146
|
+
* Normalizes hash algorithm names to WebCrypto format (e.g. `sha256` → `SHA-256`).
|
|
147
|
+
*
|
|
148
|
+
* Defaults to `'SHA-256'` when no algorithm is provided.
|
|
149
|
+
*
|
|
150
|
+
* @internal
|
|
151
|
+
*/
|
|
152
|
+
function normalizeAlgorithmName(rawAlg) {
|
|
153
|
+
return (rawAlg ?? "SHA-256").replace(SHA_ALGORITHM_PATTERN, "SHA-$1");
|
|
154
|
+
}
|
|
155
|
+
const HEX_TABLE = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0"));
|
|
156
|
+
/**
|
|
157
|
+
* Converts a Uint8Array to a lowercase hex string.
|
|
158
|
+
*
|
|
159
|
+
* @internal
|
|
160
|
+
*/
|
|
161
|
+
function bytesToHex(bytes) {
|
|
162
|
+
let hex = "";
|
|
163
|
+
for (const byte of bytes) hex += HEX_TABLE[byte];
|
|
164
|
+
return hex;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Checks whether a session key has expired based on its creation time
|
|
168
|
+
* and validity period.
|
|
169
|
+
*
|
|
170
|
+
* @param createdAt - ISO 8601 date string when the key was created
|
|
171
|
+
* @param validityPeriodSeconds - Validity duration in seconds
|
|
172
|
+
* @param now - Current time (defaults to `new Date()`, injectable for testing)
|
|
173
|
+
* @returns `true` if the key has expired
|
|
174
|
+
*
|
|
175
|
+
* @internal
|
|
176
|
+
*/
|
|
177
|
+
function isKeyExpired(createdAt, validityPeriodSeconds, now = /* @__PURE__ */ new Date()) {
|
|
178
|
+
const createdAtMs = new Date(createdAt).getTime();
|
|
179
|
+
if (Number.isNaN(createdAtMs)) return true;
|
|
180
|
+
return now > new Date(createdAtMs + validityPeriodSeconds * MILLISECONDS_PER_SECOND);
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Constant-time comparison of two hash byte arrays.
|
|
184
|
+
*
|
|
185
|
+
* @internal
|
|
186
|
+
*/
|
|
187
|
+
function hashesEqual(a, b) {
|
|
188
|
+
if (a.length !== b.length) return false;
|
|
189
|
+
let diff = 0;
|
|
190
|
+
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
|
|
191
|
+
return diff === 0;
|
|
192
|
+
}
|
|
193
|
+
const C2PA_MANIFEST_UUID = [
|
|
194
|
+
216,
|
|
195
|
+
254,
|
|
196
|
+
195,
|
|
197
|
+
214,
|
|
198
|
+
26,
|
|
199
|
+
150,
|
|
200
|
+
79,
|
|
201
|
+
50,
|
|
202
|
+
160,
|
|
203
|
+
246,
|
|
204
|
+
243,
|
|
205
|
+
236,
|
|
206
|
+
249,
|
|
207
|
+
108,
|
|
208
|
+
16,
|
|
209
|
+
234
|
|
210
|
+
];
|
|
211
|
+
const JUMBF_UUID = [
|
|
212
|
+
216,
|
|
213
|
+
254,
|
|
214
|
+
195,
|
|
215
|
+
214,
|
|
216
|
+
27,
|
|
217
|
+
14,
|
|
218
|
+
72,
|
|
219
|
+
60,
|
|
220
|
+
146,
|
|
221
|
+
151,
|
|
222
|
+
88,
|
|
223
|
+
40,
|
|
224
|
+
135,
|
|
225
|
+
126,
|
|
226
|
+
196,
|
|
227
|
+
129
|
|
228
|
+
];
|
|
229
|
+
function matchesUuid(usertype, expected) {
|
|
230
|
+
return usertype.length === expected.length && expected.every((b, i) => b === usertype[i]);
|
|
231
|
+
}
|
|
232
|
+
function isC2paUuid(usertype) {
|
|
233
|
+
return matchesUuid(usertype, C2PA_MANIFEST_UUID) || matchesUuid(usertype, JUMBF_UUID);
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Finds the C2PA UUID box in a list of parsed ISO BMFF boxes.
|
|
237
|
+
*
|
|
238
|
+
* Matches against both the C2PA manifest store UUID and the JUMBF UUID
|
|
239
|
+
* (ISO 19566-5), as different tools use different UUIDs.
|
|
240
|
+
*
|
|
241
|
+
* @internal
|
|
242
|
+
*/
|
|
243
|
+
function findC2paUuidBox(boxes) {
|
|
244
|
+
return boxes.find((box) => box.type === "uuid" && isC2paUuid(box.usertype ?? []));
|
|
245
|
+
}
|
|
246
|
+
const FULLBOX_HEADER_SIZE = 4;
|
|
247
|
+
const AUX_UUID_OFFSET_SIZE = 8;
|
|
248
|
+
/**
|
|
249
|
+
* Strips the JUMBF UUID box prefix (fullbox header, purpose string, aux offset)
|
|
250
|
+
* to return only the JUMBF manifest data.
|
|
251
|
+
*
|
|
252
|
+
* Structure per ISO 19566-5 / C2PA BMFF storage:
|
|
253
|
+
* version(1) + flags(3) + purpose(null-terminated) + aux_offset(8) + JUMBF data
|
|
254
|
+
*
|
|
255
|
+
* Returns `null` if the payload does not contain a valid JUMBF UUID prefix.
|
|
256
|
+
*
|
|
257
|
+
* @internal
|
|
258
|
+
*/
|
|
259
|
+
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);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
//#endregion
|
|
271
|
+
//#region src/x509/asn1.ts
|
|
272
|
+
/**
|
|
273
|
+
* Minimal ASN.1 DER parsing helpers for X.509 certificate navigation.
|
|
274
|
+
*
|
|
275
|
+
* @internal
|
|
276
|
+
*/
|
|
277
|
+
const ASN1_TAG_INTEGER = 2;
|
|
278
|
+
const ASN1_TAG_OBJECT_IDENTIFIER = 6;
|
|
279
|
+
const ASN1_TAG_SEQUENCE = 48;
|
|
280
|
+
const ASN1_TAG_SET = 49;
|
|
281
|
+
const ASN1_TAG_UTC_TIME = 23;
|
|
282
|
+
const ASN1_TAG_GENERALIZED_TIME = 24;
|
|
283
|
+
const ASN1_TAG_CONTEXT_0 = 160;
|
|
284
|
+
const ASN1_LONG_FORM_FLAG = 128;
|
|
285
|
+
const ASN1_LONG_FORM_MASK = 127;
|
|
286
|
+
function readLength(bytes, offset) {
|
|
287
|
+
if (offset >= bytes.length) return {
|
|
288
|
+
length: 0,
|
|
289
|
+
bytesRead: 0
|
|
290
|
+
};
|
|
291
|
+
const first = bytes[offset] ?? 0;
|
|
292
|
+
if (first < ASN1_LONG_FORM_FLAG) return {
|
|
293
|
+
length: first,
|
|
294
|
+
bytesRead: 1
|
|
295
|
+
};
|
|
296
|
+
const count = first & ASN1_LONG_FORM_MASK;
|
|
297
|
+
let length = 0;
|
|
298
|
+
for (let i = 0; i < count; i++) length = length << 8 | (bytes[offset + 1 + i] ?? 0);
|
|
299
|
+
return {
|
|
300
|
+
length,
|
|
301
|
+
bytesRead: 1 + count
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
function readElement(bytes, offset) {
|
|
305
|
+
if (offset + 2 > bytes.length) return null;
|
|
306
|
+
const tag = bytes[offset] ?? 0;
|
|
307
|
+
const { length, bytesRead } = readLength(bytes, offset + 1);
|
|
308
|
+
const headerSize = 1 + bytesRead;
|
|
309
|
+
const valueEnd = offset + headerSize + length;
|
|
310
|
+
if (valueEnd > bytes.length) return null;
|
|
311
|
+
return {
|
|
312
|
+
tag,
|
|
313
|
+
value: bytes.subarray(offset + headerSize, valueEnd),
|
|
314
|
+
totalSize: headerSize + length
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Reads an ASN.1 element and returns its full DER encoding (tag + length + value).
|
|
319
|
+
*
|
|
320
|
+
* @internal
|
|
321
|
+
*/
|
|
322
|
+
function readElementRaw(bytes, offset) {
|
|
323
|
+
const el = readElement(bytes, offset);
|
|
324
|
+
if (!el) return null;
|
|
325
|
+
return bytes.subarray(offset, offset + el.totalSize);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
//#endregion
|
|
329
|
+
//#region src/x509/extractCertificateInfo.ts
|
|
330
|
+
const TEXT_DECODER$1 = new TextDecoder();
|
|
331
|
+
const OID_COMMON_NAME = new Uint8Array([
|
|
332
|
+
85,
|
|
333
|
+
4,
|
|
334
|
+
3
|
|
335
|
+
]);
|
|
336
|
+
const OID_ORG_NAME = new Uint8Array([
|
|
337
|
+
85,
|
|
338
|
+
4,
|
|
339
|
+
10
|
|
340
|
+
]);
|
|
341
|
+
function matchesOID(value, oid) {
|
|
342
|
+
if (value.length < oid.length) return false;
|
|
343
|
+
for (let i = 0; i < oid.length; i++) if (value[i] !== oid[i]) return false;
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
function parseTime(element) {
|
|
347
|
+
const text = TEXT_DECODER$1.decode(element.value);
|
|
348
|
+
if (element.tag === ASN1_TAG_UTC_TIME) {
|
|
349
|
+
const yy = parseInt(text.substring(0, 2), 10);
|
|
350
|
+
return `${yy >= 50 ? 1900 + yy : 2e3 + yy}-${text.substring(2, 4)}-${text.substring(4, 6)}T${text.substring(6, 8)}:${text.substring(8, 10)}:${text.substring(10, 12)}Z`;
|
|
351
|
+
}
|
|
352
|
+
if (element.tag === ASN1_TAG_GENERALIZED_TIME) return `${text.substring(0, 4)}-${text.substring(4, 6)}-${text.substring(6, 8)}T${text.substring(8, 10)}:${text.substring(10, 12)}:${text.substring(12, 14)}Z`;
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
function findOidValueInSequence(seqValue, targetOID) {
|
|
356
|
+
const oidEl = readElement(seqValue, 0);
|
|
357
|
+
if (!oidEl || oidEl.tag !== ASN1_TAG_OBJECT_IDENTIFIER) return null;
|
|
358
|
+
if (!matchesOID(oidEl.value, targetOID)) return null;
|
|
359
|
+
const valEl = readElement(seqValue, oidEl.totalSize);
|
|
360
|
+
return valEl ? TEXT_DECODER$1.decode(valEl.value) : null;
|
|
361
|
+
}
|
|
362
|
+
function findRDNValue(issuerValue, targetOID) {
|
|
363
|
+
let offset = 0;
|
|
364
|
+
while (offset < issuerValue.length) {
|
|
365
|
+
const setEl = readElement(issuerValue, offset);
|
|
366
|
+
if (!setEl || setEl.tag !== ASN1_TAG_SET) break;
|
|
367
|
+
let setOffset = 0;
|
|
368
|
+
while (setOffset < setEl.value.length) {
|
|
369
|
+
const seqEl = readElement(setEl.value, setOffset);
|
|
370
|
+
if (!seqEl || seqEl.tag !== ASN1_TAG_SEQUENCE) break;
|
|
371
|
+
const found = findOidValueInSequence(seqEl.value, targetOID);
|
|
372
|
+
if (found) return found;
|
|
373
|
+
setOffset += seqEl.totalSize;
|
|
374
|
+
}
|
|
375
|
+
offset += setEl.totalSize;
|
|
376
|
+
}
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Extracts the issuer name and `notBefore` date from a DER-encoded X.509 certificate.
|
|
381
|
+
*
|
|
382
|
+
* Uses a minimal ASN.1 parser that handles the subset of DER structures found in
|
|
383
|
+
* typical C2PA signing certificates. Extracts the issuer Common Name (OID 2.5.4.3)
|
|
384
|
+
* or Organization Name (OID 2.5.4.10) as a fallback, and the `notBefore` validity date.
|
|
385
|
+
*
|
|
386
|
+
* @param certDER - DER-encoded X.509 certificate bytes
|
|
387
|
+
* @returns Certificate information, or `null` if parsing fails
|
|
388
|
+
*
|
|
389
|
+
* @example
|
|
390
|
+
* {@includeCode ../../test/x509/extractCertificateInfo.test.ts#example}
|
|
391
|
+
*
|
|
392
|
+
* @public
|
|
393
|
+
*/
|
|
394
|
+
function extractCertificateInfo(certDER) {
|
|
395
|
+
try {
|
|
396
|
+
const cert = readElement(certDER, 0);
|
|
397
|
+
if (!cert || cert.tag !== ASN1_TAG_SEQUENCE) return null;
|
|
398
|
+
const tbs = readElement(cert.value, 0);
|
|
399
|
+
if (!tbs || tbs.tag !== ASN1_TAG_SEQUENCE) return null;
|
|
400
|
+
let offset = 0;
|
|
401
|
+
let el = readElement(tbs.value, offset);
|
|
402
|
+
if (!el) return null;
|
|
403
|
+
if (el.tag === ASN1_TAG_CONTEXT_0) {
|
|
404
|
+
offset += el.totalSize;
|
|
405
|
+
el = readElement(tbs.value, offset);
|
|
406
|
+
if (!el) return null;
|
|
407
|
+
}
|
|
408
|
+
if (el.tag !== ASN1_TAG_INTEGER) return null;
|
|
409
|
+
offset += el.totalSize;
|
|
410
|
+
el = readElement(tbs.value, offset);
|
|
411
|
+
if (!el || el.tag !== ASN1_TAG_SEQUENCE) return null;
|
|
412
|
+
offset += el.totalSize;
|
|
413
|
+
el = readElement(tbs.value, offset);
|
|
414
|
+
if (!el || el.tag !== ASN1_TAG_SEQUENCE) return null;
|
|
415
|
+
const issuer = findRDNValue(el.value, OID_COMMON_NAME) ?? findRDNValue(el.value, OID_ORG_NAME) ?? "Unknown Issuer";
|
|
416
|
+
offset += el.totalSize;
|
|
417
|
+
el = readElement(tbs.value, offset);
|
|
418
|
+
let notBefore = null;
|
|
419
|
+
if (el && el.tag === ASN1_TAG_SEQUENCE) {
|
|
420
|
+
const timeEl = readElement(el.value, 0);
|
|
421
|
+
if (timeEl) notBefore = parseTime(timeEl);
|
|
422
|
+
}
|
|
423
|
+
return {
|
|
424
|
+
issuer,
|
|
425
|
+
notBefore
|
|
426
|
+
};
|
|
427
|
+
} catch {
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
//#endregion
|
|
433
|
+
//#region src/readC2paManifest.ts
|
|
434
|
+
const TEXT_DECODER = new TextDecoder();
|
|
435
|
+
const MAX_JUMBF_NESTING_DEPTH = 20;
|
|
436
|
+
const X5CHAIN_HEADER_LABEL = 33;
|
|
437
|
+
function resolveManifestBoxes(jumbfBoxes, depth = 0) {
|
|
438
|
+
if (depth >= MAX_JUMBF_NESTING_DEPTH) return jumbfBoxes;
|
|
439
|
+
const contentBoxes = jumbfBoxes.filter((b) => b.type !== "jumd");
|
|
440
|
+
const firstBox = contentBoxes[0];
|
|
441
|
+
if (contentBoxes.length === 1 && firstBox?.type === "jumb") return resolveManifestBoxes(parseJumbfBoxes(firstBox.data), depth + 1);
|
|
442
|
+
return jumbfBoxes;
|
|
443
|
+
}
|
|
444
|
+
function extractManifestLabel(jumbfBoxes) {
|
|
445
|
+
for (const box of jumbfBoxes) {
|
|
446
|
+
if (box.type !== "jumb") continue;
|
|
447
|
+
const inner = parseJumbfBoxes(box.data);
|
|
448
|
+
for (const child of inner) {
|
|
449
|
+
if (child.type !== "jumb") continue;
|
|
450
|
+
const jumd = parseJumbfBoxes(child.data).find((b) => b.type === "jumd");
|
|
451
|
+
if (jumd) {
|
|
452
|
+
const label = parseJumbfLabel(jumd.data);
|
|
453
|
+
if (label) return label;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
function parseAssertionsInternal(assertionStoreBoxes) {
|
|
460
|
+
const assertions = [];
|
|
461
|
+
for (const box of assertionStoreBoxes) {
|
|
462
|
+
if (box.type !== "jumb") continue;
|
|
463
|
+
const inner = parseJumbfBoxes(box.data);
|
|
464
|
+
const jumd = inner.find((b) => b.type === "jumd");
|
|
465
|
+
if (!jumd) continue;
|
|
466
|
+
const label = parseJumbfLabel(jumd.data);
|
|
467
|
+
if (!label) continue;
|
|
468
|
+
const contentBox = inner.find((b) => b.type === "cbor" || b.type === "json" || b.type === "jumc" || b.type === "jp2c" || b.type === "bidb");
|
|
469
|
+
let data = null;
|
|
470
|
+
if (contentBox) if (contentBox.type === "cbor") try {
|
|
471
|
+
data = decode(contentBox.data);
|
|
472
|
+
} catch {
|
|
473
|
+
data = contentBox.data;
|
|
474
|
+
}
|
|
475
|
+
else if (contentBox.type === "json") try {
|
|
476
|
+
data = JSON.parse(TEXT_DECODER.decode(contentBox.data));
|
|
477
|
+
} catch {
|
|
478
|
+
data = contentBox.data;
|
|
479
|
+
}
|
|
480
|
+
else data = contentBox.data;
|
|
481
|
+
assertions.push({
|
|
482
|
+
label,
|
|
483
|
+
data,
|
|
484
|
+
rawBoxPayload: box.data
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
return assertions;
|
|
488
|
+
}
|
|
489
|
+
function parseSignatureInfo(signatureBytes) {
|
|
490
|
+
if (!signatureBytes) return {
|
|
491
|
+
issuer: null,
|
|
492
|
+
certNotBefore: null
|
|
493
|
+
};
|
|
494
|
+
try {
|
|
495
|
+
const cose = decodeCoseSign1(signatureBytes);
|
|
496
|
+
const x5chain = cose.protectedHeader[X5CHAIN_HEADER_LABEL] ?? cose.unprotectedHeader[X5CHAIN_HEADER_LABEL];
|
|
497
|
+
if (!x5chain) return {
|
|
498
|
+
issuer: null,
|
|
499
|
+
certNotBefore: null
|
|
500
|
+
};
|
|
501
|
+
const certDER = Array.isArray(x5chain) ? x5chain[0] : x5chain;
|
|
502
|
+
if (!(certDER instanceof Uint8Array)) return {
|
|
503
|
+
issuer: null,
|
|
504
|
+
certNotBefore: null
|
|
505
|
+
};
|
|
506
|
+
const certInfo = extractCertificateInfo(certDER);
|
|
507
|
+
return {
|
|
508
|
+
issuer: certInfo?.issuer ?? null,
|
|
509
|
+
certNotBefore: certInfo?.notBefore ?? null
|
|
510
|
+
};
|
|
511
|
+
} catch {
|
|
512
|
+
return {
|
|
513
|
+
issuer: null,
|
|
514
|
+
certNotBefore: null
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
function extractClaimAssertionRefs(claimData) {
|
|
519
|
+
const created = claimData["created_assertions"];
|
|
520
|
+
const gathered = claimData["gathered_assertions"];
|
|
521
|
+
const v1 = claimData["assertions"];
|
|
522
|
+
const sources = [
|
|
523
|
+
...created ?? [],
|
|
524
|
+
...gathered ?? [],
|
|
525
|
+
...v1 ?? []
|
|
526
|
+
];
|
|
527
|
+
const refs = [];
|
|
528
|
+
for (const entry of sources) {
|
|
529
|
+
if (!entry || typeof entry !== "object") continue;
|
|
530
|
+
const e = entry;
|
|
531
|
+
const url = e["url"];
|
|
532
|
+
const hash = e["hash"];
|
|
533
|
+
if (!url || !hash) continue;
|
|
534
|
+
refs.push({
|
|
535
|
+
url,
|
|
536
|
+
hash: hash instanceof Uint8Array ? hash : new Uint8Array(hash),
|
|
537
|
+
alg: e["alg"] ?? null
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
return refs;
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Reads a C2PA manifest from raw BMFF bytes.
|
|
544
|
+
*
|
|
545
|
+
* Locates the C2PA UUID box (`d8fec3d6-1a96-4f32-a0f6-f3ecf96c10ea`), navigates
|
|
546
|
+
* the JUMBF manifest store structure (ISO 19566-5), and returns the parsed active
|
|
547
|
+
* manifest with its claims, assertions, and signature information.
|
|
548
|
+
*
|
|
549
|
+
* This function performs structural parsing only. It does not verify the
|
|
550
|
+
* cryptographic signature of the claim.
|
|
551
|
+
*
|
|
552
|
+
* @param bytes - Raw BMFF bytes (e.g. an MP4 init segment or media segment)
|
|
553
|
+
* @param preParsedBoxes - Optional pre-parsed ISO boxes to avoid redundant parsing
|
|
554
|
+
* @returns The parsed manifest data including claim, assertions, and signature bytes
|
|
555
|
+
* @throws If no C2PA UUID box is found, or the JUMBF structure is invalid
|
|
556
|
+
*
|
|
557
|
+
* @example
|
|
558
|
+
* {@includeCode ../test/readC2paManifest.test.ts#example}
|
|
559
|
+
*
|
|
560
|
+
* @internal
|
|
561
|
+
*/
|
|
562
|
+
function readC2paManifest(bytes, preParsedBoxes) {
|
|
563
|
+
const uuidBox = findC2paUuidBox(preParsedBoxes ?? readIsoBoxes(bytes));
|
|
564
|
+
if (!uuidBox) throw new Error("No C2PA UUID box found in the provided bytes");
|
|
565
|
+
const jumbfPayload = stripJumbfUuidPrefix(uuidBox.view.readData(uuidBox.view.bytesRemaining));
|
|
566
|
+
if (!jumbfPayload) throw new Error("Invalid JUMBF UUID prefix in C2PA box");
|
|
567
|
+
const jumbfBoxes = parseJumbfBoxes(jumbfPayload);
|
|
568
|
+
if (jumbfBoxes.length === 0) throw new Error("No JUMBF boxes found in C2PA UUID box");
|
|
569
|
+
const manifestLabel = extractManifestLabel(jumbfBoxes);
|
|
570
|
+
const manifestBoxes = resolveManifestBoxes(jumbfBoxes);
|
|
571
|
+
let claimData = null;
|
|
572
|
+
let claimCborBytes = null;
|
|
573
|
+
let internalAssertions = [];
|
|
574
|
+
let signatureBytes = null;
|
|
575
|
+
for (const box of manifestBoxes) {
|
|
576
|
+
if (box.type !== "jumb") continue;
|
|
577
|
+
const inner = parseJumbfBoxes(box.data);
|
|
578
|
+
const jumd = inner.find((b) => b.type === "jumd");
|
|
579
|
+
if (!jumd) continue;
|
|
580
|
+
const label = parseJumbfLabel(jumd.data);
|
|
581
|
+
if (!label) continue;
|
|
582
|
+
if (label === "c2pa.claim" || label === "c2pa.claim.v2") {
|
|
583
|
+
const contentBox = inner.find((b) => b.type === "cbor");
|
|
584
|
+
if (contentBox) {
|
|
585
|
+
claimCborBytes = contentBox.data;
|
|
586
|
+
try {
|
|
587
|
+
claimData = decode(contentBox.data);
|
|
588
|
+
} catch {}
|
|
589
|
+
}
|
|
590
|
+
} else if (label === "c2pa.assertions") internalAssertions = parseAssertionsInternal(inner);
|
|
591
|
+
else if (label === "c2pa.signature") {
|
|
592
|
+
const contentBox = inner.find((b) => b.type === "cbor" || b.type === "jumc");
|
|
593
|
+
if (contentBox) signatureBytes = contentBox.data;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
const { issuer, certNotBefore } = parseSignatureInfo(signatureBytes);
|
|
597
|
+
const instanceId = claimData?.["instanceID"] ?? claimData?.["instance_id"] ?? null;
|
|
598
|
+
const claimGenerator = claimData?.["claim_generator"] ?? claimData?.["claimGenerator"] ?? null;
|
|
599
|
+
const resolvedLabel = manifestLabel ?? claimData?.["dc:title"] ?? claimData?.["title"] ?? instanceId ?? "unknown";
|
|
600
|
+
const claimAssertionRefs = claimData ? extractClaimAssertionRefs(claimData) : [];
|
|
601
|
+
const assertions = internalAssertions.map((a) => ({
|
|
602
|
+
label: a.label,
|
|
603
|
+
data: a.data
|
|
604
|
+
}));
|
|
605
|
+
return {
|
|
606
|
+
manifest: {
|
|
607
|
+
label: resolvedLabel,
|
|
608
|
+
instanceId,
|
|
609
|
+
claimGenerator,
|
|
610
|
+
signatureInfo: {
|
|
611
|
+
issuer,
|
|
612
|
+
certNotBefore
|
|
613
|
+
},
|
|
614
|
+
assertions
|
|
615
|
+
},
|
|
616
|
+
claimAssertionRefs,
|
|
617
|
+
claimCborBytes,
|
|
618
|
+
signatureBytes,
|
|
619
|
+
assertions: internalAssertions
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
//#endregion
|
|
624
|
+
//#region src/extractManifestCertificate.ts
|
|
625
|
+
const X5CHAIN_COSE_HEADER = 33;
|
|
626
|
+
/**
|
|
627
|
+
* Extracts the end-entity certificate from raw COSE_Sign1 signature bytes.
|
|
628
|
+
*
|
|
629
|
+
* @internal
|
|
630
|
+
*/
|
|
631
|
+
function extractCertificateFromSignatureBytes(signatureBytes) {
|
|
632
|
+
try {
|
|
633
|
+
const cose = decodeCoseSign1(signatureBytes);
|
|
634
|
+
const x5chain = cose.protectedHeader[X5CHAIN_COSE_HEADER] ?? cose.unprotectedHeader[X5CHAIN_COSE_HEADER];
|
|
635
|
+
if (!x5chain) return null;
|
|
636
|
+
const certDER = Array.isArray(x5chain) ? x5chain[0] : x5chain;
|
|
637
|
+
return certDER instanceof Uint8Array ? certDER : null;
|
|
638
|
+
} catch {
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
//#endregion
|
|
644
|
+
//#region src/bmff/shouldExcludeBox.ts
|
|
645
|
+
function bytesMatchAt(boxData, offset, expected) {
|
|
646
|
+
const bytes = expected instanceof Uint8Array ? expected : new Uint8Array(expected);
|
|
647
|
+
if (offset + bytes.length > boxData.length) return false;
|
|
648
|
+
for (let i = 0; i < bytes.length; i++) if (boxData[offset + i] !== bytes[i]) return false;
|
|
649
|
+
return true;
|
|
650
|
+
}
|
|
651
|
+
function constraintMatches(boxData, constraint) {
|
|
652
|
+
return bytesMatchAt(boxData, constraint.offset, constraint.value);
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Returns `true` if the given BMFF box should be excluded from the
|
|
656
|
+
* C2PA content hash, based on the provided exclusion list.
|
|
657
|
+
*
|
|
658
|
+
* Exclusions use C2PA xpath notation (e.g. `/emsg`, `/moof/traf`).
|
|
659
|
+
* Optional `data` constraints narrow the match to specific box content by byte offset.
|
|
660
|
+
*
|
|
661
|
+
* @param boxType - Four-character box type code (e.g. `'emsg'`, `'moof'`)
|
|
662
|
+
* @param boxData - Full box bytes including the 8-byte size+type header
|
|
663
|
+
* @param exclusions - Exclusion list from the C2PA `c2pa.hash.bmff.v3` assertion
|
|
664
|
+
* @returns `true` if the box should be excluded from the hash input
|
|
665
|
+
*
|
|
666
|
+
* @example
|
|
667
|
+
* {@includeCode ../../test/bmff/shouldExcludeBox.test.ts#example}
|
|
668
|
+
*
|
|
669
|
+
* @public
|
|
670
|
+
*/
|
|
671
|
+
function shouldExcludeBox(boxType, boxData, exclusions) {
|
|
672
|
+
for (const exclusion of exclusions) {
|
|
673
|
+
if (!exclusion.xpath) continue;
|
|
674
|
+
if (!(exclusion.xpath === `/${boxType}` || exclusion.xpath.startsWith(`/${boxType}/`))) continue;
|
|
675
|
+
const { data } = exclusion;
|
|
676
|
+
if (!data || data.length === 0) return true;
|
|
677
|
+
if (data.every((constraint) => constraintMatches(boxData, constraint))) return true;
|
|
678
|
+
}
|
|
679
|
+
return false;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
//#endregion
|
|
683
|
+
//#region src/bmff/computeBmffHash.ts
|
|
684
|
+
const MINIMUM_BOX_SIZE = 8;
|
|
685
|
+
const BOX_TYPE_OFFSET = 4;
|
|
686
|
+
const DEFAULT_HASH_ALG = "SHA-256";
|
|
687
|
+
function readBoxType(bytes, offset) {
|
|
688
|
+
return String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]);
|
|
689
|
+
}
|
|
690
|
+
function writeUint64BigEndian(bytes, value) {
|
|
691
|
+
new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).setBigUint64(0, BigInt(value), false);
|
|
692
|
+
}
|
|
693
|
+
function buildHashInput(bytes, exclusions, offsetPrefixSize) {
|
|
694
|
+
if (offsetPrefixSize !== 0 && offsetPrefixSize !== 8) throw new Error(`offsetPrefixSize must be 0 or 8, got ${offsetPrefixSize}`);
|
|
695
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
696
|
+
let totalLength = 0;
|
|
697
|
+
let offset = 0;
|
|
698
|
+
while (offset + MINIMUM_BOX_SIZE <= bytes.length) {
|
|
699
|
+
const boxSize = view.getUint32(offset, false);
|
|
700
|
+
if (boxSize < MINIMUM_BOX_SIZE || offset + boxSize > bytes.length) break;
|
|
701
|
+
if (!shouldExcludeBox(readBoxType(bytes, offset + BOX_TYPE_OFFSET), bytes.subarray(offset, offset + boxSize), exclusions)) totalLength += offsetPrefixSize + boxSize;
|
|
702
|
+
offset += boxSize;
|
|
703
|
+
}
|
|
704
|
+
const hashInput = new Uint8Array(totalLength);
|
|
705
|
+
let writeOffset = 0;
|
|
706
|
+
offset = 0;
|
|
707
|
+
while (offset + MINIMUM_BOX_SIZE <= bytes.length) {
|
|
708
|
+
const boxSize = view.getUint32(offset, false);
|
|
709
|
+
if (boxSize < MINIMUM_BOX_SIZE || offset + boxSize > bytes.length) break;
|
|
710
|
+
const boxType = readBoxType(bytes, offset + BOX_TYPE_OFFSET);
|
|
711
|
+
const boxData = bytes.subarray(offset, offset + boxSize);
|
|
712
|
+
if (!shouldExcludeBox(boxType, boxData, exclusions)) {
|
|
713
|
+
if (offsetPrefixSize > 0) {
|
|
714
|
+
writeUint64BigEndian(hashInput.subarray(writeOffset, writeOffset + offsetPrefixSize), offset);
|
|
715
|
+
writeOffset += offsetPrefixSize;
|
|
716
|
+
}
|
|
717
|
+
hashInput.set(boxData, writeOffset);
|
|
718
|
+
writeOffset += boxSize;
|
|
719
|
+
}
|
|
720
|
+
offset += boxSize;
|
|
721
|
+
}
|
|
722
|
+
return hashInput;
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Computes the C2PA BMFF content hash (`c2pa.hash.bmff.v3`) for a DASH segment.
|
|
726
|
+
*
|
|
727
|
+
* Iterates through top-level BMFF boxes, excludes those matching the exclusion list,
|
|
728
|
+
* concatenates the remaining box bytes (optionally prefixed with each box's file offset),
|
|
729
|
+
* and returns the WebCrypto digest.
|
|
730
|
+
*
|
|
731
|
+
* @param segmentBytes - Raw segment bytes to hash
|
|
732
|
+
* @param options - Hash options (exclusions, algorithm, offset prefix size)
|
|
733
|
+
* @returns The computed hash bytes
|
|
734
|
+
*
|
|
735
|
+
* @example
|
|
736
|
+
* {@includeCode ../../test/bmff/computeBmffHash.test.ts#example}
|
|
737
|
+
*
|
|
738
|
+
* @public
|
|
739
|
+
*/
|
|
740
|
+
async function computeBmffHash(segmentBytes, options) {
|
|
741
|
+
const exclusions = options?.exclusions ?? [];
|
|
742
|
+
const alg = options?.alg ?? DEFAULT_HASH_ALG;
|
|
743
|
+
const hashInput = buildHashInput(segmentBytes, exclusions, options?.offsetPrefixSize ?? 0);
|
|
744
|
+
const hashBuffer = await crypto.subtle.digest(alg, hashInput);
|
|
745
|
+
return new Uint8Array(hashBuffer);
|
|
746
|
+
}
|
|
747
|
+
|
|
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
|
+
//#endregion
|
|
778
|
+
//#region src/C2paStatusCode.ts
|
|
779
|
+
/**
|
|
780
|
+
* Standard C2PA validation status codes for manifest integrity checks,
|
|
781
|
+
* as defined in the C2PA specification chapters 15 and 18.
|
|
782
|
+
*
|
|
783
|
+
* @see {@link https://c2pa.org/specifications/specifications/2.3/specs/C2PA_Specification.html#_claim_validation | C2PA Spec §15.10.3}
|
|
784
|
+
*
|
|
785
|
+
* @enum
|
|
786
|
+
*
|
|
787
|
+
* @public
|
|
788
|
+
*/
|
|
789
|
+
const C2paStatusCode = {
|
|
790
|
+
ASSERTION_HASHEDURI_MISMATCH: "assertion.hashedURI.mismatch",
|
|
791
|
+
ASSERTION_MISSING: "assertion.missing",
|
|
792
|
+
ASSERTION_ACTION_INGREDIENT_MISMATCH: "assertion.action.ingredientMismatch",
|
|
793
|
+
CLAIM_SIGNATURE_MISMATCH: "claim.signature.mismatch"
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
//#endregion
|
|
797
|
+
//#region src/claim/validateActionIngredients.ts
|
|
798
|
+
const ACTIONS_LABELS = new Set(["c2pa.actions", "c2pa.actions.v2"]);
|
|
799
|
+
const ACTIONS_REQUIRING_INGREDIENTS = new Set([
|
|
800
|
+
"c2pa.opened",
|
|
801
|
+
"c2pa.placed",
|
|
802
|
+
"c2pa.removed"
|
|
803
|
+
]);
|
|
804
|
+
/**
|
|
805
|
+
* Validates action ingredient references per C2PA §18.15.4.7.
|
|
806
|
+
*
|
|
807
|
+
* For any `c2pa.actions` or `c2pa.actions.v2` assertion, actions with type
|
|
808
|
+
* `c2pa.opened`, `c2pa.placed`, or `c2pa.removed` must include a
|
|
809
|
+
* `parameters.ingredients` field. Reports `assertion.action.ingredientMismatch`
|
|
810
|
+
* if any such action is missing the required field.
|
|
811
|
+
*
|
|
812
|
+
* @param assertions - Parsed assertions (label + decoded data)
|
|
813
|
+
* @returns Array of C2PA status codes for any failures found
|
|
814
|
+
*
|
|
815
|
+
* @internal
|
|
816
|
+
*/
|
|
817
|
+
function validateActionIngredients(assertions) {
|
|
818
|
+
const codes = [];
|
|
819
|
+
for (const assertion of assertions) {
|
|
820
|
+
if (!ACTIONS_LABELS.has(assertion.label)) continue;
|
|
821
|
+
const data = assertion.data;
|
|
822
|
+
if (!data) continue;
|
|
823
|
+
const actions = data["actions"];
|
|
824
|
+
if (!Array.isArray(actions)) continue;
|
|
825
|
+
for (const action of actions) {
|
|
826
|
+
if (!action || typeof action !== "object") continue;
|
|
827
|
+
const record = action;
|
|
828
|
+
const actionType = record["action"];
|
|
829
|
+
if (!actionType || !ACTIONS_REQUIRING_INGREDIENTS.has(actionType)) continue;
|
|
830
|
+
const ingredients = record["parameters"]?.["ingredients"];
|
|
831
|
+
if (!Array.isArray(ingredients) || ingredients.length === 0) codes.push(C2paStatusCode.ASSERTION_ACTION_INGREDIENT_MISMATCH);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
return codes;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
//#endregion
|
|
838
|
+
//#region src/claim/validateAssertionHashes.ts
|
|
839
|
+
/**
|
|
840
|
+
* Extracts the assertion label from a JUMBF URI.
|
|
841
|
+
*
|
|
842
|
+
* Handles both absolute (`self#jumbf=/c2pa/<manifest>/c2pa.assertions/<label>`)
|
|
843
|
+
* and relative URI forms by returning the last path segment.
|
|
844
|
+
*/
|
|
845
|
+
function extractLabelFromUrl(url) {
|
|
846
|
+
const fragmentIndex = url.indexOf("#jumbf=");
|
|
847
|
+
const path = fragmentIndex >= 0 ? url.substring(fragmentIndex + 7) : url;
|
|
848
|
+
const lastSlash = path.lastIndexOf("/");
|
|
849
|
+
return lastSlash >= 0 ? path.substring(lastSlash + 1) : path;
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* Validates assertion hashes and presence per C2PA §15.10.3.1.
|
|
853
|
+
*
|
|
854
|
+
* For each assertion referenced in the claim's hashed URI list:
|
|
855
|
+
* - Checks that the assertion exists in the assertion store (`assertion.missing`)
|
|
856
|
+
* - Recomputes the hash of the raw JUMBF box payload and compares it to the
|
|
857
|
+
* expected hash from the claim (`assertion.hashedURI.mismatch`)
|
|
858
|
+
*
|
|
859
|
+
* @param claimRefs - Hashed URI references from the claim
|
|
860
|
+
* @param assertions - Parsed assertions with preserved raw box payloads
|
|
861
|
+
* @returns Array of C2PA status codes for any failures found
|
|
862
|
+
*
|
|
863
|
+
* @internal
|
|
864
|
+
*/
|
|
865
|
+
async function validateAssertionHashes(claimRefs, assertions) {
|
|
866
|
+
if (claimRefs.length === 0) return [];
|
|
867
|
+
const assertionsByLabel = /* @__PURE__ */ new Map();
|
|
868
|
+
for (const assertion of assertions) assertionsByLabel.set(assertion.label, assertion);
|
|
869
|
+
const codes = [];
|
|
870
|
+
const hashChecks = claimRefs.map(async (ref) => {
|
|
871
|
+
const label = extractLabelFromUrl(ref.url);
|
|
872
|
+
const assertion = assertionsByLabel.get(label);
|
|
873
|
+
if (!assertion) return C2paStatusCode.ASSERTION_MISSING;
|
|
874
|
+
const alg = ref.alg ? normalizeAlgorithmName(ref.alg) : "SHA-256";
|
|
875
|
+
if (!hashesEqual(new Uint8Array(await crypto.subtle.digest(alg, assertion.rawBoxPayload)), ref.hash)) return C2paStatusCode.ASSERTION_HASHEDURI_MISMATCH;
|
|
876
|
+
return null;
|
|
877
|
+
});
|
|
878
|
+
const results = await Promise.all(hashChecks);
|
|
879
|
+
for (const result of results) if (result) codes.push(result);
|
|
880
|
+
return codes;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
//#endregion
|
|
884
|
+
//#region src/cose/constants.ts
|
|
885
|
+
const ECDSA_ALGORITHM = "ECDSA";
|
|
886
|
+
const ED25519_ALGORITHM = "Ed25519";
|
|
887
|
+
const RSA_PSS_ALGORITHM = "RSA-PSS";
|
|
888
|
+
const CURVE_P256 = "P-256";
|
|
889
|
+
const CURVE_P384 = "P-384";
|
|
890
|
+
const CURVE_P521 = "P-521";
|
|
891
|
+
const HASH_SHA256 = "SHA-256";
|
|
892
|
+
const HASH_SHA384 = "SHA-384";
|
|
893
|
+
const HASH_SHA512 = "SHA-512";
|
|
894
|
+
|
|
895
|
+
//#endregion
|
|
896
|
+
//#region src/cose/resolveAlgorithmFromCoseAlg.ts
|
|
897
|
+
const COSE_ALGORITHM_MAP = new Map([
|
|
898
|
+
[-7, {
|
|
899
|
+
name: ECDSA_ALGORITHM,
|
|
900
|
+
namedCurve: CURVE_P256
|
|
901
|
+
}],
|
|
902
|
+
[-35, {
|
|
903
|
+
name: ECDSA_ALGORITHM,
|
|
904
|
+
namedCurve: CURVE_P384
|
|
905
|
+
}],
|
|
906
|
+
[-36, {
|
|
907
|
+
name: ECDSA_ALGORITHM,
|
|
908
|
+
namedCurve: CURVE_P521
|
|
909
|
+
}],
|
|
910
|
+
[-8, { name: ED25519_ALGORITHM }],
|
|
911
|
+
[-37, {
|
|
912
|
+
name: RSA_PSS_ALGORITHM,
|
|
913
|
+
hash: { name: HASH_SHA256 }
|
|
914
|
+
}],
|
|
915
|
+
[-38, {
|
|
916
|
+
name: RSA_PSS_ALGORITHM,
|
|
917
|
+
hash: { name: HASH_SHA384 }
|
|
918
|
+
}],
|
|
919
|
+
[-39, {
|
|
920
|
+
name: RSA_PSS_ALGORITHM,
|
|
921
|
+
hash: { name: HASH_SHA512 }
|
|
922
|
+
}]
|
|
923
|
+
]);
|
|
924
|
+
/**
|
|
925
|
+
* Maps a COSE algorithm number (from a COSE_Sign1 protected header) to the
|
|
926
|
+
* WebCrypto algorithm identifier needed for `crypto.subtle.importKey('spki', ...)`.
|
|
927
|
+
*
|
|
928
|
+
* @param coseAlg - COSE algorithm identifier (e.g. -7 for ES256)
|
|
929
|
+
* @returns WebCrypto algorithm identifier
|
|
930
|
+
* @throws If the COSE algorithm is not supported
|
|
931
|
+
*
|
|
932
|
+
* @internal
|
|
933
|
+
*/
|
|
934
|
+
function resolveAlgorithmFromCoseAlg(coseAlg) {
|
|
935
|
+
const algorithm = COSE_ALGORITHM_MAP.get(coseAlg);
|
|
936
|
+
if (!algorithm) throw new Error(`Unsupported COSE algorithm: ${coseAlg}`);
|
|
937
|
+
return algorithm;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
//#endregion
|
|
941
|
+
//#region src/cose/buildSigStructure.ts
|
|
942
|
+
const CBOR_ARRAY_FOUR_ITEMS = 132;
|
|
943
|
+
const CBOR_TEXT_MAJOR_TYPE_BASE = 96;
|
|
944
|
+
const CBOR_BYTES_MAJOR_TYPE_BASE = 64;
|
|
945
|
+
const CBOR_UINT8_LENGTH_INDICATOR = 88;
|
|
946
|
+
const CBOR_UINT16_LENGTH_INDICATOR = 89;
|
|
947
|
+
const CBOR_UINT32_LENGTH_INDICATOR = 90;
|
|
948
|
+
const CBOR_INLINE_MAX = 23;
|
|
949
|
+
const CBOR_UINT8_MAX = 255;
|
|
950
|
+
const CBOR_UINT16_MAX = 65535;
|
|
951
|
+
const COSE_SIG1_CONTEXT_BYTES = new TextEncoder().encode("Signature1");
|
|
952
|
+
function byteStringHeaderSize(length) {
|
|
953
|
+
if (length <= CBOR_INLINE_MAX) return 1;
|
|
954
|
+
if (length <= CBOR_UINT8_MAX) return 2;
|
|
955
|
+
if (length <= CBOR_UINT16_MAX) return 3;
|
|
956
|
+
return 5;
|
|
957
|
+
}
|
|
958
|
+
function writeByteStringHeader(output, offset, length) {
|
|
959
|
+
if (length <= CBOR_INLINE_MAX) output[offset++] = CBOR_BYTES_MAJOR_TYPE_BASE + length;
|
|
960
|
+
else if (length <= CBOR_UINT8_MAX) {
|
|
961
|
+
output[offset++] = CBOR_UINT8_LENGTH_INDICATOR;
|
|
962
|
+
output[offset++] = length;
|
|
963
|
+
} else if (length <= CBOR_UINT16_MAX) {
|
|
964
|
+
output[offset++] = CBOR_UINT16_LENGTH_INDICATOR;
|
|
965
|
+
output[offset++] = length >> 8 & 255;
|
|
966
|
+
output[offset++] = length & 255;
|
|
967
|
+
} else {
|
|
968
|
+
output[offset++] = CBOR_UINT32_LENGTH_INDICATOR;
|
|
969
|
+
output[offset++] = length >>> 24 & 255;
|
|
970
|
+
output[offset++] = length >>> 16 & 255;
|
|
971
|
+
output[offset++] = length >>> 8 & 255;
|
|
972
|
+
output[offset++] = length & 255;
|
|
973
|
+
}
|
|
974
|
+
return offset;
|
|
975
|
+
}
|
|
976
|
+
/**
|
|
977
|
+
* Builds the COSE `Sig_Structure` (ToBeSigned) bytes for a `COSE_Sign1` structure (RFC 9052 §4.4).
|
|
978
|
+
*
|
|
979
|
+
* The resulting bytes are the direct input to signature creation or verification via WebCrypto.
|
|
980
|
+
*
|
|
981
|
+
* @param protectedBytes - Serialized protected header (CBOR-encoded byte string)
|
|
982
|
+
* @param payload - Payload bytes
|
|
983
|
+
* @param externalAad - External additional authenticated data (default: empty)
|
|
984
|
+
* @returns CBOR-encoded `Sig_Structure` array ready for signing or verification
|
|
985
|
+
*
|
|
986
|
+
* @example
|
|
987
|
+
* {@includeCode ../../test/cose/buildSigStructure.test.ts#example}
|
|
988
|
+
*
|
|
989
|
+
* @public
|
|
990
|
+
*/
|
|
991
|
+
function buildSigStructure(protectedBytes, payload, externalAad = new Uint8Array(0)) {
|
|
992
|
+
const totalLength = 2 + COSE_SIG1_CONTEXT_BYTES.length + byteStringHeaderSize(protectedBytes.length) + protectedBytes.length + byteStringHeaderSize(externalAad.length) + externalAad.length + byteStringHeaderSize(payload.length) + payload.length;
|
|
993
|
+
const result = new Uint8Array(totalLength);
|
|
994
|
+
let offset = 0;
|
|
995
|
+
result[offset++] = CBOR_ARRAY_FOUR_ITEMS;
|
|
996
|
+
result[offset++] = CBOR_TEXT_MAJOR_TYPE_BASE + COSE_SIG1_CONTEXT_BYTES.length;
|
|
997
|
+
result.set(COSE_SIG1_CONTEXT_BYTES, offset);
|
|
998
|
+
offset += COSE_SIG1_CONTEXT_BYTES.length;
|
|
999
|
+
offset = writeByteStringHeader(result, offset, protectedBytes.length);
|
|
1000
|
+
result.set(protectedBytes, offset);
|
|
1001
|
+
offset += protectedBytes.length;
|
|
1002
|
+
offset = writeByteStringHeader(result, offset, externalAad.length);
|
|
1003
|
+
result.set(externalAad, offset);
|
|
1004
|
+
offset += externalAad.length;
|
|
1005
|
+
offset = writeByteStringHeader(result, offset, payload.length);
|
|
1006
|
+
result.set(payload, offset);
|
|
1007
|
+
return result;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
//#endregion
|
|
1011
|
+
//#region src/cose/verifyCoseSign1.ts
|
|
1012
|
+
const DER_SEQUENCE_TAG = 48;
|
|
1013
|
+
const DER_INTEGER_TAG = 2;
|
|
1014
|
+
const CURVE_COMPONENT_BYTES = {
|
|
1015
|
+
[CURVE_P256]: 32,
|
|
1016
|
+
[CURVE_P384]: 48,
|
|
1017
|
+
[CURVE_P521]: 66
|
|
1018
|
+
};
|
|
1019
|
+
const RSA_PSS_SALT_LENGTH = {
|
|
1020
|
+
[HASH_SHA256]: 32,
|
|
1021
|
+
[HASH_SHA384]: 48,
|
|
1022
|
+
[HASH_SHA512]: 64
|
|
1023
|
+
};
|
|
1024
|
+
function getComponentSize(publicKey) {
|
|
1025
|
+
const curve = publicKey.algorithm.namedCurve;
|
|
1026
|
+
const size = CURVE_COMPONENT_BYTES[curve];
|
|
1027
|
+
if (!size) throw new Error(`Unsupported EC curve: ${curve}`);
|
|
1028
|
+
return size;
|
|
1029
|
+
}
|
|
1030
|
+
function isDerEncoded(signature, expectedRawLength) {
|
|
1031
|
+
return signature.length !== expectedRawLength && signature.length > 2 && signature[0] === DER_SEQUENCE_TAG;
|
|
1032
|
+
}
|
|
1033
|
+
function parseDerLength(der, offset) {
|
|
1034
|
+
if (offset >= der.length) throw new Error("DER signature: truncated length");
|
|
1035
|
+
const firstByte = der[offset];
|
|
1036
|
+
if ((firstByte & 128) === 0) return {
|
|
1037
|
+
length: firstByte,
|
|
1038
|
+
nextOffset: offset + 1
|
|
1039
|
+
};
|
|
1040
|
+
const numBytes = firstByte & 127;
|
|
1041
|
+
if (numBytes === 0 || offset + numBytes >= der.length) throw new Error("DER signature: invalid long-form length");
|
|
1042
|
+
let length = 0;
|
|
1043
|
+
for (let i = 1; i <= numBytes; i++) length = length << 8 | der[offset + i];
|
|
1044
|
+
return {
|
|
1045
|
+
length,
|
|
1046
|
+
nextOffset: offset + 1 + numBytes
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
function extractDerInteger(der, offset) {
|
|
1050
|
+
if (der[offset] !== DER_INTEGER_TAG) throw new Error("DER signature: expected INTEGER tag");
|
|
1051
|
+
const { length, nextOffset: valueStart } = parseDerLength(der, offset + 1);
|
|
1052
|
+
let start = valueStart;
|
|
1053
|
+
let remaining = length;
|
|
1054
|
+
if (remaining > 0 && der[start] === 0) {
|
|
1055
|
+
start++;
|
|
1056
|
+
remaining--;
|
|
1057
|
+
}
|
|
1058
|
+
return {
|
|
1059
|
+
value: der.slice(start, start + remaining),
|
|
1060
|
+
nextOffset: valueStart + length
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
function derToRawEcdsaSignature(der, componentSize) {
|
|
1064
|
+
if (der[0] !== DER_SEQUENCE_TAG) throw new Error("DER signature: expected SEQUENCE tag");
|
|
1065
|
+
const { nextOffset: contentStart } = parseDerLength(der, 1);
|
|
1066
|
+
const rResult = extractDerInteger(der, contentStart);
|
|
1067
|
+
const sResult = extractDerInteger(der, rResult.nextOffset);
|
|
1068
|
+
const rawLength = componentSize * 2;
|
|
1069
|
+
const raw = new Uint8Array(rawLength);
|
|
1070
|
+
raw.set(rResult.value, componentSize - rResult.value.length);
|
|
1071
|
+
raw.set(sResult.value, rawLength - sResult.value.length);
|
|
1072
|
+
return raw;
|
|
1073
|
+
}
|
|
1074
|
+
function resolveVerifyAlgorithm(publicKey) {
|
|
1075
|
+
const { name } = publicKey.algorithm;
|
|
1076
|
+
if (name === ED25519_ALGORITHM) return { name: ED25519_ALGORITHM };
|
|
1077
|
+
if (name === ECDSA_ALGORITHM) {
|
|
1078
|
+
const curve = publicKey.algorithm.namedCurve;
|
|
1079
|
+
return {
|
|
1080
|
+
name: ECDSA_ALGORITHM,
|
|
1081
|
+
hash: { name: curve === CURVE_P384 ? HASH_SHA384 : curve === CURVE_P521 ? HASH_SHA512 : HASH_SHA256 }
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
if (name === RSA_PSS_ALGORITHM) return {
|
|
1085
|
+
name: RSA_PSS_ALGORITHM,
|
|
1086
|
+
saltLength: RSA_PSS_SALT_LENGTH[publicKey.algorithm.hash.name] ?? 32
|
|
1087
|
+
};
|
|
1088
|
+
throw new Error(`Unsupported public key algorithm: ${name}`);
|
|
1089
|
+
}
|
|
1090
|
+
function normalizeSignature(signature, publicKey) {
|
|
1091
|
+
if (publicKey.algorithm.name !== ECDSA_ALGORITHM) return signature;
|
|
1092
|
+
const componentSize = getComponentSize(publicKey);
|
|
1093
|
+
if (isDerEncoded(signature, componentSize * 2)) return derToRawEcdsaSignature(signature, componentSize);
|
|
1094
|
+
return signature;
|
|
1095
|
+
}
|
|
1096
|
+
/**
|
|
1097
|
+
* Verifies a `COSE_Sign1` signature using a provided `CryptoKey` (RFC 9052 §4.4).
|
|
1098
|
+
*
|
|
1099
|
+
* Builds the `Sig_Structure` from the protected header and payload, normalizes
|
|
1100
|
+
* DER-encoded ECDSA signatures to raw format if needed, and delegates
|
|
1101
|
+
* verification to the WebCrypto API.
|
|
1102
|
+
*
|
|
1103
|
+
* Supports `ECDSA` (P-256, P-384, P-521), `Ed25519`, and `RSA-PSS` (PS256, PS384, PS512) keys.
|
|
1104
|
+
*
|
|
1105
|
+
* @param coseSign1 - Decoded COSE_Sign1 structure (from {@link decodeCoseSign1})
|
|
1106
|
+
* @param payload - Payload bytes to verify. May differ from `coseSign1.payload` for detached payloads.
|
|
1107
|
+
* @param publicKey - Imported public key for verification
|
|
1108
|
+
* @returns `true` if the signature is valid
|
|
1109
|
+
* @throws If the key algorithm is not supported
|
|
1110
|
+
*
|
|
1111
|
+
* @example
|
|
1112
|
+
* {@includeCode ../../test/cose/verifyCoseSign1.test.ts#example}
|
|
1113
|
+
*
|
|
1114
|
+
* @public
|
|
1115
|
+
*/
|
|
1116
|
+
async function verifyCoseSign1(coseSign1, payload, publicKey) {
|
|
1117
|
+
const sigStructure = buildSigStructure(coseSign1.protectedBytes, payload);
|
|
1118
|
+
const algorithm = resolveVerifyAlgorithm(publicKey);
|
|
1119
|
+
const signature = normalizeSignature(coseSign1.signature, publicKey);
|
|
1120
|
+
return crypto.subtle.verify(algorithm, publicKey, signature, sigStructure);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
//#endregion
|
|
1124
|
+
//#region src/x509/extractCertificateSpki.ts
|
|
1125
|
+
/**
|
|
1126
|
+
* Extracts the SubjectPublicKeyInfo (SPKI) DER bytes from an X.509 DER certificate.
|
|
1127
|
+
*
|
|
1128
|
+
* Navigates the TBSCertificate structure to reach the 7th field
|
|
1129
|
+
* (SubjectPublicKeyInfo), skipping version, serialNumber, signatureAlgorithm,
|
|
1130
|
+
* issuer, validity, and subject.
|
|
1131
|
+
*
|
|
1132
|
+
* The returned bytes can be imported directly via
|
|
1133
|
+
* `crypto.subtle.importKey('spki', ...)`.
|
|
1134
|
+
*
|
|
1135
|
+
* @param certDER - DER-encoded X.509 certificate bytes
|
|
1136
|
+
* @returns Full DER encoding of the SubjectPublicKeyInfo, or `null` on parse failure
|
|
1137
|
+
*
|
|
1138
|
+
* @internal
|
|
1139
|
+
*/
|
|
1140
|
+
function extractCertificateSpki(certDER) {
|
|
1141
|
+
try {
|
|
1142
|
+
const cert = readElement(certDER, 0);
|
|
1143
|
+
if (!cert || cert.tag !== ASN1_TAG_SEQUENCE) return null;
|
|
1144
|
+
const tbs = readElement(cert.value, 0);
|
|
1145
|
+
if (!tbs || tbs.tag !== ASN1_TAG_SEQUENCE) return null;
|
|
1146
|
+
let offset = 0;
|
|
1147
|
+
let el = readElement(tbs.value, offset);
|
|
1148
|
+
if (!el) return null;
|
|
1149
|
+
if (el.tag === ASN1_TAG_CONTEXT_0) {
|
|
1150
|
+
offset += el.totalSize;
|
|
1151
|
+
el = readElement(tbs.value, offset);
|
|
1152
|
+
if (!el) return null;
|
|
1153
|
+
}
|
|
1154
|
+
if (el.tag !== ASN1_TAG_INTEGER) return null;
|
|
1155
|
+
offset += el.totalSize;
|
|
1156
|
+
el = readElement(tbs.value, offset);
|
|
1157
|
+
if (!el || el.tag !== ASN1_TAG_SEQUENCE) return null;
|
|
1158
|
+
offset += el.totalSize;
|
|
1159
|
+
el = readElement(tbs.value, offset);
|
|
1160
|
+
if (!el || el.tag !== ASN1_TAG_SEQUENCE) return null;
|
|
1161
|
+
offset += el.totalSize;
|
|
1162
|
+
el = readElement(tbs.value, offset);
|
|
1163
|
+
if (!el || el.tag !== ASN1_TAG_SEQUENCE) return null;
|
|
1164
|
+
offset += el.totalSize;
|
|
1165
|
+
el = readElement(tbs.value, offset);
|
|
1166
|
+
if (!el || el.tag !== ASN1_TAG_SEQUENCE) return null;
|
|
1167
|
+
offset += el.totalSize;
|
|
1168
|
+
return readElementRaw(tbs.value, offset);
|
|
1169
|
+
} catch {
|
|
1170
|
+
return null;
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
//#endregion
|
|
1175
|
+
//#region src/x509/normalizeRsaPssSpki.ts
|
|
1176
|
+
const OID_RSASSA_PSS = new Uint8Array([
|
|
1177
|
+
6,
|
|
1178
|
+
9,
|
|
1179
|
+
42,
|
|
1180
|
+
134,
|
|
1181
|
+
72,
|
|
1182
|
+
134,
|
|
1183
|
+
247,
|
|
1184
|
+
13,
|
|
1185
|
+
1,
|
|
1186
|
+
1,
|
|
1187
|
+
10
|
|
1188
|
+
]);
|
|
1189
|
+
const RSA_ENCRYPTION_ALGORITHM_ID = new Uint8Array([
|
|
1190
|
+
48,
|
|
1191
|
+
13,
|
|
1192
|
+
6,
|
|
1193
|
+
9,
|
|
1194
|
+
42,
|
|
1195
|
+
134,
|
|
1196
|
+
72,
|
|
1197
|
+
134,
|
|
1198
|
+
247,
|
|
1199
|
+
13,
|
|
1200
|
+
1,
|
|
1201
|
+
1,
|
|
1202
|
+
1,
|
|
1203
|
+
5,
|
|
1204
|
+
0
|
|
1205
|
+
]);
|
|
1206
|
+
function containsRsaPssOid(bytes) {
|
|
1207
|
+
if (bytes.length < OID_RSASSA_PSS.length) return false;
|
|
1208
|
+
outer: for (let i = 0; i <= bytes.length - OID_RSASSA_PSS.length; i++) {
|
|
1209
|
+
for (let j = 0; j < OID_RSASSA_PSS.length; j++) if (bytes[i + j] !== OID_RSASSA_PSS[j]) continue outer;
|
|
1210
|
+
return true;
|
|
1211
|
+
}
|
|
1212
|
+
return false;
|
|
1213
|
+
}
|
|
1214
|
+
/**
|
|
1215
|
+
* Normalizes an SPKI that uses the `rsassaPss` OID (1.2.840.113549.1.1.10) to
|
|
1216
|
+
* use the generic `rsaEncryption` OID (1.2.840.113549.1.1.1) instead.
|
|
1217
|
+
*
|
|
1218
|
+
* WebCrypto's `importKey('spki', ...)` with `{ name: 'RSA-PSS' }` expects the
|
|
1219
|
+
* SPKI to use the generic `rsaEncryption` OID. Certificates signed with
|
|
1220
|
+
* RSASSA-PSS often embed the PSS-specific OID and parameters, which causes
|
|
1221
|
+
* `DataError` on import.
|
|
1222
|
+
*
|
|
1223
|
+
* If the SPKI does not use `rsassaPss`, it is returned unchanged.
|
|
1224
|
+
*
|
|
1225
|
+
* @internal
|
|
1226
|
+
*/
|
|
1227
|
+
function normalizeRsaPssSpki(spkiBytes) {
|
|
1228
|
+
if (!containsRsaPssOid(spkiBytes)) return spkiBytes;
|
|
1229
|
+
const outer = readElement(spkiBytes, 0);
|
|
1230
|
+
if (!outer || outer.tag !== ASN1_TAG_SEQUENCE) return spkiBytes;
|
|
1231
|
+
const algId = readElement(outer.value, 0);
|
|
1232
|
+
if (!algId || algId.tag !== ASN1_TAG_SEQUENCE) return spkiBytes;
|
|
1233
|
+
const publicKeyBitString = outer.value.subarray(algId.totalSize);
|
|
1234
|
+
const innerLength = RSA_ENCRYPTION_ALGORITHM_ID.length + publicKeyBitString.length;
|
|
1235
|
+
const headerBytes = encodeDerLength(innerLength);
|
|
1236
|
+
const result = new Uint8Array(1 + headerBytes.length + innerLength);
|
|
1237
|
+
let offset = 0;
|
|
1238
|
+
result[offset++] = ASN1_TAG_SEQUENCE;
|
|
1239
|
+
result.set(headerBytes, offset);
|
|
1240
|
+
offset += headerBytes.length;
|
|
1241
|
+
result.set(RSA_ENCRYPTION_ALGORITHM_ID, offset);
|
|
1242
|
+
offset += RSA_ENCRYPTION_ALGORITHM_ID.length;
|
|
1243
|
+
result.set(publicKeyBitString, offset);
|
|
1244
|
+
return result;
|
|
1245
|
+
}
|
|
1246
|
+
function encodeDerLength(length) {
|
|
1247
|
+
if (length < 128) return new Uint8Array([length]);
|
|
1248
|
+
if (length <= 255) return new Uint8Array([129, length]);
|
|
1249
|
+
if (length <= 65535) return new Uint8Array([
|
|
1250
|
+
130,
|
|
1251
|
+
length >> 8 & 255,
|
|
1252
|
+
length & 255
|
|
1253
|
+
]);
|
|
1254
|
+
return new Uint8Array([
|
|
1255
|
+
131,
|
|
1256
|
+
length >> 16 & 255,
|
|
1257
|
+
length >> 8 & 255,
|
|
1258
|
+
length & 255
|
|
1259
|
+
]);
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
//#endregion
|
|
1263
|
+
//#region src/claim/verifyClaimSignature.ts
|
|
1264
|
+
/**
|
|
1265
|
+
* Verifies the claim signature of a C2PA manifest per §15.7.
|
|
1266
|
+
*
|
|
1267
|
+
* The `c2pa.signature` JUMBF box contains a `COSE_Sign1` structure whose payload
|
|
1268
|
+
* is the claim CBOR bytes. This function verifies the signature against the
|
|
1269
|
+
* public key extracted from the end-entity certificate in the `x5chain` header.
|
|
1270
|
+
*
|
|
1271
|
+
* @param signatureBytes - Raw COSE_Sign1 bytes from the `c2pa.signature` box
|
|
1272
|
+
* @param claimCborBytes - Raw CBOR bytes of the claim content box
|
|
1273
|
+
* @param certificateDER - DER-encoded end-entity X.509 certificate
|
|
1274
|
+
* @returns `true` if the signature is valid
|
|
1275
|
+
*
|
|
1276
|
+
* @internal
|
|
1277
|
+
*/
|
|
1278
|
+
async function verifyClaimSignature(signatureBytes, claimCborBytes, certificateDER) {
|
|
1279
|
+
try {
|
|
1280
|
+
const coseSign1 = decodeCoseSign1(signatureBytes);
|
|
1281
|
+
const rawSpki = extractCertificateSpki(certificateDER);
|
|
1282
|
+
if (!rawSpki) return false;
|
|
1283
|
+
const spkiBytes = normalizeRsaPssSpki(rawSpki);
|
|
1284
|
+
if (coseSign1.alg == null) return false;
|
|
1285
|
+
const importAlgorithm = resolveAlgorithmFromCoseAlg(coseSign1.alg);
|
|
1286
|
+
return verifyCoseSign1(coseSign1, claimCborBytes, await crypto.subtle.importKey("spki", new Uint8Array(spkiBytes), importAlgorithm, true, ["verify"]));
|
|
1287
|
+
} catch {
|
|
1288
|
+
return false;
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
//#endregion
|
|
1293
|
+
//#region src/claim/validateManifestIntegrity.ts
|
|
1294
|
+
/**
|
|
1295
|
+
* Validates the integrity of a C2PA manifest per Chapter 15 and Chapter 18.
|
|
1296
|
+
*
|
|
1297
|
+
* Runs four validation checks in parallel where possible:
|
|
1298
|
+
* 1. Assertion hash verification (§15.10.3.1)
|
|
1299
|
+
* 2. Missing assertion detection (§15.10.3.1)
|
|
1300
|
+
* 3. Action ingredient validation (§18.15.4.7)
|
|
1301
|
+
* 4. Claim signature verification (§15.7)
|
|
1302
|
+
*
|
|
1303
|
+
* @param internal - Enriched manifest data with raw assertion bytes and claim references
|
|
1304
|
+
* @param certificateDER - DER-encoded end-entity certificate, or `null` to skip signature check
|
|
1305
|
+
* @returns Array of C2PA status codes for any failures found (empty if all pass)
|
|
1306
|
+
*
|
|
1307
|
+
* @internal
|
|
1308
|
+
*/
|
|
1309
|
+
async function validateManifestIntegrity(internal, certificateDER) {
|
|
1310
|
+
const { signatureBytes, claimCborBytes } = internal;
|
|
1311
|
+
const [assertionHashCodes, signatureValid] = await Promise.all([validateAssertionHashes(internal.claimAssertionRefs, internal.assertions), signatureBytes && claimCborBytes && certificateDER ? verifyClaimSignature(signatureBytes, claimCborBytes, certificateDER) : Promise.resolve(true)]);
|
|
1312
|
+
const actionCodes = validateActionIngredients(internal.assertions);
|
|
1313
|
+
const codes = [...assertionHashCodes, ...actionCodes];
|
|
1314
|
+
if (!signatureValid) codes.push(C2paStatusCode.CLAIM_SIGNATURE_MISMATCH);
|
|
1315
|
+
return codes;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
//#endregion
|
|
1319
|
+
//#region src/cose/convertCoseKeyToJwk.ts
|
|
1320
|
+
const OKP_KEY_TYPE = 1;
|
|
1321
|
+
const EC2_KEY_TYPE = 2;
|
|
1322
|
+
const EC_CURVE_NAMES = {
|
|
1323
|
+
1: "P-256",
|
|
1324
|
+
2: "P-384",
|
|
1325
|
+
3: "P-521"
|
|
1326
|
+
};
|
|
1327
|
+
const OKP_CURVE_NAMES = {
|
|
1328
|
+
4: "X25519",
|
|
1329
|
+
5: "X448",
|
|
1330
|
+
6: "Ed25519",
|
|
1331
|
+
7: "Ed448"
|
|
1332
|
+
};
|
|
1333
|
+
function coseGet(key, intKey) {
|
|
1334
|
+
if (key instanceof Map) return key.get(intKey);
|
|
1335
|
+
return key[intKey];
|
|
1336
|
+
}
|
|
1337
|
+
function toBase64Url(value) {
|
|
1338
|
+
const bytes = value instanceof Uint8Array ? value : new Uint8Array(value);
|
|
1339
|
+
let binary = "";
|
|
1340
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
1341
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
1342
|
+
}
|
|
1343
|
+
/**
|
|
1344
|
+
* Converts a COSE public key (RFC 9052 / IANA COSE Key registry) to JWK format.
|
|
1345
|
+
*
|
|
1346
|
+
* Supports EC2 keys (P-256, P-384, P-521) and OKP keys (Ed25519, Ed448, X25519, X448).
|
|
1347
|
+
* Input may be a `Map\<number, unknown\>` (from CBOR decoders like cbor-x) or a plain
|
|
1348
|
+
* object with integer keys.
|
|
1349
|
+
*
|
|
1350
|
+
* @param coseKey - COSE key structure as decoded from a C2PA `c2pa.session-keys` assertion
|
|
1351
|
+
* @returns JWK representation of the public key
|
|
1352
|
+
* @throws If the key type or curve is not supported
|
|
1353
|
+
*
|
|
1354
|
+
* @example
|
|
1355
|
+
* {@includeCode ../../test/cose/convertCoseKeyToJwk.test.ts#example}
|
|
1356
|
+
*
|
|
1357
|
+
* @public
|
|
1358
|
+
*/
|
|
1359
|
+
function convertCoseKeyToJwk(coseKey) {
|
|
1360
|
+
const key = coseKey;
|
|
1361
|
+
const kty = coseGet(key, 1);
|
|
1362
|
+
const crv = coseGet(key, -1);
|
|
1363
|
+
const x = coseGet(key, -2);
|
|
1364
|
+
if (kty === EC2_KEY_TYPE) {
|
|
1365
|
+
const curveName = EC_CURVE_NAMES[crv];
|
|
1366
|
+
if (!curveName) throw new Error(`Unsupported EC curve: ${crv}`);
|
|
1367
|
+
const y = coseGet(key, -3);
|
|
1368
|
+
if (!(x instanceof Uint8Array)) throw new Error("EC2 key missing or invalid x coordinate");
|
|
1369
|
+
if (!(y instanceof Uint8Array)) throw new Error("EC2 key missing or invalid y coordinate");
|
|
1370
|
+
return {
|
|
1371
|
+
kty: "EC",
|
|
1372
|
+
crv: curveName,
|
|
1373
|
+
x: toBase64Url(x),
|
|
1374
|
+
y: toBase64Url(y)
|
|
1375
|
+
};
|
|
1376
|
+
}
|
|
1377
|
+
if (kty === OKP_KEY_TYPE) {
|
|
1378
|
+
const curveName = OKP_CURVE_NAMES[crv];
|
|
1379
|
+
if (!curveName) throw new Error(`Unsupported OKP curve: ${crv}`);
|
|
1380
|
+
return {
|
|
1381
|
+
kty: "OKP",
|
|
1382
|
+
crv: curveName,
|
|
1383
|
+
x: toBase64Url(x)
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1386
|
+
throw new Error(`Unsupported COSE key type: ${kty}`);
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
//#endregion
|
|
1390
|
+
//#region src/cose/resolveImportAlgorithm.ts
|
|
1391
|
+
const KEY_TYPE_OKP = "OKP";
|
|
1392
|
+
function resolveImportAlgorithm(jwk) {
|
|
1393
|
+
return jwk.kty === KEY_TYPE_OKP ? { name: jwk.crv } : {
|
|
1394
|
+
name: ECDSA_ALGORITHM,
|
|
1395
|
+
namedCurve: jwk.crv
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
//#endregion
|
|
1400
|
+
//#region src/cose/verifySignerBinding.ts
|
|
1401
|
+
/**
|
|
1402
|
+
* Verifies a C2PA signer binding — a `COSE_Sign1` structure that proves
|
|
1403
|
+
* a session key was authorized by the content signer (C2PA spec §18.25.2).
|
|
1404
|
+
*
|
|
1405
|
+
* The `Sig_Structure` payload is `CBOR(bstr(signerCertBytes))` per the C2PA spec.
|
|
1406
|
+
* DER-encoded ECDSA signatures are automatically normalized to raw format.
|
|
1407
|
+
*
|
|
1408
|
+
* @param signerBindingBytes - Raw `COSE_Sign1` bytes of the signer binding
|
|
1409
|
+
* @param sessionCoseKey - COSE public key from the `c2pa.session-keys` assertion
|
|
1410
|
+
* @param signerCertBytes - DER-encoded end-entity certificate (from `x5chain`)
|
|
1411
|
+
* @returns `true` if the signer binding signature is valid
|
|
1412
|
+
* @throws If the COSE key type is not supported or decoding fails
|
|
1413
|
+
*
|
|
1414
|
+
* @example
|
|
1415
|
+
* {@includeCode ../../test/cose/verifySignerBinding.test.ts#example}
|
|
1416
|
+
*
|
|
1417
|
+
* @public
|
|
1418
|
+
*/
|
|
1419
|
+
async function verifySignerBinding(signerBindingBytes, sessionCoseKey, signerCertBytes) {
|
|
1420
|
+
const coseSign1 = decodeCoseSign1(signerBindingBytes);
|
|
1421
|
+
const jwk = convertCoseKeyToJwk(sessionCoseKey);
|
|
1422
|
+
const publicKey = await crypto.subtle.importKey("jwk", jwk, resolveImportAlgorithm(jwk), false, ["verify"]);
|
|
1423
|
+
return verifyCoseSign1(coseSign1, encode(signerCertBytes), publicKey);
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
//#endregion
|
|
1427
|
+
//#region src/init/validateC2paInitSegment.ts
|
|
1428
|
+
const BMFF_HASH_ASSERTION_LABEL$1 = "c2pa.hash.bmff.v3";
|
|
1429
|
+
const SESSION_KEYS_ASSERTION_LABEL = "c2pa.session-keys";
|
|
1430
|
+
const COSE_KEY_ID_LABEL = 2;
|
|
1431
|
+
const CBOR_TAGGED_KEY = "@@TAGGED@@";
|
|
1432
|
+
function extractCborTaggedValue(value) {
|
|
1433
|
+
if (typeof value !== "object" || value === null) return null;
|
|
1434
|
+
const obj = value;
|
|
1435
|
+
if (typeof obj["tag"] === "number" && "value" in obj) return obj["value"];
|
|
1436
|
+
const tagged = obj[CBOR_TAGGED_KEY];
|
|
1437
|
+
if (Array.isArray(tagged) && tagged.length === 2) return tagged[1];
|
|
1438
|
+
return null;
|
|
1439
|
+
}
|
|
1440
|
+
function normalizeToUint8Array(value) {
|
|
1441
|
+
if (value instanceof Uint8Array) return value;
|
|
1442
|
+
if (Array.isArray(value)) return new Uint8Array(value);
|
|
1443
|
+
if (extractCborTaggedValue(value) !== null) return encode(value);
|
|
1444
|
+
throw new Error("Cannot convert value to Uint8Array");
|
|
1445
|
+
}
|
|
1446
|
+
function ensureDecodedCbor(value) {
|
|
1447
|
+
if (value instanceof Uint8Array) return decode(value);
|
|
1448
|
+
if (Array.isArray(value) && value.length > 0 && typeof value[0] === "number") return decode(new Uint8Array(value));
|
|
1449
|
+
return value;
|
|
1450
|
+
}
|
|
1451
|
+
function parseCreatedAt(value) {
|
|
1452
|
+
const resolved = extractCborTaggedValue(value) ?? value;
|
|
1453
|
+
if (typeof resolved === "string") return resolved;
|
|
1454
|
+
if (resolved instanceof Date) return resolved.toISOString();
|
|
1455
|
+
return null;
|
|
1456
|
+
}
|
|
1457
|
+
function extractKidHex(keyData, coseKey) {
|
|
1458
|
+
const kid = keyData["kid"];
|
|
1459
|
+
if (kid instanceof Uint8Array) return bytesToHex(kid);
|
|
1460
|
+
if (typeof kid === "string") return kid;
|
|
1461
|
+
if (Array.isArray(kid) && kid.length > 0) return bytesToHex(new Uint8Array(kid));
|
|
1462
|
+
const coseKeyLike = coseKey;
|
|
1463
|
+
const coseKid = coseKeyLike instanceof Map ? coseKeyLike.get(COSE_KEY_ID_LABEL) : coseKeyLike[COSE_KEY_ID_LABEL];
|
|
1464
|
+
if (coseKid instanceof Uint8Array) return bytesToHex(coseKid);
|
|
1465
|
+
if (Array.isArray(coseKid) && coseKid.length > 0) return bytesToHex(new Uint8Array(coseKid));
|
|
1466
|
+
return null;
|
|
1467
|
+
}
|
|
1468
|
+
function extractKeyArray(data) {
|
|
1469
|
+
if (Array.isArray(data)) return data;
|
|
1470
|
+
if (typeof data === "object" && data !== null) {
|
|
1471
|
+
const obj = data;
|
|
1472
|
+
const keys = obj["keys"] ?? obj["sessionKeys"];
|
|
1473
|
+
if (Array.isArray(keys)) return keys;
|
|
1474
|
+
if (typeof keys === "object" && keys !== null) {
|
|
1475
|
+
const nested = keys["keys"];
|
|
1476
|
+
if (Array.isArray(nested)) return nested;
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
return [];
|
|
1480
|
+
}
|
|
1481
|
+
async function validateBmffHashAssertion(bytes, assertion) {
|
|
1482
|
+
if (!assertion) return true;
|
|
1483
|
+
const data = assertion.data;
|
|
1484
|
+
const rawHash = data["hash"] ?? data["value"];
|
|
1485
|
+
if (!rawHash) return true;
|
|
1486
|
+
const expectedHash = rawHash instanceof Uint8Array ? rawHash : new Uint8Array(rawHash);
|
|
1487
|
+
const alg = normalizeAlgorithmName(data["alg"]);
|
|
1488
|
+
return validateBmffHash(bytes, expectedHash, {
|
|
1489
|
+
exclusions: data["exclusions"] ?? [],
|
|
1490
|
+
alg
|
|
1491
|
+
});
|
|
1492
|
+
}
|
|
1493
|
+
function extractSessionKeyFields(entry) {
|
|
1494
|
+
const keyData = entry;
|
|
1495
|
+
const minSequenceNumber = keyData["minSequenceNumber"];
|
|
1496
|
+
const validityPeriod = keyData["validityPeriod"];
|
|
1497
|
+
const createdAt = parseCreatedAt(keyData["createdAt"]);
|
|
1498
|
+
if (minSequenceNumber == null || validityPeriod == null || !createdAt) return null;
|
|
1499
|
+
if (/* @__PURE__ */ new Date() < new Date(createdAt) || isKeyExpired(createdAt, Number(validityPeriod))) return null;
|
|
1500
|
+
const coseKey = ensureDecodedCbor(keyData["key"]);
|
|
1501
|
+
const kid = extractKidHex(keyData, coseKey);
|
|
1502
|
+
if (!kid) return null;
|
|
1503
|
+
const signerBindingRaw = keyData["signerBinding"];
|
|
1504
|
+
if (!signerBindingRaw) return null;
|
|
1505
|
+
try {
|
|
1506
|
+
return {
|
|
1507
|
+
minSequenceNumber: Number(minSequenceNumber),
|
|
1508
|
+
validityPeriod: Number(validityPeriod),
|
|
1509
|
+
createdAt,
|
|
1510
|
+
kid,
|
|
1511
|
+
coseKey,
|
|
1512
|
+
signerBindingBytes: normalizeToUint8Array(signerBindingRaw)
|
|
1513
|
+
};
|
|
1514
|
+
} catch {
|
|
1515
|
+
return null;
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
async function verifyAndConvertKey(fields, certificate) {
|
|
1519
|
+
if (!await verifySignerBinding(fields.signerBindingBytes, fields.coseKey, certificate)) return null;
|
|
1520
|
+
try {
|
|
1521
|
+
const jwk = convertCoseKeyToJwk(fields.coseKey);
|
|
1522
|
+
return {
|
|
1523
|
+
kid: fields.kid,
|
|
1524
|
+
jwk,
|
|
1525
|
+
minSequenceNumber: fields.minSequenceNumber,
|
|
1526
|
+
validityPeriod: fields.validityPeriod,
|
|
1527
|
+
createdAt: fields.createdAt
|
|
1528
|
+
};
|
|
1529
|
+
} catch {
|
|
1530
|
+
return null;
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
async function validateSingleSessionKey(entry, certificate) {
|
|
1534
|
+
const fields = extractSessionKeyFields(entry);
|
|
1535
|
+
if (!fields) return null;
|
|
1536
|
+
return verifyAndConvertKey(fields, certificate);
|
|
1537
|
+
}
|
|
1538
|
+
async function validateSessionKeys(assertion, certificate) {
|
|
1539
|
+
const keyEntries = extractKeyArray(ensureDecodedCbor(assertion.data));
|
|
1540
|
+
return (await Promise.all(keyEntries.map((entry) => validateSingleSessionKey(entry, certificate)))).filter((key) => key !== null);
|
|
1541
|
+
}
|
|
1542
|
+
/**
|
|
1543
|
+
* Validates a C2PA init segment: parses the manifest, extracts and verifies
|
|
1544
|
+
* the certificate, validates the BMFF hard binding hash, verifies all
|
|
1545
|
+
* session keys from the `c2pa.session-keys` assertion, and performs
|
|
1546
|
+
* manifest integrity checks (assertion hashes, missing assertions,
|
|
1547
|
+
* action ingredients, and claim signature verification).
|
|
1548
|
+
*
|
|
1549
|
+
* Only session keys with a valid signer binding and an unexpired validity period
|
|
1550
|
+
* are included in the result.
|
|
1551
|
+
*
|
|
1552
|
+
* @param bytes - Raw init segment bytes
|
|
1553
|
+
* @returns Structured validation result (with `INIT_INVALID` error code if `mdat` box is present)
|
|
1554
|
+
* @throws If no C2PA UUID box is found
|
|
1555
|
+
*
|
|
1556
|
+
* @example
|
|
1557
|
+
* {@includeCode ../../test/init/validateC2paInitSegment.test.ts#example}
|
|
1558
|
+
*
|
|
1559
|
+
* @public
|
|
1560
|
+
*/
|
|
1561
|
+
async function validateC2paInitSegment(bytes) {
|
|
1562
|
+
const boxes = readIsoBoxes(bytes);
|
|
1563
|
+
if (findIsoBox(boxes, (box) => box.type === "mdat")) return {
|
|
1564
|
+
manifest: null,
|
|
1565
|
+
certificate: null,
|
|
1566
|
+
manifestId: null,
|
|
1567
|
+
sessionKeys: [],
|
|
1568
|
+
isValid: false,
|
|
1569
|
+
errorCodes: [LiveVideoStatusCode.INIT_INVALID]
|
|
1570
|
+
};
|
|
1571
|
+
const internalData = readC2paManifest(bytes, boxes);
|
|
1572
|
+
const { manifest } = internalData;
|
|
1573
|
+
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);
|
|
1575
|
+
const sessionKeysAssertion = manifest.assertions.find((a) => a.label === SESSION_KEYS_ASSERTION_LABEL);
|
|
1576
|
+
const sessionKeys = sessionKeysAssertion && certificate ? await validateSessionKeys(sessionKeysAssertion, certificate) : [];
|
|
1577
|
+
const integrityCodes = await validateManifestIntegrity(internalData, certificate);
|
|
1578
|
+
const codes = /* @__PURE__ */ new Set();
|
|
1579
|
+
if (!bmffHashValid) codes.add(LiveVideoStatusCode.INIT_INVALID);
|
|
1580
|
+
if (sessionKeys.length === 0) codes.add(LiveVideoStatusCode.SESSIONKEY_INVALID);
|
|
1581
|
+
for (const code of integrityCodes) codes.add(code);
|
|
1582
|
+
const errorCodes = [...codes];
|
|
1583
|
+
return {
|
|
1584
|
+
manifest,
|
|
1585
|
+
certificate,
|
|
1586
|
+
manifestId: manifest.instanceId,
|
|
1587
|
+
sessionKeys,
|
|
1588
|
+
isValid: errorCodes.length === 0,
|
|
1589
|
+
errorCodes
|
|
1590
|
+
};
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
//#endregion
|
|
1594
|
+
//#region src/emsg/extractVsiEmsgBox.ts
|
|
1595
|
+
const EMSG_BOX_TYPE = "emsg";
|
|
1596
|
+
const VSI_SCHEME_URI = "urn:c2pa:verifiable-segment-info";
|
|
1597
|
+
const EMSG_READER_CONFIG = { readers: { emsg: readEmsg } };
|
|
1598
|
+
/**
|
|
1599
|
+
* Finds and parses the first C2PA Verifiable Segment Info (VSI) EMSG box
|
|
1600
|
+
* in a DASH segment, identified by scheme URI `urn:c2pa:verifiable-segment-info`.
|
|
1601
|
+
*
|
|
1602
|
+
* @param segmentBytes - Raw DASH segment bytes
|
|
1603
|
+
* @returns The parsed VSI EMSG box, or `null` if not found
|
|
1604
|
+
*
|
|
1605
|
+
* @example
|
|
1606
|
+
* {@includeCode ../../test/emsg/extractVsiEmsgBox.test.ts#example}
|
|
1607
|
+
*
|
|
1608
|
+
* @internal
|
|
1609
|
+
*/
|
|
1610
|
+
function extractVsiEmsgBox(segmentBytes) {
|
|
1611
|
+
for (const box of readIsoBoxes(segmentBytes, EMSG_READER_CONFIG)) {
|
|
1612
|
+
if (box.type !== EMSG_BOX_TYPE) continue;
|
|
1613
|
+
const emsg = box;
|
|
1614
|
+
if (emsg.schemeIdUri === VSI_SCHEME_URI) return emsg;
|
|
1615
|
+
}
|
|
1616
|
+
return null;
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
//#endregion
|
|
1620
|
+
//#region src/vsi/decodeVsiMap.ts
|
|
1621
|
+
/**
|
|
1622
|
+
* Decodes a C2PA Verifiable Segment Info (VSI) CBOR map from raw bytes.
|
|
1623
|
+
*
|
|
1624
|
+
* Normalizes hash algorithm names to WebCrypto format (e.g. `sha256` → `SHA-256`).
|
|
1625
|
+
*
|
|
1626
|
+
* @param vsiCborBytes - Raw CBOR-encoded VSI map bytes from the EMSG `messageData`
|
|
1627
|
+
* @returns The decoded VSI map
|
|
1628
|
+
* @throws If the bytes are not a valid VSI CBOR map
|
|
1629
|
+
*
|
|
1630
|
+
* @example
|
|
1631
|
+
* {@includeCode ../../test/vsi/decodeVsiMap.test.ts#example}
|
|
1632
|
+
*
|
|
1633
|
+
* @internal
|
|
1634
|
+
*/
|
|
1635
|
+
function decodeVsiMap(vsiCborBytes) {
|
|
1636
|
+
const raw = decode(vsiCborBytes);
|
|
1637
|
+
if (typeof raw !== "object" || raw === null) throw new Error("VSI map must be a CBOR map");
|
|
1638
|
+
const sequenceNumber = raw["sequenceNumber"];
|
|
1639
|
+
if (typeof sequenceNumber !== "number") throw new Error("VSI map missing or invalid sequenceNumber");
|
|
1640
|
+
const bmffHashRaw = raw["bmffHash"];
|
|
1641
|
+
if (!bmffHashRaw || typeof bmffHashRaw !== "object") throw new Error("VSI map missing bmffHash");
|
|
1642
|
+
const hash = bmffHashRaw["hash"];
|
|
1643
|
+
if (!(hash instanceof Uint8Array)) throw new Error("VSI map bmffHash.hash must be a Uint8Array");
|
|
1644
|
+
const exclusions = bmffHashRaw["exclusions"];
|
|
1645
|
+
if (exclusions !== void 0 && !Array.isArray(exclusions)) throw new Error("VSI map bmffHash.exclusions must be an array");
|
|
1646
|
+
const manifestId = raw["manifestId"];
|
|
1647
|
+
if (typeof manifestId !== "string") throw new Error("VSI map missing or invalid manifestId");
|
|
1648
|
+
return {
|
|
1649
|
+
sequenceNumber,
|
|
1650
|
+
bmffHash: {
|
|
1651
|
+
hash,
|
|
1652
|
+
alg: normalizeAlgorithmName(bmffHashRaw["alg"]),
|
|
1653
|
+
exclusions: exclusions ?? []
|
|
1654
|
+
},
|
|
1655
|
+
manifestId
|
|
1656
|
+
};
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
//#endregion
|
|
1660
|
+
//#region src/vsi/createSequenceState.ts
|
|
1661
|
+
/**
|
|
1662
|
+
* Creates the initial (empty) sequence state for a new stream.
|
|
1663
|
+
*
|
|
1664
|
+
* @returns A {@link SequenceState} with no history
|
|
1665
|
+
*
|
|
1666
|
+
* @example
|
|
1667
|
+
* {@includeCode ../../test/vsi/validateSequenceNumber.test.ts#example}
|
|
1668
|
+
*
|
|
1669
|
+
* @internal
|
|
1670
|
+
*/
|
|
1671
|
+
function createSequenceState() {
|
|
1672
|
+
return {
|
|
1673
|
+
lastSequenceNumber: null,
|
|
1674
|
+
seenSequences: /* @__PURE__ */ new Set()
|
|
1675
|
+
};
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
//#endregion
|
|
1679
|
+
//#region src/vsi/SequenceState.ts
|
|
1680
|
+
/**
|
|
1681
|
+
* Reason codes for sequence number validation outcomes.
|
|
1682
|
+
*
|
|
1683
|
+
* @see {@link SequenceValidationResult}
|
|
1684
|
+
*
|
|
1685
|
+
* @enum
|
|
1686
|
+
*
|
|
1687
|
+
* @public
|
|
1688
|
+
*/
|
|
1689
|
+
const SequenceValidationReason = {
|
|
1690
|
+
VALID: "valid",
|
|
1691
|
+
DUPLICATE: "duplicate",
|
|
1692
|
+
GAP_DETECTED: "gap_detected",
|
|
1693
|
+
OUT_OF_ORDER: "out_of_order",
|
|
1694
|
+
SEQUENCE_NUMBER_BELOW_MINIMUM: "sequence_number_below_minimum"
|
|
1695
|
+
};
|
|
1696
|
+
|
|
1697
|
+
//#endregion
|
|
1698
|
+
//#region src/vsi/validateSequenceNumber.ts
|
|
1699
|
+
const SEEN_SEQUENCES_WINDOW_SIZE = 32;
|
|
1700
|
+
function pruneSeenSequences(seen, lastSequenceNumber) {
|
|
1701
|
+
if (seen.size <= SEEN_SEQUENCES_WINDOW_SIZE) return seen;
|
|
1702
|
+
const threshold = lastSequenceNumber - SEEN_SEQUENCES_WINDOW_SIZE;
|
|
1703
|
+
const pruned = /* @__PURE__ */ new Set();
|
|
1704
|
+
for (const seq of seen) if (seq >= threshold) pruned.add(seq);
|
|
1705
|
+
return pruned;
|
|
1706
|
+
}
|
|
1707
|
+
/**
|
|
1708
|
+
* Validates a segment's sequence number against the current stream state
|
|
1709
|
+
* per C2PA Live Streaming Specification §18.4.
|
|
1710
|
+
*
|
|
1711
|
+
* This function is **pure and stateless** — it returns both the validation
|
|
1712
|
+
* result and the next state. The caller is responsible for persisting
|
|
1713
|
+
* `nextState` between calls.
|
|
1714
|
+
*
|
|
1715
|
+
* Detects: `duplicate`, `out_of_order`, `gap_detected`, and
|
|
1716
|
+
* `sequence_number_below_minimum`.
|
|
1717
|
+
*
|
|
1718
|
+
* Internally uses a sliding window of the last 32 sequence numbers
|
|
1719
|
+
* to bound memory usage during long-running streams.
|
|
1720
|
+
*
|
|
1721
|
+
* @param state - Current stream state (from {@link createSequenceState} or previous `nextState`)
|
|
1722
|
+
* @param sequenceNumber - Sequence number from the segment's VSI map
|
|
1723
|
+
* @param minSequenceNumber - Minimum accepted sequence number (from the session key, default 0)
|
|
1724
|
+
* @returns Validation result and the updated state to persist
|
|
1725
|
+
*
|
|
1726
|
+
* @example
|
|
1727
|
+
* {@includeCode ../../test/vsi/validateSequenceNumber.test.ts#example}
|
|
1728
|
+
*
|
|
1729
|
+
* @internal
|
|
1730
|
+
*/
|
|
1731
|
+
function validateSequenceNumber(state, sequenceNumber, minSequenceNumber) {
|
|
1732
|
+
if (sequenceNumber < minSequenceNumber) return {
|
|
1733
|
+
result: {
|
|
1734
|
+
isValid: false,
|
|
1735
|
+
reason: SequenceValidationReason.SEQUENCE_NUMBER_BELOW_MINIMUM
|
|
1736
|
+
},
|
|
1737
|
+
nextState: state
|
|
1738
|
+
};
|
|
1739
|
+
if (state.seenSequences.has(sequenceNumber)) return {
|
|
1740
|
+
result: {
|
|
1741
|
+
isValid: false,
|
|
1742
|
+
reason: SequenceValidationReason.DUPLICATE
|
|
1743
|
+
},
|
|
1744
|
+
nextState: state
|
|
1745
|
+
};
|
|
1746
|
+
const nextSeenSequences = new Set(state.seenSequences);
|
|
1747
|
+
nextSeenSequences.add(sequenceNumber);
|
|
1748
|
+
if (state.lastSequenceNumber !== null && sequenceNumber < state.lastSequenceNumber) return {
|
|
1749
|
+
result: {
|
|
1750
|
+
isValid: false,
|
|
1751
|
+
reason: SequenceValidationReason.OUT_OF_ORDER
|
|
1752
|
+
},
|
|
1753
|
+
nextState: {
|
|
1754
|
+
lastSequenceNumber: state.lastSequenceNumber,
|
|
1755
|
+
seenSequences: pruneSeenSequences(nextSeenSequences, state.lastSequenceNumber)
|
|
1756
|
+
}
|
|
1757
|
+
};
|
|
1758
|
+
if (state.lastSequenceNumber !== null && sequenceNumber > state.lastSequenceNumber + 1) {
|
|
1759
|
+
const missingFrom = state.lastSequenceNumber + 1;
|
|
1760
|
+
const missingTo = sequenceNumber - 1;
|
|
1761
|
+
return {
|
|
1762
|
+
result: {
|
|
1763
|
+
isValid: false,
|
|
1764
|
+
reason: SequenceValidationReason.GAP_DETECTED,
|
|
1765
|
+
missingFrom,
|
|
1766
|
+
missingTo
|
|
1767
|
+
},
|
|
1768
|
+
nextState: {
|
|
1769
|
+
lastSequenceNumber: sequenceNumber,
|
|
1770
|
+
seenSequences: pruneSeenSequences(nextSeenSequences, sequenceNumber)
|
|
1771
|
+
}
|
|
1772
|
+
};
|
|
1773
|
+
}
|
|
1774
|
+
return {
|
|
1775
|
+
result: {
|
|
1776
|
+
isValid: true,
|
|
1777
|
+
reason: SequenceValidationReason.VALID
|
|
1778
|
+
},
|
|
1779
|
+
nextState: {
|
|
1780
|
+
lastSequenceNumber: sequenceNumber,
|
|
1781
|
+
seenSequences: pruneSeenSequences(nextSeenSequences, sequenceNumber)
|
|
1782
|
+
}
|
|
1783
|
+
};
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
//#endregion
|
|
1787
|
+
//#region src/segment/validateC2paSegment.ts
|
|
1788
|
+
function findSessionKey(sessionKeys, kidHex) {
|
|
1789
|
+
if (!kidHex || sessionKeys.length === 0) return null;
|
|
1790
|
+
return sessionKeys.find((k) => k.kid === kidHex) ?? null;
|
|
1791
|
+
}
|
|
1792
|
+
/**
|
|
1793
|
+
* Validates a C2PA live stream segment using the VSI/EMSG method (§19.7.3).
|
|
1794
|
+
*
|
|
1795
|
+
* Extracts the EMSG box, decodes the COSE_Sign1 and VSI map, matches the
|
|
1796
|
+
* session key by kid, then performs all cryptographic checks: signature
|
|
1797
|
+
* verification, BMFF content hash, sequence number floor, and key validity.
|
|
1798
|
+
*
|
|
1799
|
+
* Returns `null` if the segment does not contain a C2PA EMSG box.
|
|
1800
|
+
*
|
|
1801
|
+
* @param segmentBytes - Raw segment bytes
|
|
1802
|
+
* @param sessionKeys - Available session keys from the init segment
|
|
1803
|
+
* @param sequenceState - Current sequence state for this stream
|
|
1804
|
+
* @returns Validation result and updated sequence state, or `null` if no C2PA EMSG box
|
|
1805
|
+
*
|
|
1806
|
+
* @example
|
|
1807
|
+
* {@includeCode ../../test/segment/validateC2paSegment.test.ts#example}
|
|
1808
|
+
*
|
|
1809
|
+
* @public
|
|
1810
|
+
*/
|
|
1811
|
+
async function validateC2paSegment(segmentBytes, sessionKeys, sequenceState = createSequenceState()) {
|
|
1812
|
+
const emsgBox = extractVsiEmsgBox(segmentBytes);
|
|
1813
|
+
if (!emsgBox) return null;
|
|
1814
|
+
const coseSign1 = decodeCoseSign1(emsgBox.messageData);
|
|
1815
|
+
const { payload } = coseSign1;
|
|
1816
|
+
if (!payload) throw new Error("COSE_Sign1 payload is empty — cannot decode VSI map");
|
|
1817
|
+
const vsi = decodeVsiMap(payload);
|
|
1818
|
+
const kidHex = coseSign1.kid ? bytesToHex(coseSign1.kid) : null;
|
|
1819
|
+
const sessionKey = findSessionKey(sessionKeys, kidHex);
|
|
1820
|
+
const bmffHashHex = bytesToHex(vsi.bmffHash.hash);
|
|
1821
|
+
const minSequenceNumber = sessionKey?.minSequenceNumber ?? 0;
|
|
1822
|
+
const { result: sequenceResult, nextState: nextSequenceState } = validateSequenceNumber(sequenceState, vsi.sequenceNumber, minSequenceNumber);
|
|
1823
|
+
const baseFields = {
|
|
1824
|
+
sequenceNumber: vsi.sequenceNumber,
|
|
1825
|
+
manifestId: vsi.manifestId,
|
|
1826
|
+
bmffHashHex,
|
|
1827
|
+
kidHex,
|
|
1828
|
+
sequenceResult
|
|
1829
|
+
};
|
|
1830
|
+
if (!sessionKey) return {
|
|
1831
|
+
result: {
|
|
1832
|
+
...baseFields,
|
|
1833
|
+
isValid: false,
|
|
1834
|
+
errorCodes: [LiveVideoStatusCode.SEGMENT_INVALID]
|
|
1835
|
+
},
|
|
1836
|
+
nextSequenceState
|
|
1837
|
+
};
|
|
1838
|
+
const algorithm = resolveImportAlgorithm(sessionKey.jwk);
|
|
1839
|
+
const publicKey = await crypto.subtle.importKey("jwk", sessionKey.jwk, algorithm, false, ["verify"]);
|
|
1840
|
+
const [signatureValid, hashValid] = await Promise.all([verifyCoseSign1(coseSign1, payload, publicKey), validateBmffHash(segmentBytes, vsi.bmffHash.hash, {
|
|
1841
|
+
exclusions: vsi.bmffHash.exclusions,
|
|
1842
|
+
alg: vsi.bmffHash.alg
|
|
1843
|
+
})]);
|
|
1844
|
+
const sequenceAboveMin = vsi.sequenceNumber >= sessionKey.minSequenceNumber;
|
|
1845
|
+
const keyExpired = isKeyExpired(sessionKey.createdAt, sessionKey.validityPeriod);
|
|
1846
|
+
const codes = /* @__PURE__ */ new Set();
|
|
1847
|
+
if (!signatureValid || !hashValid || !sequenceAboveMin) codes.add(LiveVideoStatusCode.SEGMENT_INVALID);
|
|
1848
|
+
if (!sequenceResult.isValid) codes.add(LiveVideoStatusCode.ASSERTION_INVALID);
|
|
1849
|
+
if (keyExpired) codes.add(LiveVideoStatusCode.SESSIONKEY_INVALID);
|
|
1850
|
+
const errorCodes = [...codes];
|
|
1851
|
+
return {
|
|
1852
|
+
result: {
|
|
1853
|
+
...baseFields,
|
|
1854
|
+
isValid: errorCodes.length === 0,
|
|
1855
|
+
errorCodes
|
|
1856
|
+
},
|
|
1857
|
+
nextSequenceState
|
|
1858
|
+
};
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
//#endregion
|
|
1862
|
+
//#region src/manifestbox/validateC2paManifestBoxSegment.ts
|
|
1863
|
+
const LIVE_VIDEO_ASSERTION_LABEL = "c2pa.livevideo.segment";
|
|
1864
|
+
const BMFF_HASH_ASSERTION_LABEL = "c2pa.hash.bmff.v3";
|
|
1865
|
+
const MANIFEST_ID_PREFIX_PATTERN = /^(xmp:iid:|urn:uuid:)/i;
|
|
1866
|
+
const CONTINUITY_METHOD_MANIFEST_ID = "c2pa.manifestId";
|
|
1867
|
+
const SUPPORTED_CONTINUITY_METHODS = new Set([CONTINUITY_METHOD_MANIFEST_ID]);
|
|
1868
|
+
function normalizeManifestId(id) {
|
|
1869
|
+
if (!id) return null;
|
|
1870
|
+
return id.replace(MANIFEST_ID_PREFIX_PATTERN, "").toLowerCase();
|
|
1871
|
+
}
|
|
1872
|
+
function extractAssertionData(data) {
|
|
1873
|
+
if (data !== null && typeof data === "object" && !Array.isArray(data)) return data;
|
|
1874
|
+
return null;
|
|
1875
|
+
}
|
|
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
|
+
function parseLiveVideoAssertion(assertions) {
|
|
1882
|
+
const assertion = assertions.find((a) => a.label === LIVE_VIDEO_ASSERTION_LABEL);
|
|
1883
|
+
if (!assertion) return null;
|
|
1884
|
+
const data = extractAssertionData(assertion.data);
|
|
1885
|
+
const rawSeq = data?.["sequenceNumber"];
|
|
1886
|
+
const rawPrev = data?.["previousManifestId"];
|
|
1887
|
+
const rawStreamId = data?.["streamId"];
|
|
1888
|
+
const rawContinuity = data?.["continuityMethod"];
|
|
1889
|
+
return {
|
|
1890
|
+
sequenceNumber: typeof rawSeq === "number" ? rawSeq : null,
|
|
1891
|
+
previousManifestId: typeof rawPrev === "string" ? rawPrev : null,
|
|
1892
|
+
streamId: typeof rawStreamId === "string" ? rawStreamId : null,
|
|
1893
|
+
continuityMethod: typeof rawContinuity === "string" ? rawContinuity : null
|
|
1894
|
+
};
|
|
1895
|
+
}
|
|
1896
|
+
const EMPTY_BMFF_HASH = {
|
|
1897
|
+
hashBytes: null,
|
|
1898
|
+
hashHex: null,
|
|
1899
|
+
exclusions: [],
|
|
1900
|
+
alg: null
|
|
1901
|
+
};
|
|
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
|
+
function parseBmffHashAssertion(assertions) {
|
|
1933
|
+
const assertion = assertions.find((a) => a.label === BMFF_HASH_ASSERTION_LABEL);
|
|
1934
|
+
if (!assertion) return EMPTY_BMFF_HASH;
|
|
1935
|
+
const data = extractAssertionData(assertion.data);
|
|
1936
|
+
if (!data) return EMPTY_BMFF_HASH;
|
|
1937
|
+
const hashBytes = toUint8Array(data["hash"] ?? data["value"]);
|
|
1938
|
+
return {
|
|
1939
|
+
hashBytes,
|
|
1940
|
+
hashHex: hashBytes ? bytesToHex(hashBytes) : null,
|
|
1941
|
+
exclusions: parseExclusions(data["exclusions"]),
|
|
1942
|
+
alg: typeof data["alg"] === "string" ? normalizeAlgorithmName(data["alg"]) : null
|
|
1943
|
+
};
|
|
1944
|
+
}
|
|
1945
|
+
function parseManifest(bytes) {
|
|
1946
|
+
try {
|
|
1947
|
+
const { manifest } = readC2paManifest(bytes);
|
|
1948
|
+
if (!manifest) return {
|
|
1949
|
+
manifest: null,
|
|
1950
|
+
issuer: null,
|
|
1951
|
+
liveVideo: null,
|
|
1952
|
+
bmff: EMPTY_BMFF_HASH
|
|
1953
|
+
};
|
|
1954
|
+
return {
|
|
1955
|
+
manifest,
|
|
1956
|
+
issuer: manifest.signatureInfo?.issuer ?? null,
|
|
1957
|
+
liveVideo: parseLiveVideoAssertion(manifest.assertions),
|
|
1958
|
+
bmff: parseBmffHashAssertion(manifest.assertions)
|
|
1959
|
+
};
|
|
1960
|
+
} catch {
|
|
1961
|
+
return {
|
|
1962
|
+
manifest: null,
|
|
1963
|
+
issuer: null,
|
|
1964
|
+
liveVideo: null,
|
|
1965
|
+
bmff: EMPTY_BMFF_HASH
|
|
1966
|
+
};
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
function collectErrorCodes(hasManifest, hasLiveVideo, streamIdValid, sequenceNumberValid, bmffHashMatches, continuityMethod, previousManifestId, lastManifestId) {
|
|
1970
|
+
const codes = /* @__PURE__ */ new Set();
|
|
1971
|
+
if (!hasManifest) codes.add(LiveVideoStatusCode.MANIFEST_INVALID);
|
|
1972
|
+
if (!hasLiveVideo) codes.add(LiveVideoStatusCode.ASSERTION_INVALID);
|
|
1973
|
+
if (!streamIdValid) codes.add(LiveVideoStatusCode.ASSERTION_INVALID);
|
|
1974
|
+
if (!sequenceNumberValid) codes.add(LiveVideoStatusCode.ASSERTION_INVALID);
|
|
1975
|
+
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
|
+
return [...codes];
|
|
1983
|
+
}
|
|
1984
|
+
/**
|
|
1985
|
+
* Validates a C2PA manifest-box live stream segment.
|
|
1986
|
+
*
|
|
1987
|
+
* Parses the C2PA manifest embedded in the segment and validates per §19.7.1 and §19.7.2.
|
|
1988
|
+
* Recomputes the `c2pa.hash.bmff.v3` content hash from the raw segment bytes and compares
|
|
1989
|
+
* it against the expected hash in the manifest assertion. Checks live-video assertions
|
|
1990
|
+
* (sequenceNumber, streamId, continuityMethod) and manifest-ID chain continuity.
|
|
1991
|
+
*
|
|
1992
|
+
* This function is **pure** — it does not access any external state. The
|
|
1993
|
+
* caller is responsible for persisting `nextManifestId` and `nextState`
|
|
1994
|
+
* between calls.
|
|
1995
|
+
*
|
|
1996
|
+
* @param bytes - Raw segment bytes
|
|
1997
|
+
* @param lastManifestId - Manifest ID from the previous segment, or null for the first segment
|
|
1998
|
+
* @param state - Optional state from the previous segment for streamId/sequenceNumber checks
|
|
1999
|
+
* @returns Validation result, the manifest ID, and state to persist for the next call
|
|
2000
|
+
*
|
|
2001
|
+
* @example
|
|
2002
|
+
* {@includeCode ../../test/manifestbox/validateC2paManifestBoxSegment.test.ts#example}
|
|
2003
|
+
*
|
|
2004
|
+
* @public
|
|
2005
|
+
*/
|
|
2006
|
+
async function validateC2paManifestBoxSegment(bytes, lastManifestId, state) {
|
|
2007
|
+
const { manifest, issuer, liveVideo, bmff } = parseManifest(bytes);
|
|
2008
|
+
const sequenceNumber = liveVideo?.sequenceNumber ?? null;
|
|
2009
|
+
const previousManifestId = liveVideo?.previousManifestId ?? null;
|
|
2010
|
+
const streamId = liveVideo?.streamId ?? null;
|
|
2011
|
+
const continuityMethod = liveVideo?.continuityMethod ?? null;
|
|
2012
|
+
const streamIdValid = state?.lastStreamId == null || streamId === state.lastStreamId;
|
|
2013
|
+
const sequenceNumberValid = state?.lastSequenceNumber == null || sequenceNumber !== null && sequenceNumber > state.lastSequenceNumber;
|
|
2014
|
+
let bmffHashMatches = true;
|
|
2015
|
+
if (bmff.hashBytes !== null) bmffHashMatches = await validateBmffHash(bytes, bmff.hashBytes, {
|
|
2016
|
+
exclusions: bmff.exclusions,
|
|
2017
|
+
alg: bmff.alg ?? void 0
|
|
2018
|
+
});
|
|
2019
|
+
const errorCodes = [...collectErrorCodes(manifest !== null, liveVideo !== null, streamIdValid, sequenceNumberValid, bmffHashMatches, continuityMethod, previousManifestId, lastManifestId)];
|
|
2020
|
+
const currentManifestId = manifest?.instanceId ?? null;
|
|
2021
|
+
return {
|
|
2022
|
+
result: {
|
|
2023
|
+
manifest: manifest ?? null,
|
|
2024
|
+
issuer,
|
|
2025
|
+
sequenceNumber,
|
|
2026
|
+
previousManifestId,
|
|
2027
|
+
streamId,
|
|
2028
|
+
continuityMethod,
|
|
2029
|
+
bmffHashHex: bmff.hashHex,
|
|
2030
|
+
isValid: errorCodes.length === 0,
|
|
2031
|
+
errorCodes
|
|
2032
|
+
},
|
|
2033
|
+
nextManifestId: currentManifestId ?? lastManifestId,
|
|
2034
|
+
nextState: {
|
|
2035
|
+
lastStreamId: streamId ?? state?.lastStreamId,
|
|
2036
|
+
lastSequenceNumber: sequenceNumber ?? state?.lastSequenceNumber
|
|
2037
|
+
}
|
|
2038
|
+
};
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
//#endregion
|
|
2042
|
+
export { C2paStatusCode, LiveVideoStatusCode, SequenceValidationReason, validateC2paInitSegment, validateC2paManifestBoxSegment, validateC2paSegment };
|
|
2043
|
+
//# sourceMappingURL=index.js.map
|