@feralfile/cli 3.0.0 → 4.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.
Files changed (45) hide show
  1. package/README.md +20 -17
  2. package/config.json.example +1 -1
  3. package/dist/index.js +10 -2
  4. package/dist/package.json +5 -2
  5. package/dist/src/commands/build.js +1 -1
  6. package/dist/src/commands/device.js +84 -39
  7. package/dist/src/commands/enrich.js +2 -2
  8. package/dist/src/commands/find.js +71 -37
  9. package/dist/src/commands/helpers/device-control.js +937 -0
  10. package/dist/src/commands/helpers/hidden-alias-help.js +37 -0
  11. package/dist/src/commands/helpers/playlist-display.js +2 -15
  12. package/dist/src/commands/play.js +39 -26
  13. package/dist/src/commands/report.js +251 -0
  14. package/dist/src/commands/setup.js +1 -1
  15. package/dist/src/config.js +162 -6
  16. package/dist/src/utilities/control/codec.js +578 -0
  17. package/dist/src/utilities/control/control-errors.js +157 -0
  18. package/dist/src/utilities/control/controller-store.js +964 -0
  19. package/dist/src/utilities/control/jwk.js +141 -0
  20. package/dist/src/utilities/control/pairing-channel.js +52 -0
  21. package/dist/src/utilities/control/pairing.js +416 -0
  22. package/dist/src/utilities/control/registration.js +391 -0
  23. package/dist/src/utilities/control/relay-pairing-channel.js +191 -0
  24. package/dist/src/utilities/control/secure-transport.js +850 -0
  25. package/dist/src/utilities/control/strict-json.js +376 -0
  26. package/dist/src/utilities/device-network.js +129 -0
  27. package/dist/src/utilities/device-upsert.js +1 -1
  28. package/dist/src/utilities/ff1-compatibility.js +87 -11
  29. package/dist/src/utilities/ff1-device.js +161 -78
  30. package/dist/src/utilities/ff1-relayer.js +34 -14
  31. package/dist/src/utilities/handoff-client.js +23 -9
  32. package/dist/src/utilities/neort-marketplace.js +3 -3
  33. package/dist/src/utilities/playlist-builder.js +3 -3
  34. package/dist/src/utilities/playlist-cast.js +6 -6
  35. package/dist/src/utilities/playlist-source.js +7 -7
  36. package/dist/src/utilities/raster-client.js +1 -1
  37. package/dist/src/utilities/relay-client.js +774 -0
  38. package/dist/src/utilities/ssh-access.js +192 -58
  39. package/dist/src/utilities/support-report.js +511 -0
  40. package/dist/src/utilities/temporary-channel.js +139 -0
  41. package/docs/CONFIGURATION.md +270 -28
  42. package/docs/EXAMPLES.md +31 -31
  43. package/docs/PROJECT_SPEC.md +32 -19
  44. package/docs/README.md +113 -36
  45. package/package.json +5 -2
@@ -0,0 +1,578 @@
1
+ "use strict";
2
+ /**
3
+ * FF1 control protocol v1 codec (ffos-user docs/ff1-control-v1.md), the
4
+ * controller side: seal requests (§3), open replies (§4) and sealed
5
+ * notifications (§9), and the approval code (§5.4).
6
+ *
7
+ * This is the TypeScript implementation that spec §12 requires to round-trip
8
+ * the Go reference's fixtures (tests/fixtures/ff1-control-v1). Cryptography is
9
+ * done by `jose` (panva); everything jose is lenient about is checked here
10
+ * first, because the spec is stricter than RFC 7515/7516:
11
+ *
12
+ * - Every protected header has a CLOSED member set. jose would accept a `zip`,
13
+ * `jku` or extra member; this module rejects them before jose sees the token.
14
+ * - Duplicate members and non-canonical integers are rejected (strict-json.ts).
15
+ * - Only ES256 (raw 64-byte r||s), ECDH-ES (direct) and dir with A256GCM.
16
+ *
17
+ * Do not "simplify" a check here into a jose option: the closed-set rules are
18
+ * the protocol, and two implementations disagreeing on them is the bug class
19
+ * the spec exists to prevent.
20
+ */
21
+ Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.NotificationOrder = exports.CONTROL_PLATFORMS = exports.DEFAULT_REQUEST_LIFETIME_SECONDS = exports.MAX_ENVELOPE_COMPACT_BYTES = exports.MAX_HEADER_BYTES = exports.MAX_COMPACT_BYTES = void 0;
23
+ exports.parseProfile = parseProfile;
24
+ exports.approvalCode = approvalCode;
25
+ exports.controllerIdOf = controllerIdOf;
26
+ exports.encodeRequestClaims = encodeRequestClaims;
27
+ exports.buildRequestClaims = buildRequestClaims;
28
+ exports.labelProblem = labelProblem;
29
+ exports.stripUnsafeLabelCharacters = stripUnsafeLabelCharacters;
30
+ exports.signRequest = signRequest;
31
+ exports.encryptRequest = encryptRequest;
32
+ exports.checkRequestHeader = checkRequestHeader;
33
+ exports.sealRequest = sealRequest;
34
+ exports.openReply = openReply;
35
+ exports.openNotification = openNotification;
36
+ exports.envelopeCastBody = envelopeCastBody;
37
+ const node_crypto_1 = require("node:crypto");
38
+ const jose_1 = require("jose");
39
+ const strict_json_1 = require("./strict-json");
40
+ const jwk_1 = require("./jwk");
41
+ /** MAX_COMPACT_BYTES bounds a whole compact object before any decoding (§3.1). */
42
+ exports.MAX_COMPACT_BYTES = 4 * 1024 * 1024;
43
+ /** MAX_HEADER_BYTES bounds the base64url protected-header segment (§3.1). */
44
+ exports.MAX_HEADER_BYTES = 4 * 1024;
45
+ /**
46
+ * MAX_ENVELOPE_COMPACT_BYTES is what ff-cli lets a request grow to: the LAN
47
+ * hub caps the whole cast BODY at 4 MiB (§10), so the compact leaves room for
48
+ * the `{"command":"ff1Envelope","request":{"v":1,"jwe":…}}` wrapper.
49
+ */
50
+ exports.MAX_ENVELOPE_COMPACT_BYTES = exports.MAX_COMPACT_BYTES - 1024;
51
+ /** DEFAULT_REQUEST_LIFETIME_SECONDS is exp - iat; the spec allows [1, 120]. */
52
+ exports.DEFAULT_REQUEST_LIFETIME_SECONDS = 60;
53
+ const TYP_REQ_JWE = 'ff1-req+jwe';
54
+ const TYP_REQ_JWS = 'ff1-req+jws';
55
+ const TYP_RES_JWE = 'ff1-res+jwe';
56
+ const TYP_RES_JWS = 'ff1-res+jws';
57
+ const TYP_NTF_JWE = 'ff1-ntf+jwe';
58
+ const GCM_IV_LEN = 12;
59
+ const GCM_TAG_LEN = 16;
60
+ const KEY_LEN = 32;
61
+ const ES256_SIG_LEN = 64;
62
+ const RID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
63
+ const MAX_LABEL_CHARS = 64;
64
+ /** CONTROL_PLATFORMS are the enroll.platform values of §5. */
65
+ exports.CONTROL_PLATFORMS = ['ios', 'android', 'cli', 'other'];
66
+ /**
67
+ * parseProfile validates a device profile `{v:1,dev,gen,sig,enc}` (§2) with
68
+ * a closed member set. The profile is the pin source, so it is never trusted
69
+ * partially.
70
+ *
71
+ * @param value - Parsed JSON value
72
+ * @returns The profile with canonical JWKs
73
+ * @throws {ControlFormatError} When malformed
74
+ */
75
+ function parseProfile(value) {
76
+ const obj = (0, strict_json_1.strictObject)(value, ['v', 'dev', 'gen', 'sig', 'enc'], [], 'profile');
77
+ if (obj.v !== 1) {
78
+ throw new strict_json_1.ControlFormatError('profile v must be 1');
79
+ }
80
+ const dev = (0, strict_json_1.stringMember)(obj, 'dev');
81
+ const gen = (0, strict_json_1.stringMember)(obj, 'gen');
82
+ if (!dev) {
83
+ throw new strict_json_1.ControlFormatError('profile dev is empty');
84
+ }
85
+ if (!(0, strict_json_1.isB64uOfLength)(gen, 16)) {
86
+ throw new strict_json_1.ControlFormatError('profile gen must be b64u(16 bytes)');
87
+ }
88
+ return {
89
+ v: 1,
90
+ dev,
91
+ gen,
92
+ sig: (0, jwk_1.parsePublicJwk)(obj.sig, 'profile sig'),
93
+ enc: (0, jwk_1.parsePublicJwk)(obj.enc, 'profile enc'),
94
+ };
95
+ }
96
+ /**
97
+ * approvalCode is §5.4: the first 4 bytes of
98
+ * SHA-256("ff1-approval-code/v1|" + ctl + "|" + sigPin) as a big-endian
99
+ * uint32, mod 1,000,000, zero-padded to 6 digits. sigPin is thumb(devSig) as
100
+ * THIS controller pinned it, so a LAN man-in-the-middle serving a fake
101
+ * profile produces a code that differs from the one the real device shows
102
+ * the owner.
103
+ *
104
+ * @param ctl - controllerId (thumb(ctlSig))
105
+ * @param sigPin - thumb(devSig) of the pinned profile
106
+ * @returns Six decimal digits
107
+ */
108
+ function approvalCode(ctl, sigPin) {
109
+ const digest = (0, node_crypto_1.createHash)('sha256')
110
+ .update(`ff1-approval-code/v1|${ctl}|${sigPin}`, 'utf8')
111
+ .digest();
112
+ return String(digest.readUInt32BE(0) % 1_000_000).padStart(6, '0');
113
+ }
114
+ /**
115
+ * controllerIdOf returns thumb(ctlSig), the controllerId (§2).
116
+ *
117
+ * @param keys - Controller keys
118
+ * @returns The controllerId
119
+ */
120
+ function controllerIdOf(keys) {
121
+ return (0, jwk_1.thumbprint)(keys.sig);
122
+ }
123
+ /**
124
+ * encodeRequestClaims serializes claims in the reference member order. Order
125
+ * is not significant to the device, but a stable order makes the payload
126
+ * byte-comparable with the Go fixtures.
127
+ *
128
+ * @param c - Claims to encode
129
+ * @returns JSON text
130
+ */
131
+ function encodeRequestClaims(c) {
132
+ const parts = [
133
+ `"v":1`,
134
+ `"dev":${JSON.stringify(c.dev)}`,
135
+ `"gen":${JSON.stringify(c.gen)}`,
136
+ `"ctl":${JSON.stringify(c.ctl)}`,
137
+ `"rid":${JSON.stringify(c.rid)}`,
138
+ `"ctr":${c.ctr}`,
139
+ `"iat":${c.iat}`,
140
+ `"exp":${c.exp}`,
141
+ `"op":${JSON.stringify(c.op)}`,
142
+ `"rk":${JSON.stringify(c.rk)}`,
143
+ ];
144
+ if (c.cmd !== undefined) {
145
+ parts.push(`"cmd":${JSON.stringify(c.cmd)}`, `"req":${JSON.stringify(c.req ?? {})}`);
146
+ }
147
+ if (c.enroll) {
148
+ const e = c.enroll;
149
+ const enroll = [
150
+ `"sig":${(0, jwk_1.canonicalJwkJson)(e.sig)}`,
151
+ `"enc":${(0, jwk_1.canonicalJwkJson)(e.enc)}`,
152
+ `"label":${JSON.stringify(e.label)}`,
153
+ `"platform":${JSON.stringify(e.platform)}`,
154
+ ];
155
+ if (e.inv !== undefined) {
156
+ enroll.push(`"inv":${JSON.stringify(e.inv)}`);
157
+ }
158
+ parts.push(`"enroll":{${enroll.join(',')}}`);
159
+ }
160
+ return `{${parts.join(',')}}`;
161
+ }
162
+ /**
163
+ * buildRequestClaims assembles and validates §3.3 claims. It refuses to build
164
+ * a request the device would answer `invalid_request`, so such a bug surfaces
165
+ * locally instead of burning a counter on the device.
166
+ *
167
+ * @param input - Request input
168
+ * @param rk - Reply key bytes
169
+ * @returns The claims
170
+ * @throws {Error} When the input breaks a §3.3 rule
171
+ */
172
+ function buildRequestClaims(input, rk) {
173
+ const now = input.now ?? Math.floor(Date.now() / 1000);
174
+ const lifetime = input.lifetimeSeconds ?? exports.DEFAULT_REQUEST_LIFETIME_SECONDS;
175
+ const rid = input.rid ?? (0, strict_json_1.b64uEncode)((0, node_crypto_1.randomBytes)(16));
176
+ if (!Number.isSafeInteger(input.ctr) || input.ctr < 1) {
177
+ throw new Error('ctr must be a positive integer');
178
+ }
179
+ if (!RID_PATTERN.test(rid) || rk.length !== KEY_LEN) {
180
+ throw new Error('rid or rk is malformed');
181
+ }
182
+ if (!Number.isInteger(lifetime) || lifetime < 1 || lifetime > 120) {
183
+ throw new Error('request lifetime must be 1-120 seconds');
184
+ }
185
+ const claims = {
186
+ v: 1,
187
+ dev: input.profile.dev,
188
+ gen: input.profile.gen,
189
+ ctl: controllerIdOf(input.keys),
190
+ rid,
191
+ ctr: input.ctr,
192
+ iat: now,
193
+ exp: now + lifetime,
194
+ op: input.op,
195
+ rk: (0, strict_json_1.b64uEncode)(rk),
196
+ };
197
+ if (input.op === 'cmd') {
198
+ assertCommand(input.cmd, input.req, input.enroll);
199
+ claims.cmd = input.cmd;
200
+ claims.req = input.req ?? {};
201
+ }
202
+ else {
203
+ assertEnroll(input.op, input.enroll, input.cmd, claims.ctl);
204
+ claims.enroll = input.enroll;
205
+ }
206
+ return claims;
207
+ }
208
+ /**
209
+ * UNSAFE_LABEL_CHARACTERS are characters a controller label must not contain:
210
+ * C0/C1 controls, every Unicode format character (Cf: zero-width spaces and
211
+ * joiners, bidi marks and isolates, the BOM, soft hyphen, …) and the line and
212
+ * paragraph separators. The device rejects the C0/C1 controls and its own
213
+ * invisible/bidi set (ffos-user devicename.HasUnsafeCharacters) with
214
+ * `invalid_request`; this set is a superset of that, so ff-cli never sends a
215
+ * label the device would refuse, and the owner's approval sheet cannot be
216
+ * spoofed with invisible or reordering characters.
217
+ */
218
+ const UNSAFE_LABEL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\p{Cf}\u2028\u2029]/u;
219
+ /**
220
+ * labelProblem explains why a controller label is not acceptable (§5), or
221
+ * returns undefined when it is.
222
+ *
223
+ * @param label - Candidate label
224
+ * @returns A reason, or undefined
225
+ */
226
+ function labelProblem(label) {
227
+ const length = [...label].length;
228
+ if (length < 1 || length > MAX_LABEL_CHARS) {
229
+ return 'must be 1-64 characters';
230
+ }
231
+ if (UNSAFE_LABEL_CHARACTERS.test(label)) {
232
+ return 'must not contain control characters or invisible formatting (bidi, zero-width) characters';
233
+ }
234
+ return undefined;
235
+ }
236
+ /**
237
+ * stripUnsafeLabelCharacters removes what labelProblem rejects, for deriving
238
+ * a default label from the hostname.
239
+ *
240
+ * @param value - Raw text
241
+ * @returns Text without unsafe characters
242
+ */
243
+ function stripUnsafeLabelCharacters(value) {
244
+ return value.replace(new RegExp(UNSAFE_LABEL_CHARACTERS.source, 'gu'), '');
245
+ }
246
+ function assertCommand(cmd, req, enroll) {
247
+ if (typeof cmd !== 'string' || cmd.length === 0 || Buffer.byteLength(cmd) > 64) {
248
+ throw new Error('cmd must be a 1-64 byte command name');
249
+ }
250
+ if (req !== undefined && !(0, strict_json_1.isPlainObject)(req)) {
251
+ throw new Error('req must be a JSON object');
252
+ }
253
+ if (enroll !== undefined) {
254
+ throw new Error('op=cmd forbids enroll');
255
+ }
256
+ }
257
+ function assertEnroll(op, enroll, cmd, ctl) {
258
+ if (op !== 'enroll') {
259
+ throw new Error(`unknown op ${String(op)}`);
260
+ }
261
+ if (!enroll || cmd !== undefined) {
262
+ throw new Error(`op=${op} requires enroll and forbids cmd/req`);
263
+ }
264
+ if ((0, jwk_1.thumbprint)(enroll.sig) !== ctl) {
265
+ throw new Error('enroll.sig must be the signing key');
266
+ }
267
+ const problem = labelProblem(enroll.label);
268
+ if (problem) {
269
+ throw new Error(`enroll label ${problem}`);
270
+ }
271
+ if (!exports.CONTROL_PLATFORMS.includes(enroll.platform)) {
272
+ throw new Error('enroll platform is invalid');
273
+ }
274
+ }
275
+ /**
276
+ * signRequest produces the §3.2 inner JWS: header exactly
277
+ * `{"alg":"ES256","kid":ctl,"typ":"ff1-req+jws"}`, raw r||s signature.
278
+ *
279
+ * @param ctlSig - Controller signing key
280
+ * @param payload - Encoded claims
281
+ * @returns Compact JWS
282
+ */
283
+ async function signRequest(ctlSig, payload) {
284
+ const key = await (0, jose_1.importJWK)({ ...ctlSig }, 'ES256');
285
+ return new jose_1.CompactSign(new TextEncoder().encode(payload))
286
+ .setProtectedHeader({ alg: 'ES256', kid: (0, jwk_1.thumbprint)(ctlSig), typ: TYP_REQ_JWS })
287
+ .sign(key);
288
+ }
289
+ /**
290
+ * encryptRequest wraps a JWS in the §3.1 request JWE (ECDH-ES direct, A256GCM)
291
+ * for the pinned devEnc.
292
+ *
293
+ * jose appends `epk` to the protected header itself. jose writes its members
294
+ * in the order x, crv, kty, y rather than the thumbprint order the spec
295
+ * RECOMMENDS (SHOULD); the device accepts any order (MUST), so the header is
296
+ * re-parsed and checked for the exact member set and a valid epk instead of
297
+ * hand-rolling the key agreement. That check is what guards against a jose
298
+ * upgrade adding a member (apu/apv/zip) the device would reject.
299
+ *
300
+ * @param devEnc - Pinned device encryption key
301
+ * @param jws - Inner compact JWS
302
+ * @param fixed - Test seam for a fixed ephemeral key and IV
303
+ * @returns Compact JWE
304
+ */
305
+ async function encryptRequest(devEnc, jws, fixed = {}) {
306
+ const recipient = await (0, jose_1.importJWK)({ ...(0, jwk_1.publicOf)(devEnc) }, 'ECDH-ES');
307
+ const builder = new jose_1.CompactEncrypt(new TextEncoder().encode(jws)).setProtectedHeader({
308
+ alg: 'ECDH-ES',
309
+ enc: 'A256GCM',
310
+ kid: (0, jwk_1.thumbprint)(devEnc),
311
+ typ: TYP_REQ_JWE,
312
+ cty: TYP_REQ_JWS,
313
+ });
314
+ if (fixed.ephemeralKey) {
315
+ const epk = await (0, jose_1.importJWK)({ ...fixed.ephemeralKey, ext: true }, 'ECDH-ES', {
316
+ extractable: true,
317
+ });
318
+ builder.setKeyManagementParameters({ epk: epk });
319
+ }
320
+ if (fixed.iv) {
321
+ builder.setInitializationVector(fixed.iv);
322
+ }
323
+ const compact = await builder.encrypt(recipient);
324
+ checkRequestHeader(compact.split('.')[0], (0, jwk_1.thumbprint)(devEnc));
325
+ return compact;
326
+ }
327
+ /**
328
+ * checkRequestHeader asserts a request JWE header is exactly the §3.1 shape.
329
+ *
330
+ * @param segment - Base64url protected header
331
+ * @param devEncKid - Expected kid
332
+ * @returns The ephemeral public key
333
+ * @throws {ControlFormatError} When the header deviates
334
+ */
335
+ function checkRequestHeader(segment, devEncKid) {
336
+ const header = parseHeader(segment, {
337
+ alg: 'ECDH-ES',
338
+ enc: 'A256GCM',
339
+ kid: devEncKid,
340
+ typ: TYP_REQ_JWE,
341
+ cty: TYP_REQ_JWS,
342
+ epk: null,
343
+ });
344
+ return (0, jwk_1.parsePublicJwk)(header.epk, 'epk');
345
+ }
346
+ /**
347
+ * sealRequest builds a complete request: claims, inner JWS, outer JWE.
348
+ * A fresh rk, rid, epk and IV are drawn for every call, which is what makes
349
+ * "never resend the same request" structural: callers retry by sealing again.
350
+ *
351
+ * @param input - Request input
352
+ * @returns The sealed request and its reply key
353
+ * @throws {Error} When claims are invalid or the result exceeds 4 MiB
354
+ */
355
+ async function sealRequest(input) {
356
+ const rk = input.rk ?? new Uint8Array((0, node_crypto_1.randomBytes)(KEY_LEN));
357
+ const claims = buildRequestClaims(input, rk);
358
+ const jws = await signRequest(input.keys.sig, encodeRequestClaims(claims));
359
+ const compact = await encryptRequest(input.profile.enc, jws, {
360
+ ephemeralKey: input.ephemeralKey,
361
+ iv: input.iv,
362
+ });
363
+ if (compact.length > exports.MAX_ENVELOPE_COMPACT_BYTES) {
364
+ throw new Error(`the encrypted request is ${compact.length} bytes, over the FF1 limit of ${exports.MAX_ENVELOPE_COMPACT_BYTES}; host large playlists by URL instead`);
365
+ }
366
+ return { compact, jws, claims, rk };
367
+ }
368
+ /**
369
+ * parseHeader decodes a protected header and checks its exact member set and
370
+ * the exact string value of each member whose expected value is a string. A
371
+ * null expectation means "present; the caller checks it".
372
+ */
373
+ function parseHeader(segment, expected) {
374
+ if (!segment || segment.length > exports.MAX_HEADER_BYTES) {
375
+ throw new strict_json_1.ControlFormatError('protected header size');
376
+ }
377
+ const header = (0, strict_json_1.strictObject)((0, strict_json_1.parseStrictJson)((0, strict_json_1.b64uDecode)(segment)), Object.keys(expected), [], 'protected header');
378
+ for (const [key, want] of Object.entries(expected)) {
379
+ if (want !== null && header[key] !== want) {
380
+ throw new strict_json_1.ControlFormatError(`protected header ${key} is not ${JSON.stringify(want)}`);
381
+ }
382
+ }
383
+ return header;
384
+ }
385
+ /**
386
+ * splitJwe checks the five-part compact form shared by both dir profiles:
387
+ * size, empty encrypted key, 12-byte IV, 16-byte tag, exact header.
388
+ */
389
+ function splitJwe(compact, expected) {
390
+ if (typeof compact !== 'string' || compact.length > exports.MAX_COMPACT_BYTES) {
391
+ throw new strict_json_1.ControlFormatError('compact JWE size');
392
+ }
393
+ const parts = compact.split('.');
394
+ if (parts.length !== 5 || parts[1] !== '') {
395
+ throw new strict_json_1.ControlFormatError('compact JWE shape');
396
+ }
397
+ parseHeader(parts[0], expected);
398
+ if ((0, strict_json_1.b64uDecode)(parts[2]).length !== GCM_IV_LEN || (0, strict_json_1.b64uDecode)(parts[4]).length !== GCM_TAG_LEN) {
399
+ throw new strict_json_1.ControlFormatError('JWE IV or tag length');
400
+ }
401
+ (0, strict_json_1.b64uDecode)(parts[3]);
402
+ }
403
+ /** decryptDir opens a dir/A256GCM JWE whose shape splitJwe already checked. */
404
+ async function decryptDir(compact, key) {
405
+ if (key.length !== KEY_LEN) {
406
+ throw new strict_json_1.ControlFormatError('content key length');
407
+ }
408
+ try {
409
+ const { plaintext } = await (0, jose_1.compactDecrypt)(compact, key, {
410
+ keyManagementAlgorithms: ['dir'],
411
+ contentEncryptionAlgorithms: ['A256GCM'],
412
+ });
413
+ return plaintext;
414
+ }
415
+ catch {
416
+ throw new strict_json_1.ControlFormatError('JWE authentication failed');
417
+ }
418
+ }
419
+ /**
420
+ * verifyReplyJws checks the §4.1 inner JWS against the PINNED devSig and
421
+ * returns its payload bytes.
422
+ */
423
+ async function verifyReplyJws(jws, devSig) {
424
+ const parts = jws.split('.');
425
+ if (parts.length !== 3) {
426
+ throw new strict_json_1.ControlFormatError('compact JWS shape');
427
+ }
428
+ const header = parseHeader(parts[0], { alg: 'ES256', kid: null, typ: TYP_RES_JWS });
429
+ if (!(0, jwk_1.isThumbprint)(header.kid) || header.kid !== (0, jwk_1.thumbprint)(devSig)) {
430
+ throw new strict_json_1.ControlFormatError('reply kid is not the pinned devSig');
431
+ }
432
+ // A DER, truncated or padded signature is never r||s (§3.2).
433
+ if ((0, strict_json_1.b64uDecode)(parts[2]).length !== ES256_SIG_LEN) {
434
+ throw new strict_json_1.ControlFormatError('reply signature length');
435
+ }
436
+ // Decode the payload segment strictly ourselves (§1: no padding, no
437
+ // non-zero trailing bits): jose's decoder is lenient, and two segments
438
+ // that decode to the same claims must not both be accepted.
439
+ const strictPayload = (0, strict_json_1.b64uDecode)(parts[1]);
440
+ let payload;
441
+ try {
442
+ const key = await (0, jose_1.importJWK)({ ...(0, jwk_1.publicOf)(devSig) }, 'ES256');
443
+ ({ payload } = await (0, jose_1.compactVerify)(jws, key, { algorithms: ['ES256'] }));
444
+ }
445
+ catch {
446
+ throw new strict_json_1.ControlFormatError('reply signature');
447
+ }
448
+ if (Buffer.compare(Buffer.from(payload), Buffer.from(strictPayload)) !== 0) {
449
+ throw new strict_json_1.ControlFormatError('reply payload decoding mismatch');
450
+ }
451
+ return strictPayload;
452
+ }
453
+ /**
454
+ * parseReplyClaims applies the §4.1 closed member set; `code` is present
455
+ * exactly when `ok` is false.
456
+ */
457
+ function parseReplyClaims(payload) {
458
+ const obj = (0, strict_json_1.strictObject)((0, strict_json_1.parseStrictJson)(payload), ['v', 'dev', 'gen', 'ctl', 'rid', 'iat', 'ok'], ['code', 'res'], 'reply claims');
459
+ if ((0, strict_json_1.uintMember)(obj, 'v') !== 1) {
460
+ throw new strict_json_1.ControlFormatError('reply v');
461
+ }
462
+ const ok = (0, strict_json_1.boolMember)(obj, 'ok');
463
+ const hasCode = Object.prototype.hasOwnProperty.call(obj, 'code');
464
+ if (hasCode === ok) {
465
+ throw new strict_json_1.ControlFormatError('reply code must be present exactly when ok is false');
466
+ }
467
+ const claims = {
468
+ v: 1,
469
+ dev: (0, strict_json_1.stringMember)(obj, 'dev'),
470
+ gen: (0, strict_json_1.stringMember)(obj, 'gen'),
471
+ ctl: (0, strict_json_1.stringMember)(obj, 'ctl'),
472
+ rid: (0, strict_json_1.stringMember)(obj, 'rid'),
473
+ iat: (0, strict_json_1.uintMember)(obj, 'iat'),
474
+ ok,
475
+ };
476
+ if (hasCode) {
477
+ claims.code = (0, strict_json_1.stringMember)(obj, 'code');
478
+ }
479
+ if (Object.prototype.hasOwnProperty.call(obj, 'res')) {
480
+ claims.res = (0, strict_json_1.toPlainJson)(obj.res);
481
+ }
482
+ return claims;
483
+ }
484
+ /**
485
+ * openReply is §4.1-4.2 in full: exact headers, decryption with the request's
486
+ * rk, ES256 against the pinned devSig, strict claims, and the dev/gen/ctl/rid
487
+ * binding. Any failure means "no valid reply"; a reply is never trusted
488
+ * partially, so the caller gets either verified claims or an exception.
489
+ *
490
+ * @param compact - Reply compact JWE
491
+ * @param rk - Reply key from the request
492
+ * @param devSig - Pinned device signing key
493
+ * @param bind - The request's dev/gen/ctl/rid
494
+ * @returns Verified claims
495
+ * @throws {ControlFormatError} On any failure
496
+ */
497
+ async function openReply(compact, rk, devSig, bind) {
498
+ splitJwe(compact, { alg: 'dir', enc: 'A256GCM', typ: TYP_RES_JWE, cty: TYP_RES_JWS });
499
+ const jws = new TextDecoder().decode(await decryptDir(compact, rk));
500
+ const claims = parseReplyClaims(await verifyReplyJws(jws, devSig));
501
+ if (claims.dev !== bind.dev ||
502
+ claims.gen !== bind.gen ||
503
+ claims.ctl !== bind.ctl ||
504
+ claims.rid !== bind.rid) {
505
+ throw new strict_json_1.ControlFormatError('reply is not bound to this request');
506
+ }
507
+ return claims;
508
+ }
509
+ /**
510
+ * openNotification is the controller side of §9: exact header with
511
+ * kid = our controllerId, decryption under nk, the closed claim set, ctl equal
512
+ * to kid, and dev/gen equal to the pins. Notifications are NOT signed; the
513
+ * caller must only use them to inform UI and never act on one automatically.
514
+ *
515
+ * @param compact - Notification compact JWE
516
+ * @param ctl - This controller's controllerId
517
+ * @param nk - This controller's notification key
518
+ * @param pins - The pinned dev/gen; omit only in conformance tests
519
+ * @returns Claims
520
+ * @throws {ControlFormatError} On any failure
521
+ */
522
+ async function openNotification(compact, ctl, nk, pins) {
523
+ splitJwe(compact, { alg: 'dir', enc: 'A256GCM', typ: TYP_NTF_JWE, kid: ctl });
524
+ const obj = (0, strict_json_1.strictObject)((0, strict_json_1.parseStrictJson)(await decryptDir(compact, nk)), ['v', 'dev', 'gen', 'ctl', 'ep', 'seq', 'iat', 'type', 'body'], [], 'notification claims');
525
+ if ((0, strict_json_1.uintMember)(obj, 'v') !== 1) {
526
+ throw new strict_json_1.ControlFormatError('notification v');
527
+ }
528
+ const claims = {
529
+ v: 1,
530
+ dev: (0, strict_json_1.stringMember)(obj, 'dev'),
531
+ gen: (0, strict_json_1.stringMember)(obj, 'gen'),
532
+ ctl: (0, strict_json_1.stringMember)(obj, 'ctl'),
533
+ ep: (0, strict_json_1.stringMember)(obj, 'ep'),
534
+ seq: (0, strict_json_1.uintMember)(obj, 'seq'),
535
+ iat: (0, strict_json_1.uintMember)(obj, 'iat'),
536
+ type: (0, strict_json_1.stringMember)(obj, 'type'),
537
+ body: (0, strict_json_1.toPlainJson)(obj.body),
538
+ };
539
+ if (claims.ctl !== ctl) {
540
+ throw new strict_json_1.ControlFormatError('notification ctl does not match kid');
541
+ }
542
+ // §9: ep is b64u(8 random bytes), strict (no padding), as in the Go reference.
543
+ if (!(0, strict_json_1.isB64uOfLength)(claims.ep, 8)) {
544
+ throw new strict_json_1.ControlFormatError('notification ep must be b64u(8 bytes)');
545
+ }
546
+ if (pins && (claims.dev !== pins.dev || claims.gen !== pins.gen)) {
547
+ throw new strict_json_1.ControlFormatError('notification is for another device or generation');
548
+ }
549
+ return claims;
550
+ }
551
+ /**
552
+ * NotificationOrder implements the §9 ordering rule: within one `ep` keep
553
+ * only increasing `seq`; an `ep` never seen before starts a new daemon run and
554
+ * is accepted. `iat` is not used for ordering because device clocks can step.
555
+ */
556
+ class NotificationOrder {
557
+ highest = new Map();
558
+ /**
559
+ * accept reports whether a notification is newer than everything seen in its
560
+ * run, and records it when it is.
561
+ *
562
+ * @param n - Opened notification claims
563
+ * @returns True when the notification should be shown
564
+ */
565
+ accept(n) {
566
+ const seen = this.highest.get(n.ep);
567
+ if (seen !== undefined && n.seq <= seen) {
568
+ return false;
569
+ }
570
+ this.highest.set(n.ep, n.seq);
571
+ return true;
572
+ }
573
+ }
574
+ exports.NotificationOrder = NotificationOrder;
575
+ /** envelopeCastBody is the §10 LAN and legacy-relayer request body. */
576
+ function envelopeCastBody(compact) {
577
+ return { command: 'ff1Envelope', request: { v: 1, jwe: compact } };
578
+ }