@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,391 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Owner-initiated registration (ffos-user docs/ff1-control-v1.md §5.6), the
|
|
4
|
+
* controller side: the `ff1-ctl:1|…` controller QR ff-cli shows, and the
|
|
5
|
+
* sealed registration result an owner's app sends back over the pairing
|
|
6
|
+
* channel.
|
|
7
|
+
*
|
|
8
|
+
* The result is the pin source for the device profile (§14.1), so it is
|
|
9
|
+
* checked exactly like the Go reference (control/registration.go
|
|
10
|
+
* OpenRegistration) and nothing in it is trusted before every check passed:
|
|
11
|
+
* closed member sets, the channel and pairing secret from our own QR, `ctl`
|
|
12
|
+
* equal to our own key, and the configured device. A message that fails any
|
|
13
|
+
* check is not an answer; the caller keeps waiting for a genuine one.
|
|
14
|
+
*
|
|
15
|
+
* Cryptography is node:crypto (P-256 ECDH, HKDF-SHA256, AES-256-GCM). The
|
|
16
|
+
* fixtures in tests/fixtures/ff1-control-v1 pin every byte of it.
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.RegistrationForOtherDeviceError = exports.REGISTRATION_SEALED_TYPE = exports.REGISTRATION_TYPE = exports.CONTROLLER_QR_PREFIX = void 0;
|
|
20
|
+
exports.isValidChannelId = isValidChannelId;
|
|
21
|
+
exports.generatePairingMaterial = generatePairingMaterial;
|
|
22
|
+
exports.controllerQrString = controllerQrString;
|
|
23
|
+
exports.controllerQrCtl = controllerQrCtl;
|
|
24
|
+
exports.parseControllerQr = parseControllerQr;
|
|
25
|
+
exports.sealRegistration = sealRegistration;
|
|
26
|
+
exports.openRegistration = openRegistration;
|
|
27
|
+
const node_crypto_1 = require("node:crypto");
|
|
28
|
+
const codec_1 = require("./codec");
|
|
29
|
+
const jwk_1 = require("./jwk");
|
|
30
|
+
const strict_json_1 = require("./strict-json");
|
|
31
|
+
/** CONTROLLER_QR_PREFIX starts every controller QR payload (§5.6.1). */
|
|
32
|
+
exports.CONTROLLER_QR_PREFIX = 'ff1-ctl:1|';
|
|
33
|
+
/** REGISTRATION_TYPE is the plaintext result's type. */
|
|
34
|
+
exports.REGISTRATION_TYPE = 'ff1_controller_registration';
|
|
35
|
+
/** REGISTRATION_SEALED_TYPE is the wire message's type. */
|
|
36
|
+
exports.REGISTRATION_SEALED_TYPE = 'ff1_controller_registration_sealed';
|
|
37
|
+
const REGISTRATION_LABEL = 'ff1-controller-registration/v1';
|
|
38
|
+
const PS_LEN = 16;
|
|
39
|
+
const GCM_IV_LEN = 12;
|
|
40
|
+
const GCM_TAG_LEN = 16;
|
|
41
|
+
const SEC1_POINT_LEN = 65;
|
|
42
|
+
const COORD_LEN = 32;
|
|
43
|
+
// The label is the last of the nine fields after the prefix and may itself
|
|
44
|
+
// contain "|", so the payload is split at most nine times.
|
|
45
|
+
const CONTROLLER_QR_FIELDS = 9;
|
|
46
|
+
const CHANNEL_ID_PATTERN = /^[A-Za-z0-9_-]{16,64}$/;
|
|
47
|
+
const MAX_LABEL_CHARS = 64;
|
|
48
|
+
/**
|
|
49
|
+
* PROTOCOL_UNSAFE_LABEL is exactly the device's label rule (Go
|
|
50
|
+
* control/store.go unsafeLabelCharacters). It judges labels and device names
|
|
51
|
+
* this module READS, so a genuine result is never refused over a character
|
|
52
|
+
* the device accepts; labels ff-cli sends use the stricter codec.labelProblem.
|
|
53
|
+
*/
|
|
54
|
+
const PROTOCOL_UNSAFE_LABEL = /[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u202a-\u202e\u2060\u2066-\u2069\u2028\u2029\ufeff]/u;
|
|
55
|
+
/**
|
|
56
|
+
* RegistrationForOtherDeviceError is a result that passed the channel,
|
|
57
|
+
* pairing-secret and ctl checks (so it came from someone holding this QR,
|
|
58
|
+
* normally the owner) but names another FF1 than the configured one.
|
|
59
|
+
*/
|
|
60
|
+
class RegistrationForOtherDeviceError extends strict_json_1.ControlFormatError {
|
|
61
|
+
dev;
|
|
62
|
+
constructor(dev) {
|
|
63
|
+
super('registration result is for another device');
|
|
64
|
+
this.dev = dev;
|
|
65
|
+
this.name = 'RegistrationForOtherDeviceError';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
exports.RegistrationForOtherDeviceError = RegistrationForOtherDeviceError;
|
|
69
|
+
/**
|
|
70
|
+
* isValidChannelId reports whether a pairing channel ID is valid for the
|
|
71
|
+
* controller QR (§5.6.1: 16-64 characters of [A-Za-z0-9_-]). The relay
|
|
72
|
+
* accepts 1-128, so clients check this themselves.
|
|
73
|
+
*
|
|
74
|
+
* @param channelId - Candidate channel ID
|
|
75
|
+
* @returns True when the QR may carry it
|
|
76
|
+
*/
|
|
77
|
+
function isValidChannelId(channelId) {
|
|
78
|
+
return CHANNEL_ID_PATTERN.test(channelId);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* isProtocolLabel is the §5 label rule as the device applies it: 1-64
|
|
82
|
+
* characters, none of the control or invisible formatting characters.
|
|
83
|
+
*
|
|
84
|
+
* @param value - Candidate label or device name
|
|
85
|
+
* @returns True when the device would accept it
|
|
86
|
+
*/
|
|
87
|
+
function isProtocolLabel(value) {
|
|
88
|
+
const length = [...value].length;
|
|
89
|
+
return length >= 1 && length <= MAX_LABEL_CHARS && !PROTOCOL_UNSAFE_LABEL.test(value);
|
|
90
|
+
}
|
|
91
|
+
/** sec1Of renders a public JWK as b64u(0x04 || X || Y). */
|
|
92
|
+
function sec1Of(key) {
|
|
93
|
+
return (0, strict_json_1.b64uEncode)(Buffer.concat([
|
|
94
|
+
Buffer.from([4]),
|
|
95
|
+
Buffer.from((0, strict_json_1.b64uDecode)(key.x)),
|
|
96
|
+
Buffer.from((0, strict_json_1.b64uDecode)(key.y)),
|
|
97
|
+
]));
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* parseSec1Point validates b64u of an uncompressed P-256 point; compressed
|
|
101
|
+
* and off-curve points are refused (parsePublicJwk runs the on-curve check).
|
|
102
|
+
*/
|
|
103
|
+
function parseSec1Point(value, what) {
|
|
104
|
+
let bytes;
|
|
105
|
+
try {
|
|
106
|
+
bytes = (0, strict_json_1.b64uDecode)(value);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
bytes = new Uint8Array();
|
|
110
|
+
}
|
|
111
|
+
if (bytes.length !== SEC1_POINT_LEN || bytes[0] !== 4) {
|
|
112
|
+
throw new strict_json_1.ControlFormatError(`${what} must be b64u of a 65-byte uncompressed P-256 point`);
|
|
113
|
+
}
|
|
114
|
+
return (0, jwk_1.parsePublicJwk)({
|
|
115
|
+
kty: 'EC',
|
|
116
|
+
crv: 'P-256',
|
|
117
|
+
x: (0, strict_json_1.b64uEncode)(bytes.subarray(1, 1 + COORD_LEN)),
|
|
118
|
+
y: (0, strict_json_1.b64uEncode)(bytes.subarray(1 + COORD_LEN)),
|
|
119
|
+
}, what);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* generatePairingMaterial draws what one pairing attempt shows: a fresh
|
|
123
|
+
* minter key, a 16-byte pairing secret and a random channel ID, bound to the
|
|
124
|
+
* controller keys and label the owner will register.
|
|
125
|
+
*
|
|
126
|
+
* @param keys - Controller keys to register
|
|
127
|
+
* @param label - Label the owner sees (already validated)
|
|
128
|
+
* @param channelId - Pairing channel ID (default: a fresh random UUID)
|
|
129
|
+
* @returns The QR fields and the minter private key
|
|
130
|
+
*/
|
|
131
|
+
function generatePairingMaterial(keys, label, channelId = (0, node_crypto_1.randomUUID)()) {
|
|
132
|
+
const minter = (0, jwk_1.generatePrivateJwk)();
|
|
133
|
+
return {
|
|
134
|
+
minter,
|
|
135
|
+
qr: {
|
|
136
|
+
channelId,
|
|
137
|
+
minterKey: sec1Of(minter),
|
|
138
|
+
sig: (0, jwk_1.publicOf)(keys.sig),
|
|
139
|
+
enc: (0, jwk_1.publicOf)(keys.enc),
|
|
140
|
+
ps: (0, strict_json_1.b64uEncode)((0, node_crypto_1.randomBytes)(PS_LEN)),
|
|
141
|
+
platform: 'cli',
|
|
142
|
+
label,
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* controllerQrString renders the QR payload.
|
|
148
|
+
*
|
|
149
|
+
* @param qr - QR fields
|
|
150
|
+
* @returns `ff1-ctl:1|channelId|minterKey|sig.x|sig.y|enc.x|enc.y|ps|platform|label`
|
|
151
|
+
*/
|
|
152
|
+
function controllerQrString(qr) {
|
|
153
|
+
return (exports.CONTROLLER_QR_PREFIX +
|
|
154
|
+
[
|
|
155
|
+
qr.channelId,
|
|
156
|
+
qr.minterKey,
|
|
157
|
+
qr.sig.x,
|
|
158
|
+
qr.sig.y,
|
|
159
|
+
qr.enc.x,
|
|
160
|
+
qr.enc.y,
|
|
161
|
+
qr.ps,
|
|
162
|
+
qr.platform,
|
|
163
|
+
qr.label,
|
|
164
|
+
].join('|'));
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* controllerQrCtl is the controller ID the QR's sig key has once registered.
|
|
168
|
+
*
|
|
169
|
+
* @param qr - QR fields
|
|
170
|
+
* @returns thumb(sig)
|
|
171
|
+
*/
|
|
172
|
+
function controllerQrCtl(qr) {
|
|
173
|
+
return (0, jwk_1.thumbprint)(qr.sig);
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* parseControllerQr parses and validates a controller QR payload (§5.6.1).
|
|
177
|
+
*
|
|
178
|
+
* @param value - QR text
|
|
179
|
+
* @returns The QR fields
|
|
180
|
+
* @throws {ControlFormatError} On anything but a valid payload
|
|
181
|
+
*/
|
|
182
|
+
function parseControllerQr(value) {
|
|
183
|
+
if (!value.startsWith(exports.CONTROLLER_QR_PREFIX)) {
|
|
184
|
+
throw new strict_json_1.ControlFormatError('not an ff1-ctl:1 payload');
|
|
185
|
+
}
|
|
186
|
+
const fields = [];
|
|
187
|
+
let rest = value.slice(exports.CONTROLLER_QR_PREFIX.length);
|
|
188
|
+
while (fields.length < CONTROLLER_QR_FIELDS - 1) {
|
|
189
|
+
const cut = rest.indexOf('|');
|
|
190
|
+
if (cut < 0) {
|
|
191
|
+
throw new strict_json_1.ControlFormatError('controller QR has too few fields');
|
|
192
|
+
}
|
|
193
|
+
fields.push(rest.slice(0, cut));
|
|
194
|
+
rest = rest.slice(cut + 1);
|
|
195
|
+
}
|
|
196
|
+
fields.push(rest);
|
|
197
|
+
const [channelId, minterKey, sigX, sigY, encX, encY, ps, platform, label] = fields;
|
|
198
|
+
if (!CHANNEL_ID_PATTERN.test(channelId)) {
|
|
199
|
+
throw new strict_json_1.ControlFormatError('controller QR channelId is invalid');
|
|
200
|
+
}
|
|
201
|
+
parseSec1Point(minterKey, 'controller QR minterKey');
|
|
202
|
+
const sig = (0, jwk_1.parsePublicJwk)({ kty: 'EC', crv: 'P-256', x: sigX, y: sigY }, 'controller QR sig');
|
|
203
|
+
const enc = (0, jwk_1.parsePublicJwk)({ kty: 'EC', crv: 'P-256', x: encX, y: encY }, 'controller QR enc');
|
|
204
|
+
let psBytes;
|
|
205
|
+
try {
|
|
206
|
+
psBytes = (0, strict_json_1.b64uDecode)(ps);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
psBytes = new Uint8Array();
|
|
210
|
+
}
|
|
211
|
+
if (psBytes.length !== PS_LEN) {
|
|
212
|
+
throw new strict_json_1.ControlFormatError('controller QR ps must be b64u of 16 bytes');
|
|
213
|
+
}
|
|
214
|
+
if (!codec_1.CONTROL_PLATFORMS.includes(platform)) {
|
|
215
|
+
throw new strict_json_1.ControlFormatError('controller QR platform is invalid');
|
|
216
|
+
}
|
|
217
|
+
if (!isProtocolLabel(label)) {
|
|
218
|
+
throw new strict_json_1.ControlFormatError('controller QR label is invalid');
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
channelId,
|
|
222
|
+
minterKey,
|
|
223
|
+
sig,
|
|
224
|
+
enc,
|
|
225
|
+
ps,
|
|
226
|
+
platform: platform,
|
|
227
|
+
label,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
/** registrationKey derives the AES-256-GCM key and AAD of §5.6.2. */
|
|
231
|
+
function registrationKey(own, peer, channelId, minterKey, epk) {
|
|
232
|
+
const z = (0, node_crypto_1.diffieHellman)({
|
|
233
|
+
privateKey: (0, node_crypto_1.createPrivateKey)({ key: { ...own }, format: 'jwk' }),
|
|
234
|
+
publicKey: (0, node_crypto_1.createPublicKey)({ key: { ...(0, jwk_1.publicOf)(peer) }, format: 'jwk' }),
|
|
235
|
+
});
|
|
236
|
+
const salt = (0, node_crypto_1.createHash)('sha256')
|
|
237
|
+
.update(exports.CONTROLLER_QR_PREFIX + channelId, 'utf8')
|
|
238
|
+
.digest();
|
|
239
|
+
const key = Buffer.from((0, node_crypto_1.hkdfSync)('sha256', z, salt, REGISTRATION_LABEL, 32));
|
|
240
|
+
const aad = Buffer.from(`${REGISTRATION_LABEL}|${channelId}|${minterKey}|${epk}`, 'utf8');
|
|
241
|
+
return { key, aad };
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* sealRegistration is the owner side of §5.6.2, used by tests and as the
|
|
245
|
+
* reference for the app: encrypt an exact plaintext to the QR's minter key
|
|
246
|
+
* under the ephemeral key with iv (12 bytes, never reused for one epk).
|
|
247
|
+
*
|
|
248
|
+
* @param qr - The controller QR
|
|
249
|
+
* @param plaintext - Exact result JSON
|
|
250
|
+
* @param ephemeral - Owner's fresh P-256 key
|
|
251
|
+
* @param iv - 12-byte nonce
|
|
252
|
+
* @returns The wire message JSON
|
|
253
|
+
*/
|
|
254
|
+
function sealRegistration(qr, plaintext, ephemeral = (0, jwk_1.generatePrivateJwk)(), iv = (0, node_crypto_1.randomBytes)(GCM_IV_LEN)) {
|
|
255
|
+
if (iv.length !== GCM_IV_LEN) {
|
|
256
|
+
throw new Error('registration iv must be 12 bytes');
|
|
257
|
+
}
|
|
258
|
+
const minter = parseSec1Point(qr.minterKey, 'minterKey');
|
|
259
|
+
const epk = sec1Of(ephemeral);
|
|
260
|
+
const { key, aad } = registrationKey(ephemeral, minter, qr.channelId, qr.minterKey, epk);
|
|
261
|
+
const cipher = (0, node_crypto_1.createCipheriv)('aes-256-gcm', key, iv);
|
|
262
|
+
cipher.setAAD(aad);
|
|
263
|
+
const ct = Buffer.concat([
|
|
264
|
+
cipher.update(typeof plaintext === 'string' ? Buffer.from(plaintext, 'utf8') : plaintext),
|
|
265
|
+
cipher.final(),
|
|
266
|
+
cipher.getAuthTag(),
|
|
267
|
+
]);
|
|
268
|
+
return `{"v":1,"type":${JSON.stringify(exports.REGISTRATION_SEALED_TYPE)},"epk":${JSON.stringify(epk)},"iv":${JSON.stringify((0, strict_json_1.b64uEncode)(iv))},"ct":${JSON.stringify((0, strict_json_1.b64uEncode)(ct))}}`;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* openRegistration is the controller side of §5.6.3: decrypt a sealed result
|
|
272
|
+
* with the minter key behind qr and check every binding, in the spec's order.
|
|
273
|
+
*
|
|
274
|
+
* @param material - The QR this controller showed and its minter key
|
|
275
|
+
* @param wire - The wire message (relay frame payload)
|
|
276
|
+
* @param wantDev - The configured device ID; profile.dev must equal it
|
|
277
|
+
* @param now - Unix seconds, bounding expiresAt
|
|
278
|
+
* @returns The verified result
|
|
279
|
+
* @throws {ControlFormatError} When the message is not a genuine result for this QR
|
|
280
|
+
*/
|
|
281
|
+
function openRegistration(material, wire, wantDev, now) {
|
|
282
|
+
const { qr, minter } = material;
|
|
283
|
+
if (sec1Of(minter) !== qr.minterKey) {
|
|
284
|
+
throw new strict_json_1.ControlFormatError('minter key does not match the QR');
|
|
285
|
+
}
|
|
286
|
+
const sealed = (0, strict_json_1.strictObject)((0, strict_json_1.parseStrictJson)(wire), ['v', 'type', 'epk', 'iv', 'ct'], [], 'sealed registration');
|
|
287
|
+
if ((0, strict_json_1.uintMember)(sealed, 'v') !== 1 || sealed.type !== exports.REGISTRATION_SEALED_TYPE) {
|
|
288
|
+
throw new strict_json_1.ControlFormatError('sealed registration has the wrong v or type');
|
|
289
|
+
}
|
|
290
|
+
const epkB64 = (0, strict_json_1.stringMember)(sealed, 'epk');
|
|
291
|
+
const epk = parseSec1Point(epkB64, 'sealed registration epk');
|
|
292
|
+
const iv = (0, strict_json_1.b64uDecode)((0, strict_json_1.stringMember)(sealed, 'iv'));
|
|
293
|
+
if (iv.length !== GCM_IV_LEN) {
|
|
294
|
+
throw new strict_json_1.ControlFormatError('sealed registration iv must be 12 bytes');
|
|
295
|
+
}
|
|
296
|
+
const ct = (0, strict_json_1.b64uDecode)((0, strict_json_1.stringMember)(sealed, 'ct'));
|
|
297
|
+
if (ct.length < GCM_TAG_LEN) {
|
|
298
|
+
throw new strict_json_1.ControlFormatError('sealed registration does not decrypt');
|
|
299
|
+
}
|
|
300
|
+
const { key, aad } = registrationKey(minter, epk, qr.channelId, qr.minterKey, epkB64);
|
|
301
|
+
let plaintext;
|
|
302
|
+
try {
|
|
303
|
+
const decipher = (0, node_crypto_1.createDecipheriv)('aes-256-gcm', key, iv);
|
|
304
|
+
decipher.setAAD(aad);
|
|
305
|
+
decipher.setAuthTag(ct.subarray(ct.length - GCM_TAG_LEN));
|
|
306
|
+
plaintext = Buffer.concat([
|
|
307
|
+
decipher.update(ct.subarray(0, ct.length - GCM_TAG_LEN)),
|
|
308
|
+
decipher.final(),
|
|
309
|
+
]);
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
throw new strict_json_1.ControlFormatError('sealed registration does not decrypt');
|
|
313
|
+
}
|
|
314
|
+
return parseRegistrationResult(qr, plaintext, wantDev, now);
|
|
315
|
+
}
|
|
316
|
+
/** parseRegistrationResult checks the decrypted plaintext (§5.6.2, §5.6.3 steps 2-4). */
|
|
317
|
+
function parseRegistrationResult(qr, plaintext, wantDev, now) {
|
|
318
|
+
const base = ['v', 'type', 'channelId', 'ps', 'ctl', 'outcome'];
|
|
319
|
+
const obj = (0, strict_json_1.strictObject)((0, strict_json_1.parseStrictJson)(plaintext), base, ['profile', 'deviceName', 'role', 'expiresAt', 'topicId'], 'registration result');
|
|
320
|
+
if ((0, strict_json_1.uintMember)(obj, 'v') !== 1 || obj.type !== exports.REGISTRATION_TYPE) {
|
|
321
|
+
throw new strict_json_1.ControlFormatError('registration result has the wrong v or type');
|
|
322
|
+
}
|
|
323
|
+
if (obj.channelId !== qr.channelId) {
|
|
324
|
+
throw new strict_json_1.ControlFormatError('registration result is for another channel');
|
|
325
|
+
}
|
|
326
|
+
const ps = (0, strict_json_1.stringMember)(obj, 'ps');
|
|
327
|
+
const psBytes = Buffer.from(ps, 'utf8');
|
|
328
|
+
const wantPs = Buffer.from(qr.ps, 'utf8');
|
|
329
|
+
if (psBytes.length !== wantPs.length || !(0, node_crypto_1.timingSafeEqual)(psBytes, wantPs)) {
|
|
330
|
+
throw new strict_json_1.ControlFormatError('registration result ps does not match');
|
|
331
|
+
}
|
|
332
|
+
const ctl = controllerQrCtl(qr);
|
|
333
|
+
if (obj.ctl !== ctl) {
|
|
334
|
+
throw new strict_json_1.ControlFormatError('registration result is for another controller');
|
|
335
|
+
}
|
|
336
|
+
const outcome = (0, strict_json_1.stringMember)(obj, 'outcome');
|
|
337
|
+
const result = {
|
|
338
|
+
v: 1,
|
|
339
|
+
type: exports.REGISTRATION_TYPE,
|
|
340
|
+
channelId: qr.channelId,
|
|
341
|
+
ps,
|
|
342
|
+
ctl,
|
|
343
|
+
outcome: 'rejected',
|
|
344
|
+
};
|
|
345
|
+
if (outcome === 'rejected') {
|
|
346
|
+
if (Object.keys(obj).length !== base.length) {
|
|
347
|
+
throw new strict_json_1.ControlFormatError('a rejected registration carries no grant members');
|
|
348
|
+
}
|
|
349
|
+
return result;
|
|
350
|
+
}
|
|
351
|
+
if (outcome !== 'approved') {
|
|
352
|
+
throw new strict_json_1.ControlFormatError('registration result outcome is invalid');
|
|
353
|
+
}
|
|
354
|
+
for (const member of ['profile', 'deviceName', 'role']) {
|
|
355
|
+
if (!Object.prototype.hasOwnProperty.call(obj, member)) {
|
|
356
|
+
throw new strict_json_1.ControlFormatError(`an approved registration needs ${member}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (!(0, strict_json_1.isPlainObject)(obj.profile) || (0, strict_json_1.uintMember)(obj.profile, 'v') !== 1) {
|
|
360
|
+
throw new strict_json_1.ControlFormatError('registration result profile is invalid');
|
|
361
|
+
}
|
|
362
|
+
const profile = (0, codec_1.parseProfile)(obj.profile);
|
|
363
|
+
if (profile.dev !== wantDev) {
|
|
364
|
+
throw new RegistrationForOtherDeviceError(profile.dev);
|
|
365
|
+
}
|
|
366
|
+
const deviceName = (0, strict_json_1.stringMember)(obj, 'deviceName');
|
|
367
|
+
if (!isProtocolLabel(deviceName)) {
|
|
368
|
+
throw new strict_json_1.ControlFormatError('registration result deviceName is invalid');
|
|
369
|
+
}
|
|
370
|
+
const role = (0, strict_json_1.stringMember)(obj, 'role');
|
|
371
|
+
if (role !== 'owner' && role !== 'controller') {
|
|
372
|
+
throw new strict_json_1.ControlFormatError('registration result role is invalid');
|
|
373
|
+
}
|
|
374
|
+
Object.assign(result, { outcome, profile, deviceName, role });
|
|
375
|
+
if (Object.prototype.hasOwnProperty.call(obj, 'expiresAt')) {
|
|
376
|
+
const expiresAt = (0, strict_json_1.uintMember)(obj, 'expiresAt');
|
|
377
|
+
// An owner grant never expires (§5.5), and an expiry in the past is no grant.
|
|
378
|
+
if (expiresAt <= now || role === 'owner') {
|
|
379
|
+
throw new strict_json_1.ControlFormatError('registration result expiresAt is invalid');
|
|
380
|
+
}
|
|
381
|
+
result.expiresAt = expiresAt;
|
|
382
|
+
}
|
|
383
|
+
if (Object.prototype.hasOwnProperty.call(obj, 'topicId')) {
|
|
384
|
+
const topicId = obj.topicId;
|
|
385
|
+
if (typeof topicId !== 'string' || topicId === '') {
|
|
386
|
+
throw new strict_json_1.ControlFormatError('registration result topicId must be a non-empty string');
|
|
387
|
+
}
|
|
388
|
+
result.topicId = topicId;
|
|
389
|
+
}
|
|
390
|
+
return result;
|
|
391
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The relay v2 opener for the owner-initiated pairing channel
|
|
4
|
+
* (ffos-user docs/ff1-control-v1.md §5.6 step 1). It adapts TemporaryChannel
|
|
5
|
+
* (`/v2/channels/{channelId}/connections`) to the PairingChannel interface
|
|
6
|
+
* that `ff-cli device pair` consumes, and is installed at startup by
|
|
7
|
+
* installRelayPairingChannel when a v2 endpoint is configured.
|
|
8
|
+
*
|
|
9
|
+
* The channel carries no authority: every message is untrusted bytes that
|
|
10
|
+
* registration.ts opens and checks. This adapter only queues them.
|
|
11
|
+
*
|
|
12
|
+
* A relay drop ends the pairing wait (no reconnect: a new connection would
|
|
13
|
+
* get a new connection ID the owner's app does not know); `device pair` says
|
|
14
|
+
* the channel closed and the user shows a new code.
|
|
15
|
+
*
|
|
16
|
+
* Limit: TemporaryChannel does not reassemble `relayChunk` transfers, so a
|
|
17
|
+
* sealed registration result must fit one relay frame (about 32 KiB of
|
|
18
|
+
* payload). Results are about 1 KiB; a chunked one would arrive as fragments
|
|
19
|
+
* that fail to open and are ignored like any other stray message.
|
|
20
|
+
*/
|
|
21
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
22
|
+
if (k2 === undefined) k2 = k;
|
|
23
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
24
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
25
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
26
|
+
}
|
|
27
|
+
Object.defineProperty(o, k2, desc);
|
|
28
|
+
}) : (function(o, m, k, k2) {
|
|
29
|
+
if (k2 === undefined) k2 = k;
|
|
30
|
+
o[k2] = m[k];
|
|
31
|
+
}));
|
|
32
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
33
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
34
|
+
}) : function(o, v) {
|
|
35
|
+
o["default"] = v;
|
|
36
|
+
});
|
|
37
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
38
|
+
var ownKeys = function(o) {
|
|
39
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
40
|
+
var ar = [];
|
|
41
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
42
|
+
return ar;
|
|
43
|
+
};
|
|
44
|
+
return ownKeys(o);
|
|
45
|
+
};
|
|
46
|
+
return function (mod) {
|
|
47
|
+
if (mod && mod.__esModule) return mod;
|
|
48
|
+
var result = {};
|
|
49
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
50
|
+
__setModuleDefault(result, mod);
|
|
51
|
+
return result;
|
|
52
|
+
};
|
|
53
|
+
})();
|
|
54
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
55
|
+
exports.relayPairingChannelOpener = relayPairingChannelOpener;
|
|
56
|
+
exports.installRelayPairingChannel = installRelayPairingChannel;
|
|
57
|
+
const config_1 = require("../../config");
|
|
58
|
+
const logger = __importStar(require("../../logger"));
|
|
59
|
+
const temporary_channel_1 = require("../temporary-channel");
|
|
60
|
+
const pairing_1 = require("./pairing");
|
|
61
|
+
const pairing_channel_1 = require("./pairing-channel");
|
|
62
|
+
const registration_1 = require("./registration");
|
|
63
|
+
/**
|
|
64
|
+
* PAIRING_CHANNEL_TIMEOUT_MS is TemporaryChannel's own deadline. It only has
|
|
65
|
+
* to outlast the longest `device pair --timeout`; the caller's AbortSignal
|
|
66
|
+
* is what actually ends the wait.
|
|
67
|
+
*/
|
|
68
|
+
const PAIRING_CHANNEL_TIMEOUT_MS = pairing_1.MAX_PAIR_TIMEOUT_MS + 60_000;
|
|
69
|
+
/**
|
|
70
|
+
* MAX_QUEUED_MESSAGES bounds unread messages. Anyone who saw the QR can write
|
|
71
|
+
* to the channel; the pairing loop drains each message at once, so only a
|
|
72
|
+
* flood fills the queue. Then NEW messages are dropped: evicting the oldest
|
|
73
|
+
* would let a flood push out an owner's result that is already queued.
|
|
74
|
+
*/
|
|
75
|
+
const MAX_QUEUED_MESSAGES = 64;
|
|
76
|
+
/**
|
|
77
|
+
* MessageQueue is a single-consumer async iterable of channel payloads that
|
|
78
|
+
* ends when the channel closes.
|
|
79
|
+
*/
|
|
80
|
+
class MessageQueue {
|
|
81
|
+
items = [];
|
|
82
|
+
waiter;
|
|
83
|
+
ended = false;
|
|
84
|
+
/** push queues one payload, or hands it to a waiting reader. */
|
|
85
|
+
push(item) {
|
|
86
|
+
if (this.ended) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (this.waiter) {
|
|
90
|
+
const waiter = this.waiter;
|
|
91
|
+
this.waiter = undefined;
|
|
92
|
+
waiter({ value: item, done: false });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (this.items.length < MAX_QUEUED_MESSAGES) {
|
|
96
|
+
this.items.push(item);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** end finishes the iteration once queued payloads are read. */
|
|
100
|
+
end() {
|
|
101
|
+
this.ended = true;
|
|
102
|
+
if (this.waiter && !this.items.length) {
|
|
103
|
+
const waiter = this.waiter;
|
|
104
|
+
this.waiter = undefined;
|
|
105
|
+
waiter({ value: undefined, done: true });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
[Symbol.asyncIterator]() {
|
|
109
|
+
return {
|
|
110
|
+
next: () => {
|
|
111
|
+
const item = this.items.shift();
|
|
112
|
+
if (item) {
|
|
113
|
+
return Promise.resolve({ value: item, done: false });
|
|
114
|
+
}
|
|
115
|
+
if (this.ended) {
|
|
116
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
117
|
+
}
|
|
118
|
+
return new Promise((resolve) => {
|
|
119
|
+
this.waiter = resolve;
|
|
120
|
+
});
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* relayPairingChannelOpener returns an opener that joins the pairing channel
|
|
127
|
+
* on the relay v2 endpoint.
|
|
128
|
+
*
|
|
129
|
+
* The payload handler is attached before `ready` is awaited, so a result
|
|
130
|
+
* sent right after the relay's hello is not lost. Aborting the signal closes
|
|
131
|
+
* the channel, which ends the message iteration.
|
|
132
|
+
*
|
|
133
|
+
* @param endpoint - Relay v2 origin
|
|
134
|
+
* @param socketFactory - WebSocket constructor seam for tests
|
|
135
|
+
* @returns The opener for setPairingChannelOpener
|
|
136
|
+
*/
|
|
137
|
+
function relayPairingChannelOpener(endpoint, socketFactory) {
|
|
138
|
+
return async (channelId, signal) => {
|
|
139
|
+
if (!(0, registration_1.isValidChannelId)(channelId)) {
|
|
140
|
+
throw new Error('invalid pairing channel ID');
|
|
141
|
+
}
|
|
142
|
+
signal.throwIfAborted();
|
|
143
|
+
const channel = new temporary_channel_1.TemporaryChannel(endpoint, channelId, PAIRING_CHANNEL_TIMEOUT_MS, socketFactory);
|
|
144
|
+
const queue = new MessageQueue();
|
|
145
|
+
// The frame payload is a view into the relay frame; keep a private copy.
|
|
146
|
+
channel.onPayload = (_from, payload) => queue.push(payload.slice());
|
|
147
|
+
channel.onClose = () => queue.end();
|
|
148
|
+
const onAbort = () => channel.close(new Error('Pairing cancelled'));
|
|
149
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
150
|
+
try {
|
|
151
|
+
await channel.ready;
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
signal.removeEventListener('abort', onAbort);
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
return pairingChannel(channel, queue, () => signal.removeEventListener('abort', onAbort));
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/** pairingChannel exposes a joined TemporaryChannel as a receive-only PairingChannel. */
|
|
161
|
+
function pairingChannel(channel, queue, detach) {
|
|
162
|
+
return {
|
|
163
|
+
channelId: channel.channelId,
|
|
164
|
+
messages: () => queue,
|
|
165
|
+
close: async () => {
|
|
166
|
+
detach();
|
|
167
|
+
channel.close();
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* installRelayPairingChannel installs the relay opener when a v2 endpoint is
|
|
173
|
+
* configured (ff1Relayer.v2Endpoint, FF1_RELAY_V2_ENDPOINT, or the default
|
|
174
|
+
* derived from the relayer). Without one, or with an invalid relayer config,
|
|
175
|
+
* `device pair` keeps the default opener, which says pairing is unavailable.
|
|
176
|
+
*
|
|
177
|
+
* @param getConfig - Relayer config source (tests inject one)
|
|
178
|
+
*/
|
|
179
|
+
function installRelayPairingChannel(getConfig = config_1.getFF1RelayerConfig) {
|
|
180
|
+
let endpoint;
|
|
181
|
+
try {
|
|
182
|
+
endpoint = getConfig().v2Endpoint;
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
logger.debug(`Relay pairing channel not installed: ${error.message}`);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (endpoint) {
|
|
189
|
+
(0, pairing_channel_1.setPairingChannelOpener)(relayPairingChannelOpener(endpoint));
|
|
190
|
+
}
|
|
191
|
+
}
|