@feralfile/cli 2.10.1 → 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.
- package/README.md +31 -23
- package/config.json.example +6 -1
- package/dist/index.js +10 -2
- package/dist/package.json +5 -2
- package/dist/src/commands/build.js +40 -0
- package/dist/src/commands/config.js +1 -0
- package/dist/src/commands/device.js +85 -40
- package/dist/src/commands/enrich.js +18 -23
- package/dist/src/commands/find.js +144 -106
- package/dist/src/commands/helpers/device-control.js +937 -0
- package/dist/src/commands/helpers/hidden-alias-help.js +37 -0
- package/dist/src/commands/helpers/playlist-display.js +2 -15
- package/dist/src/commands/helpers/prompt.js +85 -10
- package/dist/src/commands/play.js +162 -84
- package/dist/src/commands/report.js +251 -0
- package/dist/src/commands/setup.js +82 -7
- package/dist/src/commands/status.js +4 -1
- package/dist/src/config.js +256 -6
- package/dist/src/main.js +14 -5
- package/dist/src/utilities/control/codec.js +578 -0
- package/dist/src/utilities/control/control-errors.js +157 -0
- package/dist/src/utilities/control/controller-store.js +964 -0
- package/dist/src/utilities/control/jwk.js +141 -0
- package/dist/src/utilities/control/pairing-channel.js +52 -0
- package/dist/src/utilities/control/pairing.js +416 -0
- package/dist/src/utilities/control/registration.js +391 -0
- package/dist/src/utilities/control/relay-pairing-channel.js +191 -0
- package/dist/src/utilities/control/secure-transport.js +850 -0
- package/dist/src/utilities/control/strict-json.js +376 -0
- package/dist/src/utilities/device-lookup.js +36 -11
- package/dist/src/utilities/device-network.js +129 -0
- package/dist/src/utilities/device-upsert.js +1 -1
- package/dist/src/utilities/feral-file-artwork.js +2 -2
- package/dist/src/utilities/ff1-compatibility.js +87 -11
- package/dist/src/utilities/ff1-device.js +161 -78
- package/dist/src/utilities/ff1-relayer.js +34 -14
- package/dist/src/utilities/handoff-client.js +23 -9
- package/dist/src/utilities/index.js +270 -259
- package/dist/src/utilities/media-url.js +117 -0
- package/dist/src/utilities/neort-marketplace.js +3 -3
- package/dist/src/utilities/nft-indexer.js +12 -376
- package/dist/src/utilities/playlist-builder.js +11 -2
- package/dist/src/utilities/playlist-cast.js +6 -6
- package/dist/src/utilities/playlist-source.js +223 -34
- package/dist/src/utilities/raster-client.js +101 -14
- package/dist/src/utilities/relay-client.js +774 -0
- package/dist/src/utilities/ssh-access.js +192 -58
- package/dist/src/utilities/support-report.js +511 -0
- package/dist/src/utilities/temporary-channel.js +139 -0
- package/dist/src/utilities/token-item.js +428 -0
- package/dist/src/utilities/token-source-contract.js +55 -0
- package/dist/src/utilities/token-source-indexer.js +99 -0
- package/dist/src/utilities/token-source-raster.js +378 -0
- package/dist/src/utilities/token-source.js +48 -0
- package/docs/CONFIGURATION.md +331 -28
- package/docs/EXAMPLES.md +33 -33
- package/docs/PROJECT_SPEC.md +55 -36
- package/docs/README.md +145 -43
- 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
|
+
}
|