@blockcast/mmt-base 0.3.0 → 0.4.0-main.2654dbc2ed6d
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/README.md +8 -18
- package/dist/catalog-fec-source.d.ts +79 -0
- package/dist/catalog-fec-source.d.ts.map +1 -0
- package/dist/catalog-fec-source.js +141 -0
- package/dist/catalog-fec-source.js.map +1 -0
- package/dist/fec-block-profile.d.ts +7 -7
- package/dist/fec-block-profile.d.ts.map +1 -1
- package/dist/fec-block-profile.js +22 -18
- package/dist/fec-block-profile.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/mmtp.d.ts +28 -24
- package/dist/mmtp.d.ts.map +1 -1
- package/dist/mmtp.js +63 -41
- package/dist/mmtp.js.map +1 -1
- package/dist/multicast-catalog.d.ts +75 -0
- package/dist/multicast-catalog.d.ts.map +1 -0
- package/dist/multicast-catalog.js +318 -0
- package/dist/multicast-catalog.js.map +1 -0
- package/dist/serial-number.d.ts +0 -29
- package/dist/serial-number.d.ts.map +1 -1
- package/dist/serial-number.js +0 -54
- package/dist/serial-number.js.map +1 -1
- package/dist/signalling.d.ts +49 -33
- package/dist/signalling.d.ts.map +1 -1
- package/dist/signalling.js +72 -58
- package/dist/signalling.js.map +1 -1
- package/package.json +2 -4
- package/src/catalog-fec-source.ts +170 -0
- package/src/fec-block-profile.ts +24 -24
- package/src/index.ts +2 -0
- package/src/mmtp.ts +78 -59
- package/src/multicast-catalog.ts +350 -0
- package/src/serial-number.ts +0 -58
- package/src/signalling.ts +87 -75
package/src/mmtp.ts
CHANGED
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
MMTP_EXT_TYPE_ALTA_AUTH as _LEAF_MMTP_EXT_TYPE_ALTA_AUTH,
|
|
3
|
-
MMTP_EXT_TRAILER_HEADER_SIZE as _LEAF_MMTP_EXT_TRAILER_HEADER_SIZE,
|
|
4
|
-
extractAltaTrailer as _leafExtractAltaTrailer,
|
|
5
|
-
type AltaTrailer as _LeafAltaTrailer,
|
|
6
|
-
} from '@blockcast/mmt-alta-parse'
|
|
7
|
-
|
|
8
1
|
/**
|
|
9
2
|
* @mmt/container - MMTP Parser
|
|
10
3
|
*
|
|
@@ -81,7 +74,7 @@ export const MFU_DU_HEADER_SIZE_NONTIMED = 4 // T=0: non-timed media (item_id o
|
|
|
81
74
|
|
|
82
75
|
const MPU_LENGTH_FIELD_SIZE = 2
|
|
83
76
|
const MPU_HEADER_REMAINDER_SIZE = MPU_HEADER_SIZE - MPU_LENGTH_FIELD_SIZE
|
|
84
|
-
const SOURCE_FEC_PAYLOAD_ID_SIZE = 4
|
|
77
|
+
export const SOURCE_FEC_PAYLOAD_ID_SIZE = 4
|
|
85
78
|
|
|
86
79
|
// MMTP Packet Types (ISO/IEC 23008-1:2023 Section 9.2.2)
|
|
87
80
|
export const PACKET_TYPE_MPU = 0x00
|
|
@@ -103,9 +96,10 @@ export const MMTP_SIGNALING_PACKET_ID = 0
|
|
|
103
96
|
/** Largest value of the 16-bit MMTP `packet_id` header field. */
|
|
104
97
|
export const MMTP_PACKET_ID_MAX = 0xffff
|
|
105
98
|
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
export const
|
|
99
|
+
// fec_code_id_for_repair_flow code points (ISO/IEC 23008-10 Table 1;
|
|
100
|
+
// draft-ramadan-moq-mmt-01 Section 8.3).
|
|
101
|
+
export const FEC_CODE_REED_SOLOMON = 1
|
|
102
|
+
export const FEC_CODE_RAPTORQ = 3
|
|
109
103
|
|
|
110
104
|
/**
|
|
111
105
|
* Parse MMTP header from buffer
|
|
@@ -353,9 +347,17 @@ function resolveMfuPayloadLayout(
|
|
|
353
347
|
}
|
|
354
348
|
}
|
|
355
349
|
|
|
356
|
-
|
|
350
|
+
// Legacy detached per-packet authenticator suffix `[0xf0a1][len][auth]`. Its
|
|
351
|
+
// producer was removed (BLO-16483) and nothing verifies it any more; provenance
|
|
352
|
+
// is the signed per-group manifest. Receivers still skip an exact suffix so a
|
|
353
|
+
// stale producer degrades to unauthenticated playback instead of dropped
|
|
354
|
+
// packets. ponytail: hard-cut to reject once no deployed producer can emit it.
|
|
355
|
+
const LEGACY_TRAILER_EXT_TYPE = 0xf0a1
|
|
356
|
+
const LEGACY_TRAILER_HEADER_SIZE = 4
|
|
357
|
+
|
|
358
|
+
function isEmptyOrExactLegacyTrailer(payload: Uint8Array, start: number, end: number): boolean {
|
|
357
359
|
if (start === end) return true
|
|
358
|
-
if (start < 0 || end > payload.length || start +
|
|
360
|
+
if (start < 0 || end > payload.length || start + LEGACY_TRAILER_HEADER_SIZE > end) {
|
|
359
361
|
return false
|
|
360
362
|
}
|
|
361
363
|
|
|
@@ -364,46 +366,73 @@ function isEmptyOrExactAltaTrailer(payload: Uint8Array, start: number, end: numb
|
|
|
364
366
|
payload.byteOffset + start,
|
|
365
367
|
end - start,
|
|
366
368
|
)
|
|
367
|
-
if (view.getUint16(0, false) !==
|
|
369
|
+
if (view.getUint16(0, false) !== LEGACY_TRAILER_EXT_TYPE) return false
|
|
368
370
|
|
|
369
371
|
const authLength = view.getUint16(2, false)
|
|
370
|
-
return start +
|
|
372
|
+
return start + LEGACY_TRAILER_HEADER_SIZE + authLength === end
|
|
371
373
|
}
|
|
372
374
|
|
|
373
375
|
interface DeclaredTrailerLayout {
|
|
374
376
|
hasFecId: boolean
|
|
377
|
+
hasOverlayFecId: boolean
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Options for extractMFUPayload and explainMFURejection. */
|
|
381
|
+
export interface MFUExtractOptions {
|
|
382
|
+
/**
|
|
383
|
+
* When true, do NOT subtract the Source FEC Payload ID from the payload
|
|
384
|
+
* boundary, even if fecType >= 1. For FEC-recovered symbols whose FEC PID
|
|
385
|
+
* was already stripped before WASM decoding.
|
|
386
|
+
*/
|
|
387
|
+
skipFecId?: boolean
|
|
388
|
+
/**
|
|
389
|
+
* The catalog declares a keyframe overlay (`scope: "keyframe"` repair track)
|
|
390
|
+
* for this packet's source. A RAP packet then carries two Source FEC Payload
|
|
391
|
+
* IDs, base first and overlay last (BLO-32719 Clarification 7). This is a
|
|
392
|
+
* catalog fact, never inferred: the header has no overlay bit, and a payload
|
|
393
|
+
* four bytes shorter is byte-identical to a missing overlay. Mirrors
|
|
394
|
+
* mmt-core `split_payload_and_source_fec_trailers(overlay_declared)`.
|
|
395
|
+
*/
|
|
396
|
+
overlayDeclared?: boolean
|
|
375
397
|
}
|
|
376
398
|
|
|
377
399
|
function resolveDeclaredTrailers(
|
|
378
400
|
payload: Uint8Array,
|
|
379
401
|
declaredEnd: number,
|
|
380
402
|
fecType: number,
|
|
381
|
-
options?:
|
|
403
|
+
options?: MFUExtractOptions,
|
|
404
|
+
rapFlag = false,
|
|
382
405
|
): DeclaredTrailerLayout | null {
|
|
383
406
|
if (declaredEnd > payload.length) return null
|
|
384
407
|
|
|
385
408
|
if (options?.skipFecId) {
|
|
386
|
-
return
|
|
387
|
-
? { hasFecId: false }
|
|
409
|
+
return isEmptyOrExactLegacyTrailer(payload, declaredEnd, payload.length)
|
|
410
|
+
? { hasFecId: false, hasOverlayFecId: false }
|
|
388
411
|
: null
|
|
389
412
|
}
|
|
390
413
|
|
|
414
|
+
const overlay = options?.overlayDeclared === true && rapFlag
|
|
391
415
|
if (fecType === 1) {
|
|
392
|
-
const fecStart = payload.length -
|
|
393
|
-
if (fecStart < declaredEnd || !
|
|
416
|
+
const fecStart = payload.length - sourceFecTrailerSize(overlay)
|
|
417
|
+
if (fecStart < declaredEnd || !isEmptyOrExactLegacyTrailer(payload, declaredEnd, fecStart)) {
|
|
394
418
|
return null
|
|
395
419
|
}
|
|
396
|
-
return { hasFecId: true }
|
|
420
|
+
return { hasFecId: true, hasOverlayFecId: overlay }
|
|
397
421
|
}
|
|
398
|
-
|
|
422
|
+
// An overlay ID rides behind a base ID the header says is absent.
|
|
423
|
+
if (fecType !== 0 || overlay) return null
|
|
399
424
|
|
|
400
|
-
if (
|
|
401
|
-
return { hasFecId: false }
|
|
425
|
+
if (isEmptyOrExactLegacyTrailer(payload, declaredEnd, payload.length)) {
|
|
426
|
+
return { hasFecId: false, hasOverlayFecId: false }
|
|
402
427
|
}
|
|
403
428
|
|
|
404
429
|
return null
|
|
405
430
|
}
|
|
406
431
|
|
|
432
|
+
function sourceFecTrailerSize(overlay: boolean): number {
|
|
433
|
+
return overlay ? 2 * SOURCE_FEC_PAYLOAD_ID_SIZE : SOURCE_FEC_PAYLOAD_ID_SIZE
|
|
434
|
+
}
|
|
435
|
+
|
|
407
436
|
/**
|
|
408
437
|
* Extract NAL unit payload from MMTP packet
|
|
409
438
|
*/
|
|
@@ -429,6 +458,8 @@ export interface MFUExtraction {
|
|
|
429
458
|
itemId: number | null
|
|
430
459
|
/** Source FEC Payload ID if present (`FEC_type == 1`), or null */
|
|
431
460
|
fecPayloadId: { ssId: number } | null
|
|
461
|
+
/** Keyframe-overlay Source FEC Payload ID: RAP packets with `overlayDeclared` only. */
|
|
462
|
+
overlayFecPayloadId: { ssId: number } | null
|
|
432
463
|
}
|
|
433
464
|
|
|
434
465
|
/**
|
|
@@ -441,15 +472,17 @@ export interface MFUExtraction {
|
|
|
441
472
|
* - Source FEC Payload ID (4 bytes at end, only when `FEC_type == 1`)
|
|
442
473
|
*
|
|
443
474
|
* `payloadLength` is the ISO length-after-field value: six remaining MPU-header
|
|
444
|
-
* bytes, then the MFU DU header when present, then media.
|
|
445
|
-
*
|
|
475
|
+
* bytes, then the MFU DU header when present, then media. The legacy
|
|
476
|
+
* authenticator suffix and the Source FEC trailer are outside that declared span and must consume the exact suffix.
|
|
446
477
|
*
|
|
447
478
|
* @param mmtpPacket Raw MMTP packet (from multicast or MoQ)
|
|
448
479
|
* @param options.skipFecId When true, do NOT subtract the 4-byte Source FEC Payload ID
|
|
449
480
|
* from the payload boundary, even if fecType >= 1 in the MMTP header. Use this for
|
|
450
481
|
* FEC-recovered symbols where the FEC PID was already stripped before WASM decoding
|
|
451
482
|
* but the fecType bits in byte 0 still reflect the original packet (FEC Type=1 per
|
|
452
|
-
* draft-ramadan-moq-mmt-
|
|
483
|
+
* draft-ramadan-moq-mmt-01 §3.1).
|
|
484
|
+
* @param options.overlayDeclared The catalog declares a keyframe overlay for this
|
|
485
|
+
* source, so a RAP packet carries an 8-byte trailer (see MFUExtractOptions).
|
|
453
486
|
* @returns Extracted MFU data + metadata, or null if packet is too small
|
|
454
487
|
*/
|
|
455
488
|
interface MfuPlan {
|
|
@@ -470,7 +503,7 @@ interface MfuPlan {
|
|
|
470
503
|
*/
|
|
471
504
|
function planMfuExtraction(
|
|
472
505
|
mmtpPacket: Uint8Array,
|
|
473
|
-
options?:
|
|
506
|
+
options?: MFUExtractOptions,
|
|
474
507
|
parsedHeader?: MMTPHeader,
|
|
475
508
|
): { plan: MfuPlan } | { rejection: MFURejection } {
|
|
476
509
|
if (mmtpPacket.length < MMTP_HEADER_SIZE + MPU_HEADER_SIZE) {
|
|
@@ -535,6 +568,7 @@ function planMfuExtraction(
|
|
|
535
568
|
layout.dataEnd,
|
|
536
569
|
header.fecType,
|
|
537
570
|
options,
|
|
571
|
+
header.rapFlag,
|
|
538
572
|
)
|
|
539
573
|
if (!trailerLayout) {
|
|
540
574
|
// The most common cause is a producer whose Source FEC Payload ID width
|
|
@@ -542,13 +576,14 @@ function planMfuExtraction(
|
|
|
542
576
|
// leaving operators to infer it from a generic trailer error.
|
|
543
577
|
if (!options?.skipFecId && header.fecType === 1) {
|
|
544
578
|
const observed = payload.length - layout.dataEnd
|
|
545
|
-
|
|
579
|
+
const expected = sourceFecTrailerSize(options?.overlayDeclared === true && header.rapFlag)
|
|
580
|
+
if (observed !== expected) {
|
|
546
581
|
return {
|
|
547
582
|
rejection: {
|
|
548
583
|
gate: 'source-fec-payload-id-width-mismatch',
|
|
549
584
|
detail:
|
|
550
585
|
`producer wrote a ${observed}-byte Source FEC Payload ID, ` +
|
|
551
|
-
`ISO/IEC 23008-1 §C.5.2 requires ${
|
|
586
|
+
`ISO/IEC 23008-1 §C.5.2 requires ${expected} ` +
|
|
552
587
|
`(payload=${payload.length}, declaredEnd=${layout.dataEnd})`,
|
|
553
588
|
},
|
|
554
589
|
}
|
|
@@ -559,7 +594,7 @@ function planMfuExtraction(
|
|
|
559
594
|
gate: 'declared-trailers-unresolved',
|
|
560
595
|
detail:
|
|
561
596
|
`trailer bytes [${layout.dataEnd}, ${payload.length}) are neither empty ` +
|
|
562
|
-
`nor an exact
|
|
597
|
+
`nor an exact legacy authenticator trailer (fecType=${header.fecType})`,
|
|
563
598
|
},
|
|
564
599
|
}
|
|
565
600
|
}
|
|
@@ -579,7 +614,7 @@ function planMfuExtraction(
|
|
|
579
614
|
*/
|
|
580
615
|
export function explainMFURejection(
|
|
581
616
|
mmtpPacket: Uint8Array,
|
|
582
|
-
options?:
|
|
617
|
+
options?: MFUExtractOptions,
|
|
583
618
|
): MFURejection | null {
|
|
584
619
|
const result = planMfuExtraction(mmtpPacket, options)
|
|
585
620
|
return 'rejection' in result ? result.rejection : null
|
|
@@ -587,7 +622,7 @@ export function explainMFURejection(
|
|
|
587
622
|
|
|
588
623
|
export function extractMFUPayload(
|
|
589
624
|
mmtpPacket: Uint8Array,
|
|
590
|
-
options?:
|
|
625
|
+
options?: MFUExtractOptions,
|
|
591
626
|
): MFUExtraction | null {
|
|
592
627
|
return extractPlannedMfu(mmtpPacket, planMfuExtraction(mmtpPacket, options))
|
|
593
628
|
}
|
|
@@ -602,7 +637,7 @@ export function extractMFUPayload(
|
|
|
602
637
|
export function extractMFUPayloadWithHeader(
|
|
603
638
|
mmtpPacket: Uint8Array,
|
|
604
639
|
header: MMTPHeader,
|
|
605
|
-
options?:
|
|
640
|
+
options?: MFUExtractOptions,
|
|
606
641
|
): MFUExtraction | null {
|
|
607
642
|
return extractPlannedMfu(mmtpPacket, planMfuExtraction(mmtpPacket, options, header))
|
|
608
643
|
}
|
|
@@ -629,17 +664,17 @@ function extractPlannedMfu(
|
|
|
629
664
|
|
|
630
665
|
const data = payload.subarray(mfuOffset, dataEnd)
|
|
631
666
|
|
|
632
|
-
const
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
667
|
+
const trailerStart =
|
|
668
|
+
mmtpPacket.length - sourceFecTrailerSize(trailerLayout.hasOverlayFecId)
|
|
669
|
+
const ssIdAt = (offset: number) => ({
|
|
670
|
+
ssId: new DataView(mmtpPacket.buffer, mmtpPacket.byteOffset + offset, 4).getUint32(0, false),
|
|
671
|
+
})
|
|
672
|
+
const fecPayloadId = trailerLayout.hasFecId ? ssIdAt(trailerStart) : null
|
|
673
|
+
const overlayFecPayloadId = trailerLayout.hasOverlayFecId
|
|
674
|
+
? ssIdAt(trailerStart + SOURCE_FEC_PAYLOAD_ID_SIZE)
|
|
640
675
|
: null
|
|
641
676
|
|
|
642
|
-
return { header, mpu, fragmentType, data, mfuDuHeader, itemId, fecPayloadId }
|
|
677
|
+
return { header, mpu, fragmentType, data, mfuDuHeader, itemId, fecPayloadId, overlayFecPayloadId }
|
|
643
678
|
}
|
|
644
679
|
|
|
645
680
|
/**
|
|
@@ -732,22 +767,6 @@ export function sourceFecPayloadIdOf(
|
|
|
732
767
|
}
|
|
733
768
|
}
|
|
734
769
|
|
|
735
|
-
/**
|
|
736
|
-
* MMTP header-extension ext_type assigned to the detached ALTA authenticator
|
|
737
|
-
* trailer (Path 2 / 08-07). Carried in an `[ext_type][ext_length][auth]`
|
|
738
|
-
* block appended after the MPU's declared payload bytes, before the 4-byte
|
|
739
|
-
* Source FEC Payload ID. MMTP X-bit stays 0 — non-ALTA MMTP receivers treat
|
|
740
|
-
* the trailer as bytes past `payload_length` and skip it (per ISO 23008-1
|
|
741
|
-
* §9.2.2 "may be discarded without impacting correct processing").
|
|
742
|
-
*/
|
|
743
|
-
// Re-export the ALTA trailer helper from the zero-dep leaf package. The leaf
|
|
744
|
-
// owns the implementation so container and FEC consumers share one parser
|
|
745
|
-
// without creating a package cycle.
|
|
746
|
-
export const MMTP_EXT_TYPE_ALTA_AUTH = _LEAF_MMTP_EXT_TYPE_ALTA_AUTH
|
|
747
|
-
export const MMTP_EXT_TRAILER_HEADER_SIZE = _LEAF_MMTP_EXT_TRAILER_HEADER_SIZE
|
|
748
|
-
export type AltaTrailer = _LeafAltaTrailer
|
|
749
|
-
export const extractAltaTrailer = _leafExtractAltaTrailer
|
|
750
|
-
|
|
751
770
|
/** Size of Repair FEC Payload ID per ISO/IEC 23008-1 Section C.5.3 */
|
|
752
771
|
export const REPAIR_FEC_PID_SIZE = 13
|
|
753
772
|
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The draft-ramadan-moq-multicast-01 endpoint and `auth` rules, written once
|
|
3
|
+
* for both sides of the catalog: `validateCmsfMmtpCatalog` in
|
|
4
|
+
* `@blockcast/mmt-container` throws the first issue, and the receivers in
|
|
5
|
+
* `@blockcast/mmt-transport` ignore every endpoint an issue names (all of
|
|
6
|
+
* them when the issue is catalog-level). It lives here because mmt-base is
|
|
7
|
+
* the only package both depend on.
|
|
8
|
+
*
|
|
9
|
+
* Draft: Blockcast/moqcast-draft@fd962fd73d21ed281b60219c11ea61cc9e4e91c4
|
|
10
|
+
* (PR #2 head; normative text equal to 3162ef93), sections 4.1 and 7.2.6.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type MulticastJoinMode = "ssm" | "asm";
|
|
14
|
+
|
|
15
|
+
/** An endpoint's join mode and `S` (4.1): `sourceAddress` on SSM,
|
|
16
|
+
* `auth.senderAddress` on protected ASM, absent on unprotected ASM. */
|
|
17
|
+
export interface MulticastEndpointSource {
|
|
18
|
+
mode: MulticastJoinMode;
|
|
19
|
+
source?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type MulticastCatalogIssueCode =
|
|
23
|
+
| "multicast_auth"
|
|
24
|
+
| "multicast_endpoint"
|
|
25
|
+
| "multicast_endpoint_auth"
|
|
26
|
+
| "multicast_track";
|
|
27
|
+
|
|
28
|
+
export interface MulticastCatalogIssue {
|
|
29
|
+
code: MulticastCatalogIssueCode;
|
|
30
|
+
/** JSONPath of the offending field. */
|
|
31
|
+
path: string;
|
|
32
|
+
message: string;
|
|
33
|
+
/** Endpoint the issue makes a receiver ignore; absent for a catalog-level
|
|
34
|
+
* issue, which leaves the receiver no endpoint at all (7.2.6). */
|
|
35
|
+
endpointIndex?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Canonical unpadded base64url of exactly 32 octets. */
|
|
39
|
+
export const ED25519_PUBLIC_KEY_BASE64URL = /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/;
|
|
40
|
+
|
|
41
|
+
const UINT32_MAX = 0xffff_ffff;
|
|
42
|
+
const AUTH_MEMBERS = new Set(["scheme", "version", "sig", "keys", "minEpoch"]);
|
|
43
|
+
const KEY_MEMBERS = new Set(["kid", "publicKey", "firstEpoch"]);
|
|
44
|
+
const ENDPOINT_AUTH_MEMBERS = new Set([
|
|
45
|
+
"manifestPort",
|
|
46
|
+
"streamId",
|
|
47
|
+
"senderAddress",
|
|
48
|
+
"maxAuthDelayMs",
|
|
49
|
+
"manifestTimerMs",
|
|
50
|
+
"manifestTrack",
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
type JsonObject = Record<string, unknown>;
|
|
54
|
+
|
|
55
|
+
function isObject(value: unknown): value is JsonObject {
|
|
56
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
60
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isIntegerIn(value: unknown, min: number, max: number): value is number {
|
|
64
|
+
return Number.isSafeInteger(value) && (value as number) >= min && (value as number) <= max;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Join mode and `S` of one endpoint, or `undefined` when its fields disagree
|
|
69
|
+
* and a receiver must ignore it (4.1). `sourceAddress` alone selects the mode;
|
|
70
|
+
* `protocol` may only restate it, so "amt" and "atsc3" (draft -00) are
|
|
71
|
+
* inconsistent here: a bearer is a `networkSource`, never a protocol.
|
|
72
|
+
*/
|
|
73
|
+
export function multicastEndpointSource(endpoint: unknown): MulticastEndpointSource | undefined {
|
|
74
|
+
if (!isObject(endpoint)) return undefined;
|
|
75
|
+
const sourceAddress = endpoint["sourceAddress"];
|
|
76
|
+
if (sourceAddress !== undefined && !isNonEmptyString(sourceAddress)) return undefined;
|
|
77
|
+
const mode: MulticastJoinMode = sourceAddress === undefined ? "asm" : "ssm";
|
|
78
|
+
const protocol = endpoint["protocol"];
|
|
79
|
+
if (protocol !== undefined && protocol !== mode) return undefined;
|
|
80
|
+
const auth = endpoint["auth"];
|
|
81
|
+
const senderAddress = isObject(auth) ? auth["senderAddress"] : undefined;
|
|
82
|
+
if (mode === "ssm") return senderAddress === undefined ? { mode, source: sourceAddress as string } : undefined;
|
|
83
|
+
if (auth === undefined) return { mode };
|
|
84
|
+
return isNonEmptyString(senderAddress) ? { mode, source: senderAddress } : undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function authIssues(auth: unknown): MulticastCatalogIssue[] {
|
|
88
|
+
const base = "$.multicast.auth";
|
|
89
|
+
const issue = (path: string, message: string): MulticastCatalogIssue[] => [
|
|
90
|
+
{ code: "multicast_auth", path: `${base}${path}`, message },
|
|
91
|
+
];
|
|
92
|
+
if (!isObject(auth)) return issue("", "auth must be an object");
|
|
93
|
+
const unknown = Object.keys(auth).filter(key => !AUTH_MEMBERS.has(key));
|
|
94
|
+
if (unknown.length > 0) {
|
|
95
|
+
return issue("", `auth has unknown member(s) ${unknown.join(", ")}; draft -01 7.2.6 defines scheme, version, sig, keys and minEpoch`);
|
|
96
|
+
}
|
|
97
|
+
if (auth["scheme"] !== "bc-provenance") return issue(".scheme", 'scheme must be "bc-provenance"');
|
|
98
|
+
if (auth["version"] !== 2) return issue(".version", "version must be 2 (bc-provenance version 2, draft -01 7.2)");
|
|
99
|
+
if (auth["sig"] !== "ed25519") return issue(".sig", 'sig must be "ed25519"');
|
|
100
|
+
const keys = auth["keys"];
|
|
101
|
+
if (!Array.isArray(keys) || keys.length < 1 || keys.length > 4) {
|
|
102
|
+
return issue(".keys", "keys must be an array of one to four {kid, publicKey, firstEpoch} objects");
|
|
103
|
+
}
|
|
104
|
+
const kids = new Set<number>();
|
|
105
|
+
const firstEpochs = new Set<number>();
|
|
106
|
+
for (let index = 0; index < keys.length; index++) {
|
|
107
|
+
const key = keys[index];
|
|
108
|
+
const at = `.keys[${index}]`;
|
|
109
|
+
if (!isObject(key) || Object.keys(key).some(member => !KEY_MEMBERS.has(member))) {
|
|
110
|
+
return issue(at, "a key is exactly {kid, publicKey, firstEpoch}");
|
|
111
|
+
}
|
|
112
|
+
if (!isIntegerIn(key["kid"], 0, UINT32_MAX)) return issue(`${at}.kid`, "kid must be an unsigned 32-bit integer");
|
|
113
|
+
if (typeof key["publicKey"] !== "string" || !ED25519_PUBLIC_KEY_BASE64URL.test(key["publicKey"])) {
|
|
114
|
+
return issue(`${at}.publicKey`, "publicKey must be a 32-octet Ed25519 key in canonical unpadded base64url");
|
|
115
|
+
}
|
|
116
|
+
if (!isIntegerIn(key["firstEpoch"], 0, UINT32_MAX)) {
|
|
117
|
+
return issue(`${at}.firstEpoch`, "firstEpoch must be an unsigned 32-bit integer");
|
|
118
|
+
}
|
|
119
|
+
// A repeated kid or firstEpoch leaves no key set (7.2.6): fail the whole
|
|
120
|
+
// block, never fall back to unauthenticated reception.
|
|
121
|
+
if (kids.has(key["kid"])) return issue(`${at}.kid`, `kid ${key["kid"]} is repeated; the catalog holds no key set`);
|
|
122
|
+
if (firstEpochs.has(key["firstEpoch"])) {
|
|
123
|
+
return issue(`${at}.firstEpoch`, `firstEpoch ${key["firstEpoch"]} is repeated; the catalog holds no key set`);
|
|
124
|
+
}
|
|
125
|
+
kids.add(key["kid"]);
|
|
126
|
+
firstEpochs.add(key["firstEpoch"]);
|
|
127
|
+
}
|
|
128
|
+
if (!isIntegerIn(auth["minEpoch"], 0, UINT32_MAX)) {
|
|
129
|
+
return issue(".minEpoch", "minEpoch must be an unsigned 32-bit integer");
|
|
130
|
+
}
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function endpointAuthIssue(
|
|
135
|
+
endpoint: JsonObject,
|
|
136
|
+
mode: MulticastJoinMode,
|
|
137
|
+
at: string,
|
|
138
|
+
): { path: string; message: string } | undefined {
|
|
139
|
+
const auth = endpoint["auth"];
|
|
140
|
+
if (!isObject(auth)) return { path: `${at}.auth`, message: "auth must be an object" };
|
|
141
|
+
const unknown = Object.keys(auth).filter(key => !ENDPOINT_AUTH_MEMBERS.has(key));
|
|
142
|
+
if (unknown.length > 0) return { path: `${at}.auth`, message: `auth has unknown member(s) ${unknown.join(", ")}` };
|
|
143
|
+
if (!isIntegerIn(auth["manifestPort"], 1, 65535)) {
|
|
144
|
+
return { path: `${at}.auth.manifestPort`, message: "manifestPort must be an integer from 1 to 65535" };
|
|
145
|
+
}
|
|
146
|
+
if (!isIntegerIn(auth["streamId"], 0, UINT32_MAX)) {
|
|
147
|
+
return { path: `${at}.auth.streamId`, message: "streamId must be an unsigned 32-bit integer" };
|
|
148
|
+
}
|
|
149
|
+
if (mode === "ssm" && auth["senderAddress"] !== undefined) {
|
|
150
|
+
return { path: `${at}.auth.senderAddress`, message: "senderAddress must not appear on an SSM endpoint; S is sourceAddress" };
|
|
151
|
+
}
|
|
152
|
+
if (mode === "asm" && !isNonEmptyString(auth["senderAddress"])) {
|
|
153
|
+
return { path: `${at}.auth.senderAddress`, message: "a protected ASM endpoint requires senderAddress" };
|
|
154
|
+
}
|
|
155
|
+
if (auth["maxAuthDelayMs"] !== undefined && !isIntegerIn(auth["maxAuthDelayMs"], 50, 1000)) {
|
|
156
|
+
return { path: `${at}.auth.maxAuthDelayMs`, message: "maxAuthDelayMs must be an integer from 50 to 1000" };
|
|
157
|
+
}
|
|
158
|
+
if (auth["manifestTimerMs"] !== undefined && !isIntegerIn(auth["manifestTimerMs"], 1, Number.MAX_SAFE_INTEGER)) {
|
|
159
|
+
return { path: `${at}.auth.manifestTimerMs`, message: "manifestTimerMs must be an integer of at least 1" };
|
|
160
|
+
}
|
|
161
|
+
if (auth["manifestTrack"] !== undefined && !isNonEmptyString(auth["manifestTrack"])) {
|
|
162
|
+
return { path: `${at}.auth.manifestTrack`, message: "manifestTrack must be a non-empty string" };
|
|
163
|
+
}
|
|
164
|
+
if (!isIntegerIn(endpoint["bandwidth"], 1, Number.MAX_SAFE_INTEGER)) {
|
|
165
|
+
return { path: `${at}.bandwidth`, message: "a protected endpoint requires bandwidth, which bounds the receiver's hold queues" };
|
|
166
|
+
}
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Where a catalog object carries the namespace of the catalog track it was
|
|
172
|
+
* read from. A symbol, so it is never wire data and survives object spread;
|
|
173
|
+
* `Symbol.for`, so two bundled copies of this module agree on it.
|
|
174
|
+
*/
|
|
175
|
+
export const CATALOG_NAMESPACE: unique symbol = Symbol.for("@blockcast/mmt-base/catalogNamespace");
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* `catalog` tagged with the namespace of its catalog track. Every receiver
|
|
179
|
+
* rule that takes the catalog (`multicastCatalogIssues`,
|
|
180
|
+
* `admittedMulticastEndpoints` and the transport selectors built on them)
|
|
181
|
+
* then judges the foreign-namespace rule against it, independent of track
|
|
182
|
+
* order. Tag once, where the catalog track is read.
|
|
183
|
+
*/
|
|
184
|
+
export function withCatalogNamespace<T extends object>(catalog: T, namespace: string): T {
|
|
185
|
+
return { ...catalog, [CATALOG_NAMESPACE]: namespace };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Every draft -01 4.1 / 7.2.6 violation in `catalog.multicast`, in document
|
|
190
|
+
* order. Packet-id, group-address and track-existence rules are not here; the
|
|
191
|
+
* container validator and the receivers already check those.
|
|
192
|
+
*
|
|
193
|
+
* `catalogNamespace` is the namespace of the catalog track, from `options` or else the
|
|
194
|
+
* catalog's `withCatalogNamespace` tag. Without it the
|
|
195
|
+
* foreign-namespace rule can only compare listed tracks with each other: the
|
|
196
|
+
* first listed track's namespace becomes the reference, so which side of a
|
|
197
|
+
* disagreement is dropped depends on order. It still fails closed; pass
|
|
198
|
+
* `catalogNamespace` wherever the catalog track is known.
|
|
199
|
+
*/
|
|
200
|
+
export function multicastCatalogIssues(
|
|
201
|
+
catalog: unknown,
|
|
202
|
+
options: { catalogNamespace?: string } = {},
|
|
203
|
+
): MulticastCatalogIssue[] {
|
|
204
|
+
if (!isObject(catalog)) return [];
|
|
205
|
+
const issues: MulticastCatalogIssue[] = [];
|
|
206
|
+
if ("multicast.auth" in catalog) {
|
|
207
|
+
// Fail closed: a draft -00 publisher meant the flow to be protected, and
|
|
208
|
+
// the key it names verifies nothing in bc-provenance version 2.
|
|
209
|
+
issues.push({
|
|
210
|
+
code: "multicast_auth",
|
|
211
|
+
path: '$["multicast.auth"]',
|
|
212
|
+
message: 'the root "multicast.auth" key is retired; declare auth as multicast.auth (draft -01 7.2.6)',
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
const multicast = catalog["multicast"];
|
|
216
|
+
if (!isObject(multicast)) return issues;
|
|
217
|
+
const protectedCatalog = multicast["auth"] !== undefined;
|
|
218
|
+
if (protectedCatalog) issues.push(...authIssues(multicast["auth"]));
|
|
219
|
+
const endpoints = multicast["endpoints"];
|
|
220
|
+
if (!Array.isArray(endpoints)) return issues;
|
|
221
|
+
|
|
222
|
+
const tracks = Array.isArray(catalog["tracks"]) ? catalog["tracks"] : [];
|
|
223
|
+
const namespaceOf = new Map<string, unknown>();
|
|
224
|
+
for (const track of tracks) {
|
|
225
|
+
if (isObject(track) && typeof track["name"] === "string") namespaceOf.set(track["name"], track["namespace"]);
|
|
226
|
+
}
|
|
227
|
+
const tagged = (catalog as { [CATALOG_NAMESPACE]?: unknown })[CATALOG_NAMESPACE];
|
|
228
|
+
let referenceNamespace = options.catalogNamespace ?? (typeof tagged === "string" ? tagged : undefined);
|
|
229
|
+
|
|
230
|
+
const sources: Array<MulticastEndpointSource | undefined> = [];
|
|
231
|
+
for (let index = 0; index < endpoints.length; index++) {
|
|
232
|
+
const endpoint = endpoints[index];
|
|
233
|
+
const at = `$.multicast.endpoints[${index}]`;
|
|
234
|
+
const fail = (code: MulticastCatalogIssueCode, path: string, message: string) => {
|
|
235
|
+
issues.push({ code, path, message, endpointIndex: index });
|
|
236
|
+
};
|
|
237
|
+
sources.push(undefined);
|
|
238
|
+
if (!isObject(endpoint)) {
|
|
239
|
+
fail("multicast_endpoint", at, "endpoint must be an object");
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const protocol = endpoint["protocol"];
|
|
243
|
+
if (protocol !== undefined && protocol !== "ssm" && protocol !== "asm") {
|
|
244
|
+
fail("multicast_endpoint", `${at}.protocol`, `protocol must be "ssm" or "asm"; a bearer such as ${JSON.stringify(protocol)} is a networkSource entry (draft -01 4.2)`);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
const hasAuth = endpoint["auth"] !== undefined;
|
|
248
|
+
if (protectedCatalog !== hasAuth) {
|
|
249
|
+
fail(
|
|
250
|
+
"multicast_endpoint_auth",
|
|
251
|
+
`${at}.auth`,
|
|
252
|
+
protectedCatalog
|
|
253
|
+
? "every endpoint requires auth when multicast carries auth; a receiver ignores this endpoint"
|
|
254
|
+
: "auth names no key without multicast.auth; a receiver ignores this endpoint",
|
|
255
|
+
);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const sourceAddress = endpoint["sourceAddress"];
|
|
259
|
+
if (sourceAddress !== undefined && !isNonEmptyString(sourceAddress)) {
|
|
260
|
+
fail("multicast_endpoint", `${at}.sourceAddress`, "sourceAddress must be a non-empty string");
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
const mode: MulticastJoinMode = sourceAddress === undefined ? "asm" : "ssm";
|
|
264
|
+
if (protocol !== undefined && protocol !== mode) {
|
|
265
|
+
fail("multicast_endpoint", `${at}.protocol`, `protocol ${JSON.stringify(protocol)} disagrees with sourceAddress, which selects ${mode}`);
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
// The (S, G, port) rule below compares ports with ===, so only an
|
|
269
|
+
// integer may reach it: "5001" and 5001 would otherwise both pass.
|
|
270
|
+
if (!isIntegerIn(endpoint["port"], 1, 65535)) {
|
|
271
|
+
fail("multicast_endpoint", `${at}.port`, "port must be an integer from 1 to 65535");
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (hasAuth) {
|
|
275
|
+
const authIssue = endpointAuthIssue(endpoint, mode, at);
|
|
276
|
+
if (authIssue) {
|
|
277
|
+
fail("multicast_endpoint_auth", authIssue.path, authIssue.message);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
sources[index] = multicastEndpointSource(endpoint);
|
|
282
|
+
|
|
283
|
+
const refs = Array.isArray(endpoint["tracks"]) ? endpoint["tracks"] : [];
|
|
284
|
+
for (let refIndex = 0; refIndex < refs.length; refIndex++) {
|
|
285
|
+
const ref = refs[refIndex];
|
|
286
|
+
const name = isObject(ref) ? ref["name"] : ref;
|
|
287
|
+
if (typeof name !== "string") continue;
|
|
288
|
+
const namespace = namespaceOf.get(name);
|
|
289
|
+
if (typeof namespace !== "string") continue;
|
|
290
|
+
referenceNamespace ??= namespace;
|
|
291
|
+
if (namespace !== referenceNamespace) {
|
|
292
|
+
fail("multicast_track", `${at}.tracks[${refIndex}].name`, `track ${JSON.stringify(name)} declares namespace ${JSON.stringify(namespace)}, not the catalog track's ${JSON.stringify(referenceNamespace)}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// One endpoint object per (S, G, port), and a manifestPort that is no port
|
|
298
|
+
// and no other manifestPort of its (S, G) (4.1, 7.2.6). The rule binds the
|
|
299
|
+
// endpoint whose manifestPort clashes; two equal tuples or two equal
|
|
300
|
+
// manifestPorts break it on both sides, so both are named.
|
|
301
|
+
const tupleOf = (index: number) => {
|
|
302
|
+
const endpoint = endpoints[index] as JsonObject;
|
|
303
|
+
const auth = endpoint["auth"];
|
|
304
|
+
return {
|
|
305
|
+
sg: JSON.stringify([sources[index]!.source ?? null, endpoint["groupAddress"]]),
|
|
306
|
+
port: endpoint["port"],
|
|
307
|
+
manifestPort: isObject(auth) ? auth["manifestPort"] : undefined,
|
|
308
|
+
};
|
|
309
|
+
};
|
|
310
|
+
for (let index = 0; index < endpoints.length; index++) {
|
|
311
|
+
if (!sources[index]) continue;
|
|
312
|
+
const own = tupleOf(index);
|
|
313
|
+
const at = `$.multicast.endpoints[${index}]`;
|
|
314
|
+
if (own.manifestPort !== undefined && own.manifestPort === own.port) {
|
|
315
|
+
issues.push({ code: "multicast_endpoint_auth", path: `${at}.auth.manifestPort`, message: "manifestPort must differ from the endpoint's own port", endpointIndex: index });
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
for (let other = 0; other < endpoints.length; other++) {
|
|
319
|
+
if (other === index || !sources[other]) continue;
|
|
320
|
+
const peer = tupleOf(other);
|
|
321
|
+
if (peer.sg !== own.sg) continue;
|
|
322
|
+
if (peer.port === own.port) {
|
|
323
|
+
issues.push({ code: "multicast_endpoint", path: `${at}.port`, message: `(S, G, port) is shared with endpoint ${other}; one endpoint object per multicast tuple`, endpointIndex: index });
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
if (own.manifestPort !== undefined && (own.manifestPort === peer.port || own.manifestPort === peer.manifestPort)) {
|
|
327
|
+
issues.push({ code: "multicast_endpoint_auth", path: `${at}.auth.manifestPort`, message: `manifestPort is a port or manifestPort of endpoint ${other} on the same (S, G)`, endpointIndex: index });
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return issues;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Indices of the endpoints a receiver may use: none when a catalog-level
|
|
337
|
+
* issue leaves it no key set (7.2.6), otherwise every endpoint no issue
|
|
338
|
+
* names. Other receiver checks (group address, packet ids) still apply.
|
|
339
|
+
*/
|
|
340
|
+
export function admittedMulticastEndpoints(
|
|
341
|
+
catalog: unknown,
|
|
342
|
+
options: { catalogNamespace?: string } = {},
|
|
343
|
+
): ReadonlySet<number> {
|
|
344
|
+
const multicast = isObject(catalog) ? catalog["multicast"] : undefined;
|
|
345
|
+
const endpoints = isObject(multicast) && Array.isArray(multicast["endpoints"]) ? multicast["endpoints"] : [];
|
|
346
|
+
const issues = multicastCatalogIssues(catalog, options);
|
|
347
|
+
if (issues.some(issue => issue.endpointIndex === undefined)) return new Set();
|
|
348
|
+
const ignored = new Set(issues.map(issue => issue.endpointIndex));
|
|
349
|
+
return new Set(endpoints.map((_, index) => index).filter(index => !ignored.has(index)));
|
|
350
|
+
}
|