@blockcast/mmt-manifest-verify 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,520 @@
1
+ import {
2
+ decodeSourceAuthManifestFrame,
3
+ sourceAuthMerkleRoot,
4
+ sourceAuthObjectDigest,
5
+ sourceAuthSignaturePreimage,
6
+ type SourceAuthGroupClose,
7
+ type SourceAuthObjectEntry,
8
+ } from './source-auth.js'
9
+
10
+ export type ManifestAuthMode = 'off' | 'shadow' | 'enforce'
11
+ export type MissingManifestPolicy = 'stall' | 'fallback-shadow'
12
+ export type ManifestFailureReason =
13
+ | 'sigInvalid'
14
+ | 'digestMismatch'
15
+ | 'manifestLate'
16
+ | 'manifestAbsent'
17
+ | 'verifierError'
18
+
19
+ export interface ManifestAuthConfig {
20
+ mode: ManifestAuthMode
21
+ onMissingManifest: MissingManifestPolicy
22
+ }
23
+
24
+ export interface ManifestObjectIdentity {
25
+ trackName: string
26
+ /** MoQ group ids are u62; values above 2^53 must be passed as bigint. */
27
+ groupId: number | bigint
28
+ objectId: number
29
+ packetId?: number
30
+ ssId?: number
31
+ }
32
+
33
+ export interface ManifestFecBlockMapper {
34
+ readonly d: number
35
+ derive(groupId: number, objectId: number): { ssId: number }
36
+ }
37
+
38
+ export interface ManifestTrackContext {
39
+ broadcastNamespaceId: string
40
+ trackName: string
41
+ keyEpoch: number
42
+ codec: string
43
+ timing: string
44
+ fecGeometry: string
45
+ manifestFormatVersion: number
46
+ /** Authenticated-catalog binding for the namespace, track, epoch, codec,
47
+ * timing, FEC geometry, and manifest version fields above. */
48
+ authScope: Uint8Array
49
+ publicKey: Uint8Array
50
+ bitrate: number
51
+ groupDurationMs: number
52
+ mapper: ManifestFecBlockMapper
53
+ manifestChannelAuthenticated: boolean
54
+ }
55
+
56
+ export interface ManifestVerificationStats {
57
+ verified: number
58
+ failures: Record<ManifestFailureReason, number>
59
+ quarantinedBytes: number
60
+ }
61
+
62
+ export interface ManifestFailure {
63
+ trackName: string
64
+ groupId: number | bigint
65
+ objectId: number
66
+ reason: ManifestFailureReason
67
+ }
68
+
69
+ export interface ManifestAdmission {
70
+ action: 'play' | 'stall' | 'drop'
71
+ verified: boolean
72
+ reason?: ManifestFailureReason
73
+ }
74
+
75
+ type SignatureVerifier = (
76
+ publicKey: Uint8Array,
77
+ message: Uint8Array,
78
+ signature: Uint8Array,
79
+ ) => boolean
80
+
81
+ interface PendingObject {
82
+ identity: ManifestObjectIdentity
83
+ data: Uint8Array
84
+ release: () => void
85
+ bytes: number
86
+ }
87
+
88
+ interface GroupState {
89
+ entries: Map<number, SourceAuthObjectEntry>
90
+ pending: Map<number, PendingObject>
91
+ close?: SourceAuthGroupClose
92
+ /** Close frame that arrived before all object entries (manifest-channel
93
+ * reordering); retried once the entry count matches. */
94
+ pendingClose?: SourceAuthGroupClose
95
+ rootVerified: boolean
96
+ failed?: ManifestFailureReason
97
+ }
98
+
99
+ interface TrackState {
100
+ context: ManifestTrackContext
101
+ groups: Map<bigint, GroupState>
102
+ quarantine: PendingObject[]
103
+ quarantinedBytes: number
104
+ capBytes: number
105
+ manifestEntries: number
106
+ maxManifestEntries: number
107
+ maxGroups: number
108
+ }
109
+
110
+ const FAILURE_REASONS: readonly ManifestFailureReason[] = [
111
+ 'sigInvalid',
112
+ 'digestMismatch',
113
+ 'manifestLate',
114
+ 'manifestAbsent',
115
+ 'verifierError',
116
+ ]
117
+
118
+ const equalBytes = (left: Uint8Array, right: Uint8Array): boolean => {
119
+ if (left.byteLength !== right.byteLength) return false
120
+ let diff = 0
121
+ for (let index = 0; index < left.byteLength; index++) diff |= left[index]! ^ right[index]!
122
+ return diff === 0
123
+ }
124
+
125
+ export class ManifestVerifier {
126
+ #config: ManifestAuthConfig
127
+ readonly #verifySignature: SignatureVerifier
128
+ readonly #onFailure?: (failure: ManifestFailure) => void
129
+ readonly #tracks = new Map<string, TrackState>()
130
+ readonly #stats: ManifestVerificationStats = {
131
+ verified: 0,
132
+ failures: Object.fromEntries(FAILURE_REASONS.map((reason) => [reason, 0])) as Record<ManifestFailureReason, number>,
133
+ quarantinedBytes: 0,
134
+ }
135
+
136
+ constructor(options: {
137
+ manifestAuth: ManifestAuthConfig
138
+ verifySignature: SignatureVerifier
139
+ onFailure?: (failure: ManifestFailure) => void
140
+ }) {
141
+ this.#config = { ...options.manifestAuth }
142
+ this.#verifySignature = options.verifySignature
143
+ this.#onFailure = options.onFailure
144
+ }
145
+
146
+ get config(): Readonly<ManifestAuthConfig> {
147
+ return this.#config
148
+ }
149
+
150
+ get stats(): ManifestVerificationStats {
151
+ return {
152
+ verified: this.#stats.verified,
153
+ failures: { ...this.#stats.failures },
154
+ quarantinedBytes: this.#stats.quarantinedBytes,
155
+ }
156
+ }
157
+
158
+ setMode(mode: ManifestAuthMode): void {
159
+ this.#config = { ...this.#config, mode }
160
+ if (mode !== 'enforce') this.#releaseAllQuarantine(false)
161
+ }
162
+
163
+ registerTrack(context: ManifestTrackContext): void {
164
+ if (this.#tracks.has(context.trackName)) {
165
+ throw new Error(`manifest track ${context.trackName} is already registered`)
166
+ }
167
+ if (!Number.isFinite(context.bitrate) || context.bitrate <= 0) {
168
+ throw new Error('manifest track bitrate must be > 0')
169
+ }
170
+ if (!Number.isFinite(context.groupDurationMs) || context.groupDurationMs <= 0) {
171
+ throw new Error('manifest track groupDurationMs must be > 0')
172
+ }
173
+ if (context.authScope.byteLength !== 32) throw new Error('manifest authScope must be 32 bytes')
174
+ if (context.publicKey.byteLength !== 32) throw new Error('manifest publicKey must be 32 bytes')
175
+ for (const [name, value] of Object.entries({
176
+ broadcastNamespaceId: context.broadcastNamespaceId,
177
+ trackName: context.trackName,
178
+ codec: context.codec,
179
+ timing: context.timing,
180
+ fecGeometry: context.fecGeometry,
181
+ })) {
182
+ if (value.length === 0) throw new Error(`manifest ${name} must not be empty`)
183
+ }
184
+ this.#tracks.set(context.trackName, {
185
+ context,
186
+ groups: new Map(),
187
+ quarantine: [],
188
+ quarantinedBytes: 0,
189
+ capBytes: Math.ceil((context.bitrate * context.groupDurationMs) / 8000),
190
+ manifestEntries: 0,
191
+ maxManifestEntries: Math.ceil((context.bitrate * context.groupDurationMs) / (8000 * 52)),
192
+ maxGroups: context.mapper.d + 1,
193
+ })
194
+ }
195
+
196
+ ingestManifestFrame(trackName: string, frameBytes: Uint8Array): void {
197
+ const track = this.#requireTrack(trackName)
198
+ try {
199
+ const frame = decodeSourceAuthManifestFrame(frameBytes)
200
+ const groupId = frame.groupId
201
+ const group = this.#group(track, groupId)
202
+ if (frame.kind === 'object') {
203
+ if (group.close || group.failed) {
204
+ this.#failure({ trackName, groupId, objectId: frame.objectId }, 'verifierError')
205
+ return
206
+ }
207
+ if (group.entries.has(frame.objectId)) {
208
+ this.#failure({ trackName, groupId, objectId: frame.objectId }, 'verifierError')
209
+ return
210
+ }
211
+ if (group.entries.size >= 4096 || track.manifestEntries >= track.maxManifestEntries) {
212
+ this.#failure({ trackName, groupId, objectId: frame.objectId }, 'verifierError')
213
+ return
214
+ }
215
+ group.entries.set(frame.objectId, frame)
216
+ track.manifestEntries++
217
+ const pending = group.pending.get(frame.objectId)
218
+ if (pending) {
219
+ this.#failure(pending.identity, 'manifestLate')
220
+ this.#resolvePending(track, group, pending, frame)
221
+ }
222
+ if (group.pendingClose && group.entries.size === group.pendingClose.objectCount) {
223
+ const close = group.pendingClose
224
+ group.pendingClose = undefined
225
+ this.#closeGroup(track, groupId, group, close)
226
+ }
227
+ return
228
+ }
229
+ this.#closeGroup(track, groupId, group, frame)
230
+ } catch {
231
+ this.#failure({ trackName, groupId: 0, objectId: 0 }, 'verifierError')
232
+ }
233
+ }
234
+
235
+ admitObject(
236
+ identity: ManifestObjectIdentity,
237
+ data: Uint8Array,
238
+ release: () => void,
239
+ ): ManifestAdmission {
240
+ if (this.#config.mode === 'off') {
241
+ release()
242
+ return { action: 'play', verified: false }
243
+ }
244
+ const track = this.#tracks.get(identity.trackName)
245
+ if (!track) {
246
+ release()
247
+ this.#failure(identity, 'verifierError')
248
+ return { action: 'play', verified: false, reason: 'verifierError' }
249
+ }
250
+ try {
251
+ if (!this.#validateIdentity(track, identity)) {
252
+ return this.#rejectOrShadow(identity, release, 'digestMismatch')
253
+ }
254
+ const group = this.#group(track, BigInt(identity.groupId))
255
+ if (group.failed === 'sigInvalid' || group.failed === 'digestMismatch') {
256
+ return this.#rejectOrShadow(identity, release, group.failed)
257
+ }
258
+ const entry = group.entries.get(identity.objectId)
259
+ if (entry) return this.#admitAgainstEntry(track, group, identity, data, release, entry)
260
+ if (this.#config.mode === 'shadow' || this.#config.onMissingManifest === 'fallback-shadow') {
261
+ release()
262
+ this.#failure(identity, 'manifestAbsent')
263
+ return { action: 'play', verified: false, reason: 'manifestAbsent' }
264
+ }
265
+ if (!this.#quarantine(track, group, { identity, data, release, bytes: data.byteLength })) {
266
+ return { action: 'drop', verified: false }
267
+ }
268
+ return { action: 'stall', verified: false }
269
+ } catch {
270
+ release()
271
+ this.#failure(identity, 'verifierError')
272
+ return { action: 'play', verified: false, reason: 'verifierError' }
273
+ }
274
+ }
275
+
276
+ dispose(): void {
277
+ this.#releaseAllQuarantine(false)
278
+ this.#tracks.clear()
279
+ }
280
+
281
+ #requireTrack(trackName: string): TrackState {
282
+ const track = this.#tracks.get(trackName)
283
+ if (!track) throw new Error(`unknown manifest track ${trackName}`)
284
+ return track
285
+ }
286
+
287
+ #group(track: TrackState, groupId: bigint): GroupState {
288
+ let group = track.groups.get(groupId)
289
+ if (!group) {
290
+ group = { entries: new Map(), pending: new Map(), rootVerified: false }
291
+ track.groups.set(groupId, group)
292
+ while (track.groups.size > track.maxGroups) {
293
+ const oldest = track.groups.entries().next().value as [bigint, GroupState] | undefined
294
+ if (!oldest) break
295
+ const [oldestId, oldestGroup] = oldest
296
+ for (const pending of [...oldestGroup.pending.values()]) {
297
+ this.#removePending(track, oldestGroup, pending)
298
+ pending.release()
299
+ this.#failure(pending.identity, 'verifierError')
300
+ }
301
+ track.manifestEntries -= oldestGroup.entries.size
302
+ track.groups.delete(oldestId)
303
+ }
304
+ }
305
+ return group
306
+ }
307
+
308
+ #validateIdentity(track: TrackState, identity: ManifestObjectIdentity): boolean {
309
+ const coordinates = track.context.mapper.derive(Number(identity.groupId), identity.objectId)
310
+ return identity.ssId === undefined || coordinates.ssId === identity.ssId
311
+ }
312
+
313
+ #admitAgainstEntry(
314
+ track: TrackState,
315
+ group: GroupState,
316
+ identity: ManifestObjectIdentity,
317
+ data: Uint8Array,
318
+ release: () => void,
319
+ entry: SourceAuthObjectEntry,
320
+ ): ManifestAdmission {
321
+ if (entry.trueLength !== data.byteLength) {
322
+ return this.#rejectOrShadow(identity, release, 'digestMismatch')
323
+ }
324
+ const digestMatches = equalBytes(sourceAuthObjectDigest(data.subarray(0, entry.trueLength)), entry.digest)
325
+ if (!digestMatches) return this.#rejectOrShadow(identity, release, 'digestMismatch')
326
+ if (!track.context.manifestChannelAuthenticated && !group.rootVerified) {
327
+ if (this.#config.mode === 'shadow') {
328
+ release()
329
+ return { action: 'play', verified: false }
330
+ }
331
+ if (!this.#quarantine(track, group, { identity, data, release, bytes: data.byteLength })) {
332
+ return { action: 'drop', verified: false }
333
+ }
334
+ return { action: 'stall', verified: false }
335
+ }
336
+ release()
337
+ this.#stats.verified++
338
+ return { action: 'play', verified: true }
339
+ }
340
+
341
+ #rejectOrShadow(
342
+ identity: ManifestObjectIdentity,
343
+ release: () => void,
344
+ reason: 'digestMismatch' | 'sigInvalid',
345
+ ): ManifestAdmission {
346
+ this.#failure(identity, reason)
347
+ if (this.#config.mode === 'enforce') {
348
+ console.warn(JSON.stringify({ event: 'manifest-enforce-drop', track: identity.trackName, group: String(identity.groupId), object: identity.objectId, reason }))
349
+ return { action: 'drop', verified: false, reason }
350
+ }
351
+ release()
352
+ return { action: 'play', verified: false, reason }
353
+ }
354
+
355
+ #resolvePending(
356
+ track: TrackState,
357
+ group: GroupState,
358
+ pending: PendingObject,
359
+ entry: SourceAuthObjectEntry,
360
+ ): void {
361
+ this.#removePending(track, group, pending)
362
+ this.#admitAgainstEntry(track, group, pending.identity, pending.data, pending.release, entry)
363
+ }
364
+
365
+ #closeGroup(
366
+ track: TrackState,
367
+ groupId: bigint,
368
+ group: GroupState,
369
+ close: SourceAuthGroupClose,
370
+ ): void {
371
+ if (group.close) return
372
+ const entries = [...group.entries.values()]
373
+ if (entries.length !== close.objectCount) {
374
+ // Manifest-channel reordering: the close frame beat some object entries.
375
+ // Stash it (latest-wins on duplicates) so the object-entry path can
376
+ // re-attempt the close once the count matches; quarantined objects stay
377
+ // quarantined, and manifestAbsent below is a signal, not an eviction.
378
+ group.pendingClose = close
379
+ const hadPending = group.pending.size > 0
380
+ if (this.#config.onMissingManifest === 'fallback-shadow' || this.#config.mode === 'shadow') {
381
+ for (const pending of [...group.pending.values()]) {
382
+ this.#removePending(track, group, pending)
383
+ pending.release()
384
+ this.#failure(pending.identity, 'manifestAbsent')
385
+ }
386
+ } else {
387
+ for (const pending of group.pending.values()) {
388
+ this.#failure(pending.identity, 'manifestAbsent')
389
+ }
390
+ }
391
+ if (!hadPending) {
392
+ this.#failure({ trackName: track.context.trackName, groupId, objectId: 0 }, 'manifestAbsent')
393
+ }
394
+ return
395
+ }
396
+ if (!equalBytes(sourceAuthMerkleRoot(entries), close.root)) {
397
+ group.failed = 'digestMismatch'
398
+ this.#failGroup(track, groupId, group, 'digestMismatch')
399
+ return
400
+ }
401
+ const preimage = sourceAuthSignaturePreimage(track.context.authScope, close.groupId, close.objectCount, close.root)
402
+ let signatureValid: boolean
403
+ try {
404
+ signatureValid = this.#verifySignature(track.context.publicKey, preimage, close.signature)
405
+ } catch {
406
+ this.#failOpenGroup(track, groupId, group)
407
+ return
408
+ }
409
+ if (!signatureValid) {
410
+ group.failed = 'sigInvalid'
411
+ this.#failGroup(track, groupId, group, 'sigInvalid')
412
+ return
413
+ }
414
+ group.failed = undefined
415
+ group.close = close
416
+ group.pendingClose = undefined
417
+ group.rootVerified = true
418
+ for (const pending of [...group.pending.values()]) {
419
+ const entry = group.entries.get(pending.identity.objectId)
420
+ if (entry) this.#resolvePending(track, group, pending, entry)
421
+ else if (this.#config.onMissingManifest === 'fallback-shadow') {
422
+ this.#removePending(track, group, pending)
423
+ pending.release()
424
+ this.#failure(pending.identity, 'manifestAbsent')
425
+ }
426
+ }
427
+ }
428
+
429
+ #failGroup(
430
+ track: TrackState,
431
+ groupId: bigint,
432
+ group: GroupState,
433
+ reason: 'digestMismatch' | 'sigInvalid',
434
+ ): void {
435
+ if (group.pending.size === 0) {
436
+ this.#failure({ trackName: track.context.trackName, groupId, objectId: 0 }, reason)
437
+ return
438
+ }
439
+ for (const pending of [...group.pending.values()]) {
440
+ this.#removePending(track, group, pending)
441
+ this.#rejectOrShadow(pending.identity, pending.release, reason)
442
+ }
443
+ }
444
+
445
+ #removePending(track: TrackState, group: GroupState, pending: PendingObject): void {
446
+ if (group.pending.get(pending.identity.objectId) === pending) {
447
+ group.pending.delete(pending.identity.objectId)
448
+ }
449
+ const index = track.quarantine.indexOf(pending)
450
+ if (index >= 0) track.quarantine.splice(index, 1)
451
+ track.quarantinedBytes -= pending.bytes
452
+ this.#stats.quarantinedBytes -= pending.bytes
453
+ }
454
+
455
+ #quarantine(track: TrackState, group: GroupState, pending: PendingObject): boolean {
456
+ if (group.pending.has(pending.identity.objectId)) return false
457
+ group.pending.set(pending.identity.objectId, pending)
458
+ track.quarantine.push(pending)
459
+ track.quarantinedBytes += pending.bytes
460
+ this.#stats.quarantinedBytes += pending.bytes
461
+ this.#enforceCap(track)
462
+ return true
463
+ }
464
+
465
+ #enforceCap(track: TrackState): void {
466
+ while (track.quarantinedBytes > track.capBytes && track.quarantine.length > 0) {
467
+ const pending = track.quarantine.shift()!
468
+ const group = track.groups.get(BigInt(pending.identity.groupId))
469
+ if (group) group.pending.delete(pending.identity.objectId)
470
+ track.quarantinedBytes -= pending.bytes
471
+ this.#stats.quarantinedBytes -= pending.bytes
472
+ pending.release()
473
+ this.#failure(pending.identity, 'verifierError')
474
+ console.warn(`[ManifestVerifier] quarantine cap eviction fail-open track=${pending.identity.trackName} group=${pending.identity.groupId} object=${pending.identity.objectId}`)
475
+ }
476
+ }
477
+
478
+ #failOpenTrack(track: TrackState, reason: 'verifierError'): void {
479
+ for (const pending of [...track.quarantine]) {
480
+ const group = track.groups.get(BigInt(pending.identity.groupId))
481
+ if (group) this.#removePending(track, group, pending)
482
+ pending.release()
483
+ this.#failure(pending.identity, reason)
484
+ }
485
+ }
486
+
487
+ #failOpenGroup(track: TrackState, groupId: bigint, group: GroupState): void {
488
+ const pending = [...group.pending.values()]
489
+ if (pending.length === 0) {
490
+ this.#failure({ trackName: track.context.trackName, groupId, objectId: 0 }, 'verifierError')
491
+ return
492
+ }
493
+ for (const object of pending) {
494
+ this.#removePending(track, group, object)
495
+ object.release()
496
+ this.#failure(object.identity, 'verifierError')
497
+ }
498
+ }
499
+
500
+ #releaseAllQuarantine(countFailure: boolean): void {
501
+ for (const track of this.#tracks.values()) {
502
+ for (const pending of [...track.quarantine]) {
503
+ const group = track.groups.get(BigInt(pending.identity.groupId))
504
+ if (group) this.#removePending(track, group, pending)
505
+ pending.release()
506
+ if (countFailure) this.#failure(pending.identity, 'verifierError')
507
+ }
508
+ }
509
+ }
510
+
511
+ #failure(identity: ManifestObjectIdentity, reason: ManifestFailureReason): void {
512
+ this.#stats.failures[reason]++
513
+ this.#onFailure?.({
514
+ trackName: identity.trackName,
515
+ groupId: identity.groupId,
516
+ objectId: identity.objectId,
517
+ reason,
518
+ })
519
+ }
520
+ }