@tribe-nest/media-protocol 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/build/_fixtures/joinTokenVectors.json +103 -0
- package/build/claims.d.ts +327 -0
- package/build/claims.d.ts.map +1 -0
- package/build/claims.js +91 -0
- package/build/claims.js.map +1 -0
- package/build/codecs.d.ts +102 -0
- package/build/codecs.d.ts.map +1 -0
- package/build/codecs.js +138 -0
- package/build/codecs.js.map +1 -0
- package/build/errorSchema.d.ts +8 -0
- package/build/errorSchema.d.ts.map +1 -0
- package/build/errorSchema.js +12 -0
- package/build/errorSchema.js.map +1 -0
- package/build/errors.d.ts +18 -0
- package/build/errors.d.ts.map +1 -0
- package/build/errors.js +47 -0
- package/build/errors.js.map +1 -0
- package/build/frames.d.ts +1291 -0
- package/build/frames.d.ts.map +1 -0
- package/build/frames.js +222 -0
- package/build/frames.js.map +1 -0
- package/build/grants.d.ts +307 -0
- package/build/grants.d.ts.map +1 -0
- package/build/grants.js +228 -0
- package/build/grants.js.map +1 -0
- package/build/index.d.ts +24 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +40 -0
- package/build/index.js.map +1 -0
- package/build/mp-build/_fixtures/joinTokenVectors.json +103 -0
- package/build/mp-build/claims.d.ts +299 -0
- package/build/mp-build/claims.d.ts.map +1 -0
- package/build/mp-build/claims.js +60 -0
- package/build/mp-build/claims.js.map +1 -0
- package/build/mp-build/errorSchema.d.ts +8 -0
- package/build/mp-build/errorSchema.d.ts.map +1 -0
- package/build/mp-build/errorSchema.js +12 -0
- package/build/mp-build/errorSchema.js.map +1 -0
- package/build/mp-build/errors.d.ts +18 -0
- package/build/mp-build/errors.d.ts.map +1 -0
- package/build/mp-build/errors.js +47 -0
- package/build/mp-build/errors.js.map +1 -0
- package/build/mp-build/frames.d.ts +786 -0
- package/build/mp-build/frames.d.ts.map +1 -0
- package/build/mp-build/frames.js +153 -0
- package/build/mp-build/frames.js.map +1 -0
- package/build/mp-build/grants.d.ts +245 -0
- package/build/mp-build/grants.d.ts.map +1 -0
- package/build/mp-build/grants.js +135 -0
- package/build/mp-build/grants.js.map +1 -0
- package/build/mp-build/index.d.ts +23 -0
- package/build/mp-build/index.d.ts.map +1 -0
- package/build/mp-build/index.js +39 -0
- package/build/mp-build/index.js.map +1 -0
- package/build/mp-build/token.d.ts +66 -0
- package/build/mp-build/token.d.ts.map +1 -0
- package/build/mp-build/token.js +208 -0
- package/build/mp-build/token.js.map +1 -0
- package/build/mp-build/version.d.ts +9 -0
- package/build/mp-build/version.d.ts.map +1 -0
- package/build/mp-build/version.js +12 -0
- package/build/mp-build/version.js.map +1 -0
- package/build/token.d.ts +66 -0
- package/build/token.d.ts.map +1 -0
- package/build/token.js +208 -0
- package/build/token.js.map +1 -0
- package/build/version.d.ts +9 -0
- package/build/version.d.ts.map +1 -0
- package/build/version.js +12 -0
- package/build/version.js.map +1 -0
- package/package.json +53 -0
- package/src/_fixtures/joinTokenVectors.json +103 -0
- package/src/_tests/codecs.spec.ts +66 -0
- package/src/_tests/publishGrants.spec.ts +109 -0
- package/src/_tests/token.spec.ts +272 -0
- package/src/claims.ts +116 -0
- package/src/codecs.ts +165 -0
- package/src/errorSchema.ts +9 -0
- package/src/errors.ts +44 -0
- package/src/frames.ts +234 -0
- package/src/grants.ts +248 -0
- package/src/index.ts +23 -0
- package/src/token.ts +237 -0
- package/src/version.ts +8 -0
package/src/grants.ts
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What a participant is allowed to do, and - the part that matters most - who
|
|
5
|
+
* they may receive.
|
|
6
|
+
*
|
|
7
|
+
* ## Why the schema is `.strict()`
|
|
8
|
+
*
|
|
9
|
+
* This is the single line that stops the information barrier failing open.
|
|
10
|
+
*
|
|
11
|
+
* Ten phase plans were written in parallel and two of them disagreed on a name:
|
|
12
|
+
* one minted `grants.subscribeOnly`, the other read `grants.subscribe`. The
|
|
13
|
+
* reader would have found nothing, applied its documented default ("an absent
|
|
14
|
+
* rule means everyone"), and two Mandanten in one room would have received each
|
|
15
|
+
* other with nothing anywhere erroring. That is a §203 incident whose start
|
|
16
|
+
* date is unrecoverable from logs.
|
|
17
|
+
*
|
|
18
|
+
* `.strict()` turns that class of mistake from silent permissiveness into a
|
|
19
|
+
* loud verification failure: a token carrying an unknown grant key is rejected
|
|
20
|
+
* with `bad_claims` rather than partially understood. Any future rename fails
|
|
21
|
+
* the same way, at mint time, in a test.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Who a participant may receive, or be received by.
|
|
26
|
+
*
|
|
27
|
+
* A boolean cannot express an information barrier. "A and B are in one room and
|
|
28
|
+
* must not receive each other, while C may receive both" is the actual
|
|
29
|
+
* requirement for a Kanzlei, and it is a per-identity rule rather than a
|
|
30
|
+
* per-room one.
|
|
31
|
+
*/
|
|
32
|
+
export const subscribeRuleSchema = z.discriminatedUnion("mode", [
|
|
33
|
+
z.object({ mode: z.literal("all") }).strict(),
|
|
34
|
+
z.object({ mode: z.literal("none") }).strict(),
|
|
35
|
+
z.object({ mode: z.literal("allow"), identities: z.array(z.string()).max(500) }).strict(),
|
|
36
|
+
z.object({ mode: z.literal("deny"), identities: z.array(z.string()).max(500) }).strict(),
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
export type SubscribeRule = z.infer<typeof subscribeRuleSchema>;
|
|
40
|
+
|
|
41
|
+
export const participantKindSchema = z.enum(["human", "agent", "egress", "sip", "ingress"]);
|
|
42
|
+
export type ParticipantKind = z.infer<typeof participantKindSchema>;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Transport authority is NOT media authority, which is why this is its own
|
|
46
|
+
* claim rather than something inferred from `kind`.
|
|
47
|
+
*
|
|
48
|
+
* A browser holding a perfectly valid join token must not be able to ask for a
|
|
49
|
+
* `PlainTransport` pointed at an address of its choosing. That is an RTP
|
|
50
|
+
* reflector and an unauthenticated publish path into whatever room the token
|
|
51
|
+
* names. Three phases independently needed to refuse it and none could, because
|
|
52
|
+
* nothing carried the fact.
|
|
53
|
+
*/
|
|
54
|
+
export const transportKindSchema = z.enum(["webrtc", "plain", "pipe"]);
|
|
55
|
+
export type TransportKind = z.infer<typeof transportKindSchema>;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The two vocabularies, and the ONE place they are reconciled.
|
|
59
|
+
*
|
|
60
|
+
* A grant is written in kinds - "audio", "video", "screen" - because that is
|
|
61
|
+
* what a person minting a token thinks in: may this seat talk, may it be seen,
|
|
62
|
+
* may it present. A publisher labels its track with a SOURCE, because that is
|
|
63
|
+
* what it knows and what the other side renders: "microphone", "camera",
|
|
64
|
+
* "screen", "rtmp". The two lists overlap on one word and disagree on the rest.
|
|
65
|
+
*
|
|
66
|
+
* That disagreement shipped as a defect. The node compared the declared source
|
|
67
|
+
* against `publishKinds` directly, so a booking token minted with the full
|
|
68
|
+
* `["audio", "video", "screen"]` refused every microphone and every camera the
|
|
69
|
+
* browser SDK offered it - "microphone" is not in the list - and the only thing
|
|
70
|
+
* a coaching call could publish was a screen share. It refused the RTMP ingress
|
|
71
|
+
* for the same reason, whose source is "rtmp".
|
|
72
|
+
*
|
|
73
|
+
* So the translation lives here, next to the schema that creates the need for
|
|
74
|
+
* it, and both the node and the UI read it. An unrecognised source falls back to
|
|
75
|
+
* the TRACK's kind, which is a fact about the media rather than a claim: that is
|
|
76
|
+
* what lets a new source ("rtmp") work without a protocol change while a token
|
|
77
|
+
* granting audio only still cannot send video by inventing a label for it. The
|
|
78
|
+
* one thing a client can never do is send a screen share on an audio-or-video
|
|
79
|
+
* grant, because "screen" is only ever reachable by declaring it.
|
|
80
|
+
*
|
|
81
|
+
* ## A declaration is only honoured when the track could carry it
|
|
82
|
+
*
|
|
83
|
+
* The label is the client's claim; the track's kind is a fact mediasoup already
|
|
84
|
+
* knows. Translating the claim UNCONDITIONALLY made the claim outrank the fact,
|
|
85
|
+
* which is the bypass restated: `{ source: "microphone", kind: "video" }`
|
|
86
|
+
* resolved to "audio", so a seat minted `publishKinds: ["audio"]` published a
|
|
87
|
+
* camera and the node created a video producer with the real kind. The two
|
|
88
|
+
* audio-only grants in the product are a SIP gateway and an assistant runtime,
|
|
89
|
+
* and `publishKinds` is precisely the claim that has to survive the holder being
|
|
90
|
+
* compromised.
|
|
91
|
+
*
|
|
92
|
+
* So a source is translated only when it can actually carry the track in hand
|
|
93
|
+
* (a microphone carries audio, a camera carries video, a screen share carries
|
|
94
|
+
* either, because a shared window may bring system audio with it). A declaration
|
|
95
|
+
* the track contradicts is not a translation, it is a relabelling, and it is
|
|
96
|
+
* discarded in favour of the fact.
|
|
97
|
+
*/
|
|
98
|
+
export const publishKindSchema = z.enum(["audio", "video", "screen"]);
|
|
99
|
+
export type PublishKind = z.infer<typeof publishKindSchema>;
|
|
100
|
+
|
|
101
|
+
/** The sources the browser SDK publishes with. Others are legal on the wire. */
|
|
102
|
+
export const publishSourceSchema = z.enum(["microphone", "camera", "screen"]);
|
|
103
|
+
export type PublishSource = z.infer<typeof publishSourceSchema>;
|
|
104
|
+
|
|
105
|
+
const SOURCE_KINDS: Record<PublishSource, PublishKind> = {
|
|
106
|
+
microphone: "audio",
|
|
107
|
+
camera: "video",
|
|
108
|
+
screen: "screen",
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* What each source is physically able to carry.
|
|
113
|
+
*
|
|
114
|
+
* A screen share holds both, because sharing a window may bring its system
|
|
115
|
+
* audio; a microphone and a camera hold exactly one each. Anything outside this
|
|
116
|
+
* is a label that disagrees with the media, and the media wins.
|
|
117
|
+
*/
|
|
118
|
+
const SOURCE_TRACK_KINDS: Record<PublishSource, readonly ("audio" | "video")[]> = {
|
|
119
|
+
microphone: ["audio"],
|
|
120
|
+
camera: ["video"],
|
|
121
|
+
screen: ["audio", "video"],
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** Which grant a publish needs, from what it declares and what it carries. */
|
|
125
|
+
export function requiredPublishKind(source: string | undefined, trackKind: "audio" | "video"): PublishKind {
|
|
126
|
+
const declared = source ? SOURCE_KINDS[source as PublishSource] : undefined;
|
|
127
|
+
if (!declared) return trackKind;
|
|
128
|
+
// The declaration is honoured only where the track bears it out. A camera on
|
|
129
|
+
// an audio track, or a microphone on a video one, is a relabelling, and
|
|
130
|
+
// falling back to the track's own kind is what stops it buying a grant the
|
|
131
|
+
// token was never given.
|
|
132
|
+
if (!SOURCE_TRACK_KINDS[source as PublishSource].includes(trackKind)) return trackKind;
|
|
133
|
+
return declared;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* May this token publish this? The node's decision, and the UI's hint, from one
|
|
138
|
+
* function so the button and the enforcement cannot disagree.
|
|
139
|
+
*/
|
|
140
|
+
export function mayPublish(
|
|
141
|
+
grants: MediaGrants | undefined,
|
|
142
|
+
input: { source: string | undefined; trackKind: "audio" | "video" },
|
|
143
|
+
): boolean {
|
|
144
|
+
if (!grants?.canPublish) return false;
|
|
145
|
+
if (!grants.publishKinds) return true;
|
|
146
|
+
return grants.publishKinds.includes(requiredPublishKind(input.source, input.trackKind));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export const mediaGrantsSchema = z
|
|
150
|
+
.object({
|
|
151
|
+
canPublish: z.boolean(),
|
|
152
|
+
canSubscribe: z.boolean(),
|
|
153
|
+
canPublishData: z.boolean(),
|
|
154
|
+
/** Who I may receive. Omitted means `{ mode: "all" }`. */
|
|
155
|
+
subscribe: subscribeRuleSchema.optional(),
|
|
156
|
+
/**
|
|
157
|
+
* Who may receive ME. Enforced on the PRODUCER side from the owner's stored
|
|
158
|
+
* grants, so a barrier holds even if the other party's token is generous.
|
|
159
|
+
*/
|
|
160
|
+
visibleTo: subscribeRuleSchema.optional(),
|
|
161
|
+
/**
|
|
162
|
+
* Which kinds of media this token may send, in the vocabulary of
|
|
163
|
+
* `publishKindSchema`. A publisher labels its track with a SOURCE
|
|
164
|
+
* ("microphone", "camera", "screen", "rtmp", ...) and those are not the same
|
|
165
|
+
* words: `requiredPublishKind` below is the one translation between them.
|
|
166
|
+
*/
|
|
167
|
+
publishKinds: z.array(publishKindSchema).optional(),
|
|
168
|
+
/**
|
|
169
|
+
* Bypasses the top-N active-speaker refusal. Mintable ONLY for a non-human
|
|
170
|
+
* kind: a recorder needs every track, a human asking for 1,000 does not.
|
|
171
|
+
*/
|
|
172
|
+
subscribeExempt: z.boolean().optional(),
|
|
173
|
+
/** Hard ceiling on concurrent consumers, for a non-human participant. */
|
|
174
|
+
maxSubscriptions: z.number().int().positive().max(5000).optional(),
|
|
175
|
+
})
|
|
176
|
+
.strict();
|
|
177
|
+
|
|
178
|
+
export type MediaGrants = z.infer<typeof mediaGrantsSchema>;
|
|
179
|
+
|
|
180
|
+
/** An omitted rule means everyone. Stated once so both sides agree. */
|
|
181
|
+
const DEFAULT_RULE: SubscribeRule = { mode: "all" };
|
|
182
|
+
|
|
183
|
+
function ruleAllows(rule: SubscribeRule | undefined, identity: string): boolean {
|
|
184
|
+
const r = rule ?? DEFAULT_RULE;
|
|
185
|
+
switch (r.mode) {
|
|
186
|
+
case "all":
|
|
187
|
+
return true;
|
|
188
|
+
case "none":
|
|
189
|
+
return false;
|
|
190
|
+
case "allow":
|
|
191
|
+
return r.identities.includes(identity);
|
|
192
|
+
case "deny":
|
|
193
|
+
return !r.identities.includes(identity);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* May `consumer` receive `producer`? Two-sided, and deny wins.
|
|
199
|
+
*
|
|
200
|
+
* One-sided is not enough. If only the consumer's rule were consulted, a
|
|
201
|
+
* participant whose own token was minted generously could receive someone who
|
|
202
|
+
* had been explicitly walled off from them. So the producer's `visibleTo` is
|
|
203
|
+
* checked from the OWNER's stored grants, not from anything the consumer
|
|
204
|
+
* presents.
|
|
205
|
+
*
|
|
206
|
+
* **An unreadable owner is `false`.** If the producer's grants cannot be found,
|
|
207
|
+
* this refuses rather than falling through to the permissive default. A missing
|
|
208
|
+
* record is exactly the state a race or a partial failure produces, and in a
|
|
209
|
+
* room with an information barrier the safe reading of "I do not know" is "no".
|
|
210
|
+
* Note this differs deliberately from an ABSENT RULE on a record that WAS
|
|
211
|
+
* found, which means everyone.
|
|
212
|
+
*/
|
|
213
|
+
export function maySubscribe(input: {
|
|
214
|
+
consumerIdentity: string;
|
|
215
|
+
consumerGrants: MediaGrants;
|
|
216
|
+
producerIdentity: string;
|
|
217
|
+
/** `undefined` when the owner's record could not be read. */
|
|
218
|
+
ownerGrants: MediaGrants | undefined;
|
|
219
|
+
}): boolean {
|
|
220
|
+
if (!input.consumerGrants.canSubscribe) return false;
|
|
221
|
+
// Nobody subscribes to themselves; asking is a client bug, not a denial.
|
|
222
|
+
if (input.consumerIdentity === input.producerIdentity) return false;
|
|
223
|
+
if (!input.ownerGrants) return false;
|
|
224
|
+
|
|
225
|
+
return (
|
|
226
|
+
ruleAllows(input.consumerGrants.subscribe, input.producerIdentity) &&
|
|
227
|
+
ruleAllows(input.ownerGrants.visibleTo, input.consumerIdentity)
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* `subscribeExempt` and `maxSubscriptions` are meaningless for a human and
|
|
233
|
+
* dangerous if mintable for one: exemption is what lets a recorder take every
|
|
234
|
+
* track, and a human with it can pull a thousand streams through a node.
|
|
235
|
+
* Checked at MINT time so a bad token never exists.
|
|
236
|
+
*/
|
|
237
|
+
export function grantsAreValidForKind(grants: MediaGrants, kind: ParticipantKind): true | string {
|
|
238
|
+
if (kind === "human" && grants.subscribeExempt) {
|
|
239
|
+
return "subscribeExempt may not be minted for a human participant";
|
|
240
|
+
}
|
|
241
|
+
if (!grants.canSubscribe && (grants.subscribe || grants.subscribeExempt || grants.maxSubscriptions)) {
|
|
242
|
+
return "subscribe rules are meaningless when canSubscribe is false";
|
|
243
|
+
}
|
|
244
|
+
if (!grants.canPublish && grants.publishKinds?.length) {
|
|
245
|
+
return "publishKinds is meaningless when canPublish is false";
|
|
246
|
+
}
|
|
247
|
+
return true;
|
|
248
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contract between the backend, the media plane and every client.
|
|
3
|
+
*
|
|
4
|
+
* The root export is ISOMORPHIC: types, zod schemas, wire frames and the pure
|
|
5
|
+
* `maySubscribe` decision. It pulls in no `node:crypto`, no mediasoup, no DOM,
|
|
6
|
+
* so a browser SDK can import it.
|
|
7
|
+
*
|
|
8
|
+
* Signing and verifying live at `@tribe-nest/media-protocol/token`, which is
|
|
9
|
+
* Node-only. Keeping them apart is what stops importing a frame type from
|
|
10
|
+
* dragging crypto into a browser bundle.
|
|
11
|
+
*
|
|
12
|
+
* This package exists because `apps/backend` and `apps/media-server` may not
|
|
13
|
+
* import each other (there is a test), and six independently written phase
|
|
14
|
+
* plans each invented a different wire envelope for want of somewhere neutral
|
|
15
|
+
* to put one.
|
|
16
|
+
*/
|
|
17
|
+
export * from "./version";
|
|
18
|
+
export * from "./errors";
|
|
19
|
+
export * from "./errorSchema";
|
|
20
|
+
export * from "./grants";
|
|
21
|
+
export * from "./claims";
|
|
22
|
+
export * from "./frames";
|
|
23
|
+
export * from "./codecs";
|
package/src/token.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import * as crypto from "crypto";
|
|
2
|
+
import {
|
|
3
|
+
mediaControlClaimsSchema,
|
|
4
|
+
mediaJoinClaimsSchema,
|
|
5
|
+
type JoinTokenRejection,
|
|
6
|
+
type MediaControlClaims,
|
|
7
|
+
type MediaJoinClaims,
|
|
8
|
+
type VerifyResult,
|
|
9
|
+
} from "./claims";
|
|
10
|
+
import { grantsAreValidForKind } from "./grants";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Ed25519-signed join and control tokens.
|
|
14
|
+
*
|
|
15
|
+
* ## Why asymmetric, when LiveKit uses a shared secret
|
|
16
|
+
*
|
|
17
|
+
* LiveKit signs with an API key + secret, which is HMAC: every party that can
|
|
18
|
+
* verify can also mint. Media nodes are public-facing, accept connections from
|
|
19
|
+
* anyone, and there are many of them. Under a shared secret, compromising ONE
|
|
20
|
+
* node yields the ability to mint a token for any room in the system -
|
|
21
|
+
* including a Mandantengespräch.
|
|
22
|
+
*
|
|
23
|
+
* So the backend holds a PRIVATE key and signs; nodes hold only PUBLIC keys and
|
|
24
|
+
* verify. A compromised node can verify and nothing else. The cost is a keypair
|
|
25
|
+
* instead of a string.
|
|
26
|
+
*
|
|
27
|
+
* ## Why this file is Node-only, and the package root is not
|
|
28
|
+
*
|
|
29
|
+
* Signing and verifying need `node:crypto`. The client SDK imports the wire
|
|
30
|
+
* frames and grant TYPES and never verifies anything - it only carries a token
|
|
31
|
+
* it was handed - so those live at the package root and stay isomorphic. This
|
|
32
|
+
* module is a separate entry point (`@tribe-nest/media-protocol/token`) so
|
|
33
|
+
* importing a frame type cannot drag `crypto` into a browser bundle.
|
|
34
|
+
*
|
|
35
|
+
* ## Why verification is PURE
|
|
36
|
+
*
|
|
37
|
+
* No I/O, no clock unless you pass one, no Redis. Replay defence needs state
|
|
38
|
+
* and therefore lives in the media server (`auth/replayGuard`), composed
|
|
39
|
+
* around this. Keeping the crypto and claim checks free of state is what lets
|
|
40
|
+
* the golden vectors pin exactly one code path on both sides of the wire.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
export type PublicKeyring = Record<string, string>;
|
|
44
|
+
export type PrivateKeyring = Record<string, string>;
|
|
45
|
+
|
|
46
|
+
const ALG = "EdDSA";
|
|
47
|
+
const b64u = (b: Buffer) => b.toString("base64url");
|
|
48
|
+
const fromB64u = (s: string) => Buffer.from(s, "base64url");
|
|
49
|
+
|
|
50
|
+
/** Default ceiling on `exp - iat`. A join ticket, not a session. */
|
|
51
|
+
export const MAX_TOKEN_TTL_SECONDS = 15 * 60;
|
|
52
|
+
|
|
53
|
+
function encodeSegment(value: unknown): string {
|
|
54
|
+
return b64u(Buffer.from(JSON.stringify(value), "utf8"));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function signCompact(privateKeyPem: string, kid: string, payload: unknown): string {
|
|
58
|
+
const header = encodeSegment({ alg: ALG, typ: "JWT", kid });
|
|
59
|
+
const body = encodeSegment(payload);
|
|
60
|
+
const signingInput = `${header}.${body}`;
|
|
61
|
+
// For Ed25519 the digest algorithm is null: the curve prescribes it.
|
|
62
|
+
const signature = crypto.sign(null, Buffer.from(signingInput, "utf8"), privateKeyPem);
|
|
63
|
+
return `${signingInput}.${b64u(signature)}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function signJoinToken(privateKeyPem: string, kid: string, claims: MediaJoinClaims): string {
|
|
67
|
+
const parsed = mediaJoinClaimsSchema.parse(claims);
|
|
68
|
+
|
|
69
|
+
// Mint-time refusals. A token that should never exist is better stopped here
|
|
70
|
+
// than relied upon to be refused by every future verifier.
|
|
71
|
+
const grantCheck = grantsAreValidForKind(parsed.grants, parsed.kind);
|
|
72
|
+
if (grantCheck !== true) throw new Error(`refusing to mint: ${grantCheck}`);
|
|
73
|
+
if (parsed.exp <= parsed.iat) throw new Error("refusing to mint: exp is not after iat");
|
|
74
|
+
if (parsed.exp - parsed.iat > MAX_TOKEN_TTL_SECONDS) {
|
|
75
|
+
throw new Error(`refusing to mint: ttl exceeds ${MAX_TOKEN_TTL_SECONDS}s`);
|
|
76
|
+
}
|
|
77
|
+
if (parsed.transport !== "webrtc" && parsed.kind === "human") {
|
|
78
|
+
throw new Error("refusing to mint: a human participant may only use a webrtc transport");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return signCompact(privateKeyPem, kid, parsed);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function signControlToken(privateKeyPem: string, kid: string, claims: MediaControlClaims): string {
|
|
85
|
+
const parsed = mediaControlClaimsSchema.parse(claims);
|
|
86
|
+
if (parsed.exp <= parsed.iat) throw new Error("refusing to mint: exp is not after iat");
|
|
87
|
+
return signCompact(privateKeyPem, kid, parsed);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
type VerifyArgs = {
|
|
91
|
+
token: string;
|
|
92
|
+
keys: PublicKeyring;
|
|
93
|
+
issuer: string;
|
|
94
|
+
maxTtlSeconds?: number;
|
|
95
|
+
/** Seconds since epoch. Injected so vectors are deterministic. */
|
|
96
|
+
nowSeconds?: number;
|
|
97
|
+
skewSeconds?: number;
|
|
98
|
+
/** When given, the token's room must match. */
|
|
99
|
+
expectedRoom?: string;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
function verifyCompact(
|
|
103
|
+
args: VerifyArgs,
|
|
104
|
+
audience: "media" | "control",
|
|
105
|
+
): { ok: true; payload: unknown; kid: string } | { ok: false; reason: JoinTokenRejection } {
|
|
106
|
+
const parts = args.token.split(".");
|
|
107
|
+
if (parts.length !== 3) return { ok: false, reason: "malformed" };
|
|
108
|
+
const [headerSeg, bodySeg, sigSeg] = parts as [string, string, string];
|
|
109
|
+
|
|
110
|
+
let header: { alg?: unknown; kid?: unknown };
|
|
111
|
+
try {
|
|
112
|
+
header = JSON.parse(fromB64u(headerSeg).toString("utf8"));
|
|
113
|
+
} catch {
|
|
114
|
+
return { ok: false, reason: "malformed" };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Only ever EdDSA. Accepting whatever the token names is the classic JWT
|
|
118
|
+
// algorithm-confusion hole, and `none` is the worst case of it.
|
|
119
|
+
if (header.alg !== ALG) return { ok: false, reason: "malformed" };
|
|
120
|
+
if (typeof header.kid !== "string" || !header.kid) return { ok: false, reason: "malformed" };
|
|
121
|
+
|
|
122
|
+
const publicKeyPem = args.keys[header.kid];
|
|
123
|
+
if (!publicKeyPem) return { ok: false, reason: "unknown_kid" };
|
|
124
|
+
|
|
125
|
+
// Signature BEFORE claims: never parse a payload that has not been shown to
|
|
126
|
+
// come from us.
|
|
127
|
+
let signatureValid = false;
|
|
128
|
+
try {
|
|
129
|
+
signatureValid = crypto.verify(
|
|
130
|
+
null,
|
|
131
|
+
Buffer.from(`${headerSeg}.${bodySeg}`, "utf8"),
|
|
132
|
+
publicKeyPem,
|
|
133
|
+
fromB64u(sigSeg),
|
|
134
|
+
);
|
|
135
|
+
} catch {
|
|
136
|
+
return { ok: false, reason: "bad_signature" };
|
|
137
|
+
}
|
|
138
|
+
if (!signatureValid) return { ok: false, reason: "bad_signature" };
|
|
139
|
+
|
|
140
|
+
let payload: unknown;
|
|
141
|
+
try {
|
|
142
|
+
payload = JSON.parse(fromB64u(bodySeg).toString("utf8"));
|
|
143
|
+
} catch {
|
|
144
|
+
return { ok: false, reason: "malformed" };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const asRecord = payload as Record<string, unknown>;
|
|
148
|
+
if (asRecord?.aud !== audience) return { ok: false, reason: "wrong_audience" };
|
|
149
|
+
if (asRecord?.iss !== args.issuer) return { ok: false, reason: "wrong_issuer" };
|
|
150
|
+
|
|
151
|
+
return { ok: true, payload, kid: header.kid };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function checkTiming(
|
|
155
|
+
claims: { iat: number; exp: number; nbf?: number },
|
|
156
|
+
args: VerifyArgs,
|
|
157
|
+
): JoinTokenRejection | null {
|
|
158
|
+
const now = args.nowSeconds ?? Math.floor(Date.now() / 1000);
|
|
159
|
+
const skew = args.skewSeconds ?? 30;
|
|
160
|
+
const maxTtl = args.maxTtlSeconds ?? MAX_TOKEN_TTL_SECONDS;
|
|
161
|
+
|
|
162
|
+
if (claims.exp + skew <= now) return "expired";
|
|
163
|
+
if (claims.nbf !== undefined && claims.nbf - skew > now) return "not_yet_valid";
|
|
164
|
+
// A long-lived token is a stolen credential with a long life, so the ceiling
|
|
165
|
+
// is enforced at VERIFY too - not only at mint, which a compromised minter
|
|
166
|
+
// would simply skip.
|
|
167
|
+
if (claims.exp - claims.iat > maxTtl) return "ttl_too_long";
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function verifyJoinToken(args: VerifyArgs): VerifyResult<MediaJoinClaims> {
|
|
172
|
+
const base = verifyCompact(args, "media");
|
|
173
|
+
if (!base.ok) return base;
|
|
174
|
+
|
|
175
|
+
const parsed = mediaJoinClaimsSchema.safeParse(base.payload);
|
|
176
|
+
// `.strict()` fires here. A token carrying `subscribeOnly` instead of
|
|
177
|
+
// `subscribe` is rejected outright rather than read as "no rule, so
|
|
178
|
+
// everyone" - which is how an information barrier fails open.
|
|
179
|
+
if (!parsed.success) return { ok: false, reason: "bad_claims" };
|
|
180
|
+
|
|
181
|
+
const timing = checkTiming(parsed.data, args);
|
|
182
|
+
if (timing) return { ok: false, reason: timing };
|
|
183
|
+
|
|
184
|
+
if (args.expectedRoom !== undefined && parsed.data.room !== args.expectedRoom) {
|
|
185
|
+
return { ok: false, reason: "wrong_room" };
|
|
186
|
+
}
|
|
187
|
+
if (grantsAreValidForKind(parsed.data.grants, parsed.data.kind) !== true) {
|
|
188
|
+
return { ok: false, reason: "bad_claims" };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { ok: true, claims: parsed.data, kid: base.kid };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function verifyControlToken(args: VerifyArgs): VerifyResult<MediaControlClaims> {
|
|
195
|
+
const base = verifyCompact(args, "control");
|
|
196
|
+
if (!base.ok) return base;
|
|
197
|
+
|
|
198
|
+
const parsed = mediaControlClaimsSchema.safeParse(base.payload);
|
|
199
|
+
if (!parsed.success) return { ok: false, reason: "bad_claims" };
|
|
200
|
+
|
|
201
|
+
const timing = checkTiming(parsed.data, args);
|
|
202
|
+
if (timing) return { ok: false, reason: timing };
|
|
203
|
+
|
|
204
|
+
return { ok: true, claims: parsed.data, kid: base.kid };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Boot guard for a media node.
|
|
209
|
+
*
|
|
210
|
+
* A node must hold verification keys ONLY. If a private key reaches a node's
|
|
211
|
+
* keyring - a copy-paste, a shared secrets bundle - that node can mint, and the
|
|
212
|
+
* whole reason for the asymmetric split is gone silently. This makes it a
|
|
213
|
+
* refusal to start rather than a latent capability.
|
|
214
|
+
*/
|
|
215
|
+
export function assertPublicKeyringIsPublicOnly(keys: PublicKeyring): void {
|
|
216
|
+
for (const [kid, pem] of Object.entries(keys)) {
|
|
217
|
+
if (/PRIVATE KEY/.test(pem)) {
|
|
218
|
+
throw new Error(
|
|
219
|
+
`media token keyring "${kid}" contains a PRIVATE key. Nodes verify and must never be able to mint.`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
crypto.createPublicKey(pem);
|
|
224
|
+
} catch {
|
|
225
|
+
throw new Error(`media token keyring "${kid}" is not a readable public key`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Convenience for tooling and tests; not used at runtime. */
|
|
231
|
+
export function generateKeypair(): { publicKey: string; privateKey: string } {
|
|
232
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
|
233
|
+
return {
|
|
234
|
+
publicKey: publicKey.export({ type: "spki", format: "pem" }).toString(),
|
|
235
|
+
privateKey: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
|
|
236
|
+
};
|
|
237
|
+
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bumped when a frame shape changes in a way an older peer cannot parse.
|
|
3
|
+
*
|
|
4
|
+
* The client sends it on connect and a node refuses a mismatch outright, rather
|
|
5
|
+
* than negotiating: a half-understood signalling session fails later, somewhere
|
|
6
|
+
* else, and looks like a media bug.
|
|
7
|
+
*/
|
|
8
|
+
export const MEDIA_PROTOCOL_VERSION = 1 as const;
|