@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.
- package/dist/.build-stamp +0 -0
- package/dist/admission.d.ts +28 -0
- package/dist/admission.d.ts.map +1 -0
- package/dist/admission.js +128 -0
- package/dist/admission.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/source-auth.d.ts +37 -0
- package/dist/source-auth.d.ts.map +1 -0
- package/dist/source-auth.js +179 -0
- package/dist/source-auth.js.map +1 -0
- package/dist/verifier.d.ts +72 -0
- package/dist/verifier.d.ts.map +1 -0
- package/dist/verifier.js +376 -0
- package/dist/verifier.js.map +1 -0
- package/package.json +40 -0
- package/src/admission.ts +171 -0
- package/src/index.ts +3 -0
- package/src/source-auth.ts +230 -0
- package/src/verifier.test.ts +307 -0
- package/src/verifier.ts +520 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { blake3 } from '@noble/hashes/blake3'
|
|
2
|
+
|
|
3
|
+
export const SOURCE_AUTH_MANIFEST_VERSION = 1
|
|
4
|
+
export const SOURCE_AUTH_OBJECT_ENTRY_FRAME = 1
|
|
5
|
+
export const SOURCE_AUTH_GROUP_CLOSE_FRAME = 2
|
|
6
|
+
export const SOURCE_AUTH_OBJECT_ENTRY_LENGTH = 52
|
|
7
|
+
export const SOURCE_AUTH_GROUP_CLOSE_LENGTH = 112
|
|
8
|
+
export const SOURCE_AUTH_MAX_OBJECTS_PER_GROUP = 4096
|
|
9
|
+
export const SOURCE_AUTH_MAX_TRUE_LENGTH = 16 * 1024 * 1024
|
|
10
|
+
export const SOURCE_AUTH_SCOPE_LENGTH = 32
|
|
11
|
+
|
|
12
|
+
const SIGNATURE_DOMAIN = new TextEncoder().encode('blockcast-mmt-root-v1\0\0\0')
|
|
13
|
+
|
|
14
|
+
export interface SourceAuthObjectEntry {
|
|
15
|
+
kind: 'object'
|
|
16
|
+
groupId: bigint
|
|
17
|
+
objectId: number
|
|
18
|
+
trueLength: number
|
|
19
|
+
digest: Uint8Array
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SourceAuthGroupClose {
|
|
23
|
+
kind: 'close'
|
|
24
|
+
groupId: bigint
|
|
25
|
+
objectCount: number
|
|
26
|
+
root: Uint8Array
|
|
27
|
+
signature: Uint8Array
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type SourceAuthManifestFrame = SourceAuthObjectEntry | SourceAuthGroupClose
|
|
31
|
+
|
|
32
|
+
function requireBytes(name: string, value: Uint8Array, length: number): void {
|
|
33
|
+
if (value.byteLength !== length) {
|
|
34
|
+
throw new Error(`${name} must be ${length} bytes, got ${value.byteLength}`)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function requireU32(name: string, value: number): void {
|
|
39
|
+
if (!Number.isInteger(value) || value < 0 || value > 0xffff_ffff) {
|
|
40
|
+
throw new Error(`${name} must be an unsigned 32-bit integer`)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function requireU64(name: string, value: bigint): void {
|
|
45
|
+
if (value < 0n || value > 0xffff_ffff_ffff_ffffn) {
|
|
46
|
+
throw new Error(`${name} must be an unsigned 64-bit integer`)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function requireTrueLength(value: number): void {
|
|
51
|
+
requireU32('trueLength', value)
|
|
52
|
+
if (value > SOURCE_AUTH_MAX_TRUE_LENGTH) {
|
|
53
|
+
throw new Error(`trueLength exceeds the ${SOURCE_AUTH_MAX_TRUE_LENGTH}-byte profile maximum`)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function sourceAuthObjectDigest(object: Uint8Array): Uint8Array {
|
|
58
|
+
return blake3(object)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function sourceAuthLeafHash(
|
|
62
|
+
groupId: bigint,
|
|
63
|
+
objectId: number,
|
|
64
|
+
digest: Uint8Array,
|
|
65
|
+
trueLength: number,
|
|
66
|
+
): Uint8Array {
|
|
67
|
+
requireU64('groupId', groupId)
|
|
68
|
+
requireU32('objectId', objectId)
|
|
69
|
+
requireBytes('digest', digest, 32)
|
|
70
|
+
requireTrueLength(trueLength)
|
|
71
|
+
const preimage = new Uint8Array(49)
|
|
72
|
+
const view = new DataView(preimage.buffer)
|
|
73
|
+
view.setBigUint64(1, groupId)
|
|
74
|
+
view.setUint32(9, objectId)
|
|
75
|
+
preimage.set(digest, 13)
|
|
76
|
+
view.setUint32(45, trueLength)
|
|
77
|
+
return blake3(preimage)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function sourceAuthMerkleRoot(
|
|
81
|
+
entries: readonly {
|
|
82
|
+
groupId: bigint
|
|
83
|
+
objectId: number
|
|
84
|
+
digest: Uint8Array
|
|
85
|
+
trueLength: number
|
|
86
|
+
}[],
|
|
87
|
+
): Uint8Array {
|
|
88
|
+
if (entries.length === 0) throw new Error('Merkle tree requires at least one object')
|
|
89
|
+
if (entries.length > SOURCE_AUTH_MAX_OBJECTS_PER_GROUP) {
|
|
90
|
+
throw new Error(`group exceeds ${SOURCE_AUTH_MAX_OBJECTS_PER_GROUP} objects`)
|
|
91
|
+
}
|
|
92
|
+
const groupId = entries[0]!.groupId
|
|
93
|
+
const objectIds = new Set<number>()
|
|
94
|
+
const ordered = [...entries].sort((left, right) => left.objectId - right.objectId)
|
|
95
|
+
let level = ordered.map((entry) => {
|
|
96
|
+
if (entry.groupId !== groupId) throw new Error('Merkle entries must belong to one group')
|
|
97
|
+
if (objectIds.has(entry.objectId)) throw new Error(`duplicate objectId ${entry.objectId}`)
|
|
98
|
+
objectIds.add(entry.objectId)
|
|
99
|
+
return sourceAuthLeafHash(entry.groupId, entry.objectId, entry.digest, entry.trueLength)
|
|
100
|
+
})
|
|
101
|
+
while (level.length > 1) {
|
|
102
|
+
const parents: Uint8Array[] = []
|
|
103
|
+
for (let index = 0; index < level.length; index += 2) {
|
|
104
|
+
const left = level[index]!
|
|
105
|
+
const right = level[index + 1] ?? left
|
|
106
|
+
const preimage = new Uint8Array(65)
|
|
107
|
+
preimage[0] = 1
|
|
108
|
+
preimage.set(left, 1)
|
|
109
|
+
preimage.set(right, 33)
|
|
110
|
+
parents.push(blake3(preimage))
|
|
111
|
+
}
|
|
112
|
+
level = parents
|
|
113
|
+
}
|
|
114
|
+
return level[0]!
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function trimRecoveredSourceAuthObject(
|
|
118
|
+
recoveredSymbol: Uint8Array,
|
|
119
|
+
trueLength: number,
|
|
120
|
+
): Uint8Array {
|
|
121
|
+
requireTrueLength(trueLength)
|
|
122
|
+
if (trueLength > recoveredSymbol.byteLength) {
|
|
123
|
+
throw new Error(`true length ${trueLength} exceeds recovered object size ${recoveredSymbol.byteLength}`)
|
|
124
|
+
}
|
|
125
|
+
return recoveredSymbol.subarray(0, trueLength)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function encodeSourceAuthObjectEntry(
|
|
129
|
+
entry: Omit<SourceAuthObjectEntry, 'kind'>,
|
|
130
|
+
): Uint8Array {
|
|
131
|
+
requireU32('objectId', entry.objectId)
|
|
132
|
+
requireTrueLength(entry.trueLength)
|
|
133
|
+
requireU64('groupId', entry.groupId)
|
|
134
|
+
requireBytes('digest', entry.digest, 32)
|
|
135
|
+
const frame = new Uint8Array(SOURCE_AUTH_OBJECT_ENTRY_LENGTH)
|
|
136
|
+
const view = new DataView(frame.buffer)
|
|
137
|
+
frame[0] = SOURCE_AUTH_OBJECT_ENTRY_FRAME
|
|
138
|
+
frame[1] = SOURCE_AUTH_MANIFEST_VERSION
|
|
139
|
+
view.setBigUint64(4, entry.groupId)
|
|
140
|
+
view.setUint32(12, entry.objectId)
|
|
141
|
+
view.setUint32(16, entry.trueLength)
|
|
142
|
+
frame.set(entry.digest, 20)
|
|
143
|
+
return frame
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function encodeSourceAuthGroupClose(
|
|
147
|
+
close: Omit<SourceAuthGroupClose, 'kind'>,
|
|
148
|
+
): Uint8Array {
|
|
149
|
+
requireU32('objectCount', close.objectCount)
|
|
150
|
+
if (close.objectCount === 0 || close.objectCount > SOURCE_AUTH_MAX_OBJECTS_PER_GROUP) {
|
|
151
|
+
throw new Error(`objectCount must be between 1 and ${SOURCE_AUTH_MAX_OBJECTS_PER_GROUP}`)
|
|
152
|
+
}
|
|
153
|
+
requireU64('groupId', close.groupId)
|
|
154
|
+
requireBytes('root', close.root, 32)
|
|
155
|
+
requireBytes('signature', close.signature, 64)
|
|
156
|
+
const frame = new Uint8Array(SOURCE_AUTH_GROUP_CLOSE_LENGTH)
|
|
157
|
+
const view = new DataView(frame.buffer)
|
|
158
|
+
frame[0] = SOURCE_AUTH_GROUP_CLOSE_FRAME
|
|
159
|
+
frame[1] = SOURCE_AUTH_MANIFEST_VERSION
|
|
160
|
+
view.setBigUint64(4, close.groupId)
|
|
161
|
+
view.setUint32(12, close.objectCount)
|
|
162
|
+
frame.set(close.root, 16)
|
|
163
|
+
frame.set(close.signature, 48)
|
|
164
|
+
return frame
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function sourceAuthSignaturePreimage(
|
|
168
|
+
authScope: Uint8Array,
|
|
169
|
+
groupId: bigint,
|
|
170
|
+
objectCount: number,
|
|
171
|
+
root: Uint8Array,
|
|
172
|
+
): Uint8Array {
|
|
173
|
+
requireU32('objectCount', objectCount)
|
|
174
|
+
if (objectCount === 0 || objectCount > SOURCE_AUTH_MAX_OBJECTS_PER_GROUP) {
|
|
175
|
+
throw new Error(`objectCount must be between 1 and ${SOURCE_AUTH_MAX_OBJECTS_PER_GROUP}`)
|
|
176
|
+
}
|
|
177
|
+
requireU64('groupId', groupId)
|
|
178
|
+
requireBytes('authScope', authScope, SOURCE_AUTH_SCOPE_LENGTH)
|
|
179
|
+
requireBytes('root', root, 32)
|
|
180
|
+
const preimage = new Uint8Array(100)
|
|
181
|
+
const view = new DataView(preimage.buffer)
|
|
182
|
+
preimage.set(SIGNATURE_DOMAIN)
|
|
183
|
+
preimage.set(authScope, 24)
|
|
184
|
+
view.setBigUint64(56, groupId)
|
|
185
|
+
view.setUint32(64, objectCount)
|
|
186
|
+
preimage.set(root, 68)
|
|
187
|
+
return preimage
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function decodeSourceAuthManifestFrame(frame: Uint8Array): SourceAuthManifestFrame {
|
|
191
|
+
const frameType = frame[0]
|
|
192
|
+
const expectedLength = frameType === SOURCE_AUTH_OBJECT_ENTRY_FRAME
|
|
193
|
+
? SOURCE_AUTH_OBJECT_ENTRY_LENGTH
|
|
194
|
+
: frameType === SOURCE_AUTH_GROUP_CLOSE_FRAME
|
|
195
|
+
? SOURCE_AUTH_GROUP_CLOSE_LENGTH
|
|
196
|
+
: 0
|
|
197
|
+
if (expectedLength === 0) throw new Error(`unsupported manifest frame type ${frameType}`)
|
|
198
|
+
if (frame.byteLength !== expectedLength) {
|
|
199
|
+
throw new Error(`manifest frame has invalid length ${frame.byteLength} for type ${frameType}`)
|
|
200
|
+
}
|
|
201
|
+
if (frame[1] !== SOURCE_AUTH_MANIFEST_VERSION) {
|
|
202
|
+
throw new Error(`unsupported manifest version ${frame[1]}`)
|
|
203
|
+
}
|
|
204
|
+
if (frame[2] !== 0 || frame[3] !== 0) throw new Error('manifest reserved bits must be zero')
|
|
205
|
+
|
|
206
|
+
const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength)
|
|
207
|
+
const groupId = view.getBigUint64(4)
|
|
208
|
+
if (frameType === SOURCE_AUTH_OBJECT_ENTRY_FRAME) {
|
|
209
|
+
const entry: SourceAuthObjectEntry = {
|
|
210
|
+
kind: 'object',
|
|
211
|
+
groupId,
|
|
212
|
+
objectId: view.getUint32(12),
|
|
213
|
+
trueLength: view.getUint32(16),
|
|
214
|
+
digest: frame.slice(20, 52),
|
|
215
|
+
}
|
|
216
|
+
requireTrueLength(entry.trueLength)
|
|
217
|
+
return entry
|
|
218
|
+
}
|
|
219
|
+
const close: SourceAuthGroupClose = {
|
|
220
|
+
kind: 'close',
|
|
221
|
+
groupId,
|
|
222
|
+
objectCount: view.getUint32(12),
|
|
223
|
+
root: frame.slice(16, 48),
|
|
224
|
+
signature: frame.slice(48, 112),
|
|
225
|
+
}
|
|
226
|
+
if (close.objectCount === 0 || close.objectCount > SOURCE_AUTH_MAX_OBJECTS_PER_GROUP) {
|
|
227
|
+
throw new Error(`objectCount must be between 1 and ${SOURCE_AUTH_MAX_OBJECTS_PER_GROUP}`)
|
|
228
|
+
}
|
|
229
|
+
return close
|
|
230
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from 'vitest'
|
|
2
|
+
import { FecBlockMapper } from '@blockcast/mmt-fec'
|
|
3
|
+
import {
|
|
4
|
+
encodeSourceAuthGroupClose,
|
|
5
|
+
encodeSourceAuthObjectEntry,
|
|
6
|
+
ManifestVerifier,
|
|
7
|
+
sourceAuthMerkleRoot,
|
|
8
|
+
sourceAuthObjectDigest,
|
|
9
|
+
} from './index.js'
|
|
10
|
+
|
|
11
|
+
const mapper = new FecBlockMapper({ interleaveDepthMs: 1000, gopDurationMs: 1000, k: 4 })
|
|
12
|
+
|
|
13
|
+
function createVerifier(options?: {
|
|
14
|
+
mode?: 'off' | 'shadow' | 'enforce'
|
|
15
|
+
onMissingManifest?: 'stall' | 'fallback-shadow'
|
|
16
|
+
signatureValid?: boolean
|
|
17
|
+
bitrate?: number
|
|
18
|
+
authenticated?: boolean
|
|
19
|
+
}) {
|
|
20
|
+
const failures = vi.fn()
|
|
21
|
+
const verifier = new ManifestVerifier({
|
|
22
|
+
manifestAuth: {
|
|
23
|
+
mode: options?.mode ?? 'enforce',
|
|
24
|
+
onMissingManifest: options?.onMissingManifest ?? 'stall',
|
|
25
|
+
},
|
|
26
|
+
verifySignature: () => options?.signatureValid ?? true,
|
|
27
|
+
onFailure: failures,
|
|
28
|
+
})
|
|
29
|
+
verifier.registerTrack({
|
|
30
|
+
broadcastNamespaceId: 'broadcast-1',
|
|
31
|
+
trackName: 'video',
|
|
32
|
+
keyEpoch: 7,
|
|
33
|
+
codec: 'avc1.640033',
|
|
34
|
+
timing: '30/1',
|
|
35
|
+
fecGeometry: 'K=4,T=1200,D=1',
|
|
36
|
+
manifestFormatVersion: 1,
|
|
37
|
+
authScope: new Uint8Array(32).fill(1),
|
|
38
|
+
publicKey: new Uint8Array(32).fill(2),
|
|
39
|
+
bitrate: options?.bitrate ?? 80_000,
|
|
40
|
+
groupDurationMs: 1000,
|
|
41
|
+
mapper,
|
|
42
|
+
manifestChannelAuthenticated: options?.authenticated ?? true,
|
|
43
|
+
})
|
|
44
|
+
return { verifier, failures }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function entry(groupId: number, objectId: number, data: Uint8Array) {
|
|
48
|
+
return {
|
|
49
|
+
groupId: BigInt(groupId),
|
|
50
|
+
objectId,
|
|
51
|
+
trueLength: data.byteLength,
|
|
52
|
+
digest: sourceAuthObjectDigest(data),
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function ingestEntry(verifier: ManifestVerifier, value: ReturnType<typeof entry>): void {
|
|
57
|
+
verifier.ingestManifestFrame('video', encodeSourceAuthObjectEntry(value))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function closeGroup(
|
|
61
|
+
verifier: ManifestVerifier,
|
|
62
|
+
groupId: number,
|
|
63
|
+
entries: ReturnType<typeof entry>[],
|
|
64
|
+
): void {
|
|
65
|
+
verifier.ingestManifestFrame('video', encodeSourceAuthGroupClose({
|
|
66
|
+
groupId: BigInt(groupId),
|
|
67
|
+
objectCount: entries.length,
|
|
68
|
+
root: sourceAuthMerkleRoot(entries),
|
|
69
|
+
signature: new Uint8Array(64).fill(3),
|
|
70
|
+
}))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe('ManifestVerifier', () => {
|
|
74
|
+
test('supports off, shadow, and enforce modes', () => {
|
|
75
|
+
const data = new Uint8Array([1, 2, 3])
|
|
76
|
+
const identity = { trackName: 'video', groupId: 1, objectId: 0, ssId: 4 }
|
|
77
|
+
|
|
78
|
+
const off = createVerifier({ mode: 'off' }).verifier
|
|
79
|
+
const offRelease = vi.fn()
|
|
80
|
+
expect(off.admitObject(identity, data, offRelease)).toMatchObject({ action: 'play' })
|
|
81
|
+
expect(offRelease).toHaveBeenCalledOnce()
|
|
82
|
+
|
|
83
|
+
const shadow = createVerifier({ mode: 'shadow' }).verifier
|
|
84
|
+
const shadowRelease = vi.fn()
|
|
85
|
+
expect(shadow.admitObject(identity, data, shadowRelease)).toMatchObject({
|
|
86
|
+
action: 'play',
|
|
87
|
+
reason: 'manifestAbsent',
|
|
88
|
+
})
|
|
89
|
+
expect(shadowRelease).toHaveBeenCalledOnce()
|
|
90
|
+
|
|
91
|
+
const enforce = createVerifier().verifier
|
|
92
|
+
const enforceRelease = vi.fn()
|
|
93
|
+
expect(enforce.admitObject(identity, data, enforceRelease)).toMatchObject({ action: 'stall' })
|
|
94
|
+
expect(enforceRelease).not.toHaveBeenCalled()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('releases a late matching entry and drops only a digest mismatch', () => {
|
|
98
|
+
const { verifier, failures } = createVerifier()
|
|
99
|
+
const data = new Uint8Array([4, 5, 6])
|
|
100
|
+
const release = vi.fn()
|
|
101
|
+
verifier.admitObject({ trackName: 'video', groupId: 2, objectId: 1, ssId: 9 }, data, release)
|
|
102
|
+
ingestEntry(verifier, entry(2, 1, data))
|
|
103
|
+
expect(release).toHaveBeenCalledOnce()
|
|
104
|
+
expect(verifier.stats.verified).toBe(1)
|
|
105
|
+
expect(failures).toHaveBeenCalledWith(expect.objectContaining({ reason: 'manifestLate' }))
|
|
106
|
+
|
|
107
|
+
const badRelease = vi.fn()
|
|
108
|
+
ingestEntry(verifier, entry(2, 2, new Uint8Array([9])))
|
|
109
|
+
expect(verifier.admitObject(
|
|
110
|
+
{ trackName: 'video', groupId: 2, objectId: 2, ssId: 10 },
|
|
111
|
+
new Uint8Array([8]),
|
|
112
|
+
badRelease,
|
|
113
|
+
)).toMatchObject({ action: 'drop', reason: 'digestMismatch' })
|
|
114
|
+
expect(badRelease).not.toHaveBeenCalled()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('verifies one close per group and rejects quarantined media on a bad signature', () => {
|
|
118
|
+
const { verifier } = createVerifier({ authenticated: false, signatureValid: false })
|
|
119
|
+
const data = new Uint8Array([7, 8])
|
|
120
|
+
const manifestEntry = entry(3, 0, data)
|
|
121
|
+
ingestEntry(verifier, manifestEntry)
|
|
122
|
+
const release = vi.fn()
|
|
123
|
+
expect(verifier.admitObject(
|
|
124
|
+
{ trackName: 'video', groupId: 3, objectId: 0, ssId: 12 },
|
|
125
|
+
data,
|
|
126
|
+
release,
|
|
127
|
+
).action).toBe('stall')
|
|
128
|
+
closeGroup(verifier, 3, [manifestEntry])
|
|
129
|
+
expect(release).not.toHaveBeenCalled()
|
|
130
|
+
expect(verifier.stats.failures.sigInvalid).toBe(1)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('fails open on quarantine cap eviction and verifier exceptions', () => {
|
|
134
|
+
const capped = createVerifier({ bitrate: 8 }).verifier
|
|
135
|
+
const capRelease = vi.fn()
|
|
136
|
+
capped.admitObject(
|
|
137
|
+
{ trackName: 'video', groupId: 4, objectId: 0, ssId: 16 },
|
|
138
|
+
new Uint8Array(2),
|
|
139
|
+
capRelease,
|
|
140
|
+
)
|
|
141
|
+
expect(capRelease).toHaveBeenCalledOnce()
|
|
142
|
+
expect(capped.stats.failures.verifierError).toBe(1)
|
|
143
|
+
|
|
144
|
+
const release = vi.fn()
|
|
145
|
+
const verifier = new ManifestVerifier({
|
|
146
|
+
manifestAuth: { mode: 'enforce', onMissingManifest: 'stall' },
|
|
147
|
+
verifySignature: () => { throw new Error('WASM unavailable') },
|
|
148
|
+
})
|
|
149
|
+
verifier.registerTrack({
|
|
150
|
+
broadcastNamespaceId: 'broadcast-1', trackName: 'video', keyEpoch: 1,
|
|
151
|
+
codec: 'avc1', timing: '30/1', fecGeometry: 'K=4', manifestFormatVersion: 1,
|
|
152
|
+
authScope: new Uint8Array(32), publicKey: new Uint8Array(32), bitrate: 80_000,
|
|
153
|
+
groupDurationMs: 1000, mapper, manifestChannelAuthenticated: false,
|
|
154
|
+
})
|
|
155
|
+
const data = new Uint8Array([1])
|
|
156
|
+
const manifestEntry = entry(5, 0, data)
|
|
157
|
+
ingestEntry(verifier, manifestEntry)
|
|
158
|
+
verifier.admitObject({ trackName: 'video', groupId: 5, objectId: 0, ssId: 20 }, data, release)
|
|
159
|
+
closeGroup(verifier, 5, [manifestEntry])
|
|
160
|
+
expect(release).toHaveBeenCalledOnce()
|
|
161
|
+
expect(verifier.stats.failures.verifierError).toBe(1)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('switches enforce to shadow live and releases quarantine', () => {
|
|
165
|
+
const { verifier } = createVerifier()
|
|
166
|
+
const release = vi.fn()
|
|
167
|
+
verifier.admitObject(
|
|
168
|
+
{ trackName: 'video', groupId: 6, objectId: 0, ssId: 24 },
|
|
169
|
+
new Uint8Array([1]),
|
|
170
|
+
release,
|
|
171
|
+
)
|
|
172
|
+
verifier.setMode('shadow')
|
|
173
|
+
expect(release).toHaveBeenCalledOnce()
|
|
174
|
+
expect(verifier.config.mode).toBe('shadow')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test('does not release quarantine for malformed or incomplete manifest frames', () => {
|
|
178
|
+
const { verifier } = createVerifier()
|
|
179
|
+
const release = vi.fn()
|
|
180
|
+
verifier.admitObject(
|
|
181
|
+
{ trackName: 'video', groupId: 7, objectId: 0, ssId: 28 },
|
|
182
|
+
new Uint8Array([1]),
|
|
183
|
+
release,
|
|
184
|
+
)
|
|
185
|
+
verifier.ingestManifestFrame('video', new Uint8Array([0xff]))
|
|
186
|
+
verifier.ingestManifestFrame('video', encodeSourceAuthGroupClose({
|
|
187
|
+
groupId: 7n,
|
|
188
|
+
objectCount: 1,
|
|
189
|
+
root: new Uint8Array(32),
|
|
190
|
+
signature: new Uint8Array(64),
|
|
191
|
+
}))
|
|
192
|
+
expect(release).not.toHaveBeenCalled()
|
|
193
|
+
expect(verifier.stats.failures.verifierError).toBe(1)
|
|
194
|
+
expect(verifier.stats.failures.manifestAbsent).toBe(1)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('rejects entries appended after a verified close', () => {
|
|
198
|
+
const { verifier } = createVerifier({ authenticated: false })
|
|
199
|
+
const first = entry(8, 0, new Uint8Array([1]))
|
|
200
|
+
ingestEntry(verifier, first)
|
|
201
|
+
closeGroup(verifier, 8, [first])
|
|
202
|
+
|
|
203
|
+
ingestEntry(verifier, entry(8, 1, new Uint8Array([2])))
|
|
204
|
+
const release = vi.fn()
|
|
205
|
+
expect(verifier.admitObject(
|
|
206
|
+
{ trackName: 'video', groupId: 8, objectId: 1, ssId: 33 },
|
|
207
|
+
new Uint8Array([2]),
|
|
208
|
+
release,
|
|
209
|
+
).action).toBe('stall')
|
|
210
|
+
expect(release).not.toHaveBeenCalled()
|
|
211
|
+
expect(verifier.stats.failures.verifierError).toBe(1)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
test('requires exact authenticated object length', () => {
|
|
215
|
+
const { verifier } = createVerifier()
|
|
216
|
+
ingestEntry(verifier, entry(9, 0, new Uint8Array([1])))
|
|
217
|
+
const release = vi.fn()
|
|
218
|
+
expect(verifier.admitObject(
|
|
219
|
+
{ trackName: 'video', groupId: 9, objectId: 0, ssId: 36 },
|
|
220
|
+
new Uint8Array([1, 2]),
|
|
221
|
+
release,
|
|
222
|
+
)).toMatchObject({ action: 'drop', reason: 'digestMismatch' })
|
|
223
|
+
expect(release).not.toHaveBeenCalled()
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
test('keeps first arrival on duplicate pending object identity', () => {
|
|
227
|
+
const { verifier } = createVerifier()
|
|
228
|
+
const firstRelease = vi.fn()
|
|
229
|
+
const duplicateRelease = vi.fn()
|
|
230
|
+
const identity = { trackName: 'video', groupId: 10, objectId: 0, ssId: 40 }
|
|
231
|
+
verifier.admitObject(identity, new Uint8Array([1]), firstRelease)
|
|
232
|
+
verifier.admitObject(identity, new Uint8Array([1]), duplicateRelease)
|
|
233
|
+
expect(verifier.stats.quarantinedBytes).toBe(1)
|
|
234
|
+
ingestEntry(verifier, entry(10, 0, new Uint8Array([1])))
|
|
235
|
+
expect(firstRelease).toHaveBeenCalledOnce()
|
|
236
|
+
expect(duplicateRelease).not.toHaveBeenCalled()
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
test('re-attempts group close when entries complete after a reordered close frame', () => {
|
|
240
|
+
const { verifier } = createVerifier({ authenticated: false })
|
|
241
|
+
const dataA = new Uint8Array([1, 2])
|
|
242
|
+
const dataB = new Uint8Array([3])
|
|
243
|
+
const entryA = entry(11, 0, dataA)
|
|
244
|
+
const entryB = entry(11, 1, dataB)
|
|
245
|
+
|
|
246
|
+
// Manifest-channel reorder: the close frame beats every object entry.
|
|
247
|
+
closeGroup(verifier, 11, [entryA, entryB])
|
|
248
|
+
expect(verifier.stats.failures.manifestAbsent).toBe(1)
|
|
249
|
+
|
|
250
|
+
const release = vi.fn()
|
|
251
|
+
expect(verifier.admitObject(
|
|
252
|
+
{ trackName: 'video', groupId: 11, objectId: 0, ssId: 44 },
|
|
253
|
+
dataA,
|
|
254
|
+
release,
|
|
255
|
+
).action).toBe('stall')
|
|
256
|
+
|
|
257
|
+
// Before the last entry arrives the object stays quarantined, not played.
|
|
258
|
+
ingestEntry(verifier, entryA)
|
|
259
|
+
expect(release).not.toHaveBeenCalled()
|
|
260
|
+
expect(verifier.stats.verified).toBe(0)
|
|
261
|
+
|
|
262
|
+
// Final entry completes the group: the stashed close is re-attempted and
|
|
263
|
+
// the quarantined object is released verified.
|
|
264
|
+
ingestEntry(verifier, entryB)
|
|
265
|
+
expect(release).toHaveBeenCalledOnce()
|
|
266
|
+
expect(verifier.stats.verified).toBe(1)
|
|
267
|
+
|
|
268
|
+
const directRelease = vi.fn()
|
|
269
|
+
expect(verifier.admitObject(
|
|
270
|
+
{ trackName: 'video', groupId: 11, objectId: 1, ssId: 45 },
|
|
271
|
+
dataB,
|
|
272
|
+
directRelease,
|
|
273
|
+
)).toMatchObject({ action: 'play', verified: true })
|
|
274
|
+
expect(directRelease).toHaveBeenCalledOnce()
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
test('verifies a group whose id exceeds Number.MAX_SAFE_INTEGER (u62 group ids)', () => {
|
|
278
|
+
const { verifier, failures } = createVerifier()
|
|
279
|
+
const groupId = 72623859790382856n
|
|
280
|
+
expect(Number.isSafeInteger(Number(groupId))).toBe(false)
|
|
281
|
+
|
|
282
|
+
const data = new Uint8Array([9, 8, 7])
|
|
283
|
+
const bigEntry = {
|
|
284
|
+
groupId,
|
|
285
|
+
objectId: 0,
|
|
286
|
+
trueLength: data.byteLength,
|
|
287
|
+
digest: sourceAuthObjectDigest(data),
|
|
288
|
+
}
|
|
289
|
+
verifier.ingestManifestFrame('video', encodeSourceAuthObjectEntry(bigEntry))
|
|
290
|
+
verifier.ingestManifestFrame('video', encodeSourceAuthGroupClose({
|
|
291
|
+
groupId,
|
|
292
|
+
objectCount: 1,
|
|
293
|
+
root: sourceAuthMerkleRoot([bigEntry]),
|
|
294
|
+
signature: new Uint8Array(64).fill(3),
|
|
295
|
+
}))
|
|
296
|
+
|
|
297
|
+
const release = vi.fn()
|
|
298
|
+
expect(verifier.admitObject(
|
|
299
|
+
{ trackName: 'video', groupId, objectId: 0 },
|
|
300
|
+
data,
|
|
301
|
+
release,
|
|
302
|
+
)).toMatchObject({ action: 'play', verified: true })
|
|
303
|
+
expect(release).toHaveBeenCalledOnce()
|
|
304
|
+
expect(verifier.stats.verified).toBe(1)
|
|
305
|
+
expect(failures).not.toHaveBeenCalled()
|
|
306
|
+
})
|
|
307
|
+
})
|