@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.
- package/README.md +20 -17
- package/config.json.example +1 -1
- package/dist/index.js +10 -2
- package/dist/package.json +5 -2
- package/dist/src/commands/build.js +1 -1
- package/dist/src/commands/device.js +84 -39
- package/dist/src/commands/enrich.js +2 -2
- package/dist/src/commands/find.js +71 -37
- 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/play.js +39 -26
- package/dist/src/commands/report.js +251 -0
- package/dist/src/commands/setup.js +1 -1
- package/dist/src/config.js +162 -6
- 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-network.js +129 -0
- package/dist/src/utilities/device-upsert.js +1 -1
- 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/neort-marketplace.js +3 -3
- package/dist/src/utilities/playlist-builder.js +3 -3
- package/dist/src/utilities/playlist-cast.js +6 -6
- package/dist/src/utilities/playlist-source.js +7 -7
- package/dist/src/utilities/raster-client.js +1 -1
- 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/docs/CONFIGURATION.md +270 -28
- package/docs/EXAMPLES.md +31 -31
- package/docs/PROJECT_SPEC.md +32 -19
- package/docs/README.md +113 -36
- package/package.json +5 -2
|
@@ -0,0 +1,964 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Controller key store: one JSON file per FF1 at
|
|
4
|
+
* `<config dir>/controllers/<deviceId>.json` (feral-file#3551 M6).
|
|
5
|
+
*
|
|
6
|
+
* The file holds this CLI's private controller keys (ctlSig, ctlEnc), the
|
|
7
|
+
* device profile it pinned from an owner's sealed registration result during
|
|
8
|
+
* `ff-cli device pair` (the ONLY trust root for replies), the verified
|
|
9
|
+
* registration facts, the grant (role, nk, expiry) once enrolled, and the
|
|
10
|
+
* request counter.
|
|
11
|
+
*
|
|
12
|
+
* Invariants a later change must keep:
|
|
13
|
+
* - Mode 0600 in a 0700 directory on POSIX. Windows has no mode bits; there
|
|
14
|
+
* the file relies on the per-user profile ACL (documented caveat).
|
|
15
|
+
* - `ctr` only ever increases, and is persisted BEFORE a request carrying it
|
|
16
|
+
* is sent (nextCounter). A crash after the write and before the send just
|
|
17
|
+
* skips a number, which the device's window tolerates; sending first and
|
|
18
|
+
* crashing before the write would reuse a counter, which the device rejects
|
|
19
|
+
* as `replay`. Allocation is serialized across processes by a lock file.
|
|
20
|
+
* - Every write is tmp + fsync + rename, so a crash never leaves a truncated
|
|
21
|
+
* file (and a truncated file is the one way to lose `ctr`).
|
|
22
|
+
* - Private JWKs are never logged or put into an error message.
|
|
23
|
+
*
|
|
24
|
+
* The OS keyring was rejected as the store: headless Linux has no secret
|
|
25
|
+
* service, and the counter must be written on every request.
|
|
26
|
+
*/
|
|
27
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
28
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
29
|
+
};
|
|
30
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
31
|
+
exports.ControllerFileCorruptError = void 0;
|
|
32
|
+
exports.controllersDir = controllersDir;
|
|
33
|
+
exports.controllerFilePath = controllerFilePath;
|
|
34
|
+
exports.normalizeDeviceId = normalizeDeviceId;
|
|
35
|
+
exports.sameIdentity = sameIdentity;
|
|
36
|
+
exports.isEnrolled = isEnrolled;
|
|
37
|
+
exports.parseControllerRecord = parseControllerRecord;
|
|
38
|
+
exports.loadController = loadController;
|
|
39
|
+
exports.setAsideCorruptController = setAsideCorruptController;
|
|
40
|
+
exports.saveController = saveController;
|
|
41
|
+
exports.createController = createController;
|
|
42
|
+
exports.stagingStore = stagingStore;
|
|
43
|
+
exports.promoteStagedController = promoteStagedController;
|
|
44
|
+
exports.dropStagedController = dropStagedController;
|
|
45
|
+
exports.hasPairingKeys = hasPairingKeys;
|
|
46
|
+
exports.loadPairingKeys = loadPairingKeys;
|
|
47
|
+
exports.pairingKeys = pairingKeys;
|
|
48
|
+
exports.deletePairingKeys = deletePairingKeys;
|
|
49
|
+
exports.deleteController = deleteController;
|
|
50
|
+
exports.recordRegistration = recordRegistration;
|
|
51
|
+
exports.nextCounter = nextCounter;
|
|
52
|
+
exports.skipCounterAhead = skipCounterAhead;
|
|
53
|
+
exports.markEnrolled = markEnrolled;
|
|
54
|
+
exports.recordRelayV2 = recordRelayV2;
|
|
55
|
+
exports.describeController = describeController;
|
|
56
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
57
|
+
const node_crypto_1 = require("node:crypto");
|
|
58
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
59
|
+
const config_1 = require("../../config");
|
|
60
|
+
const codec_1 = require("./codec");
|
|
61
|
+
const jwk_1 = require("./jwk");
|
|
62
|
+
const strict_json_1 = require("./strict-json");
|
|
63
|
+
const FILE_MODE = 0o600;
|
|
64
|
+
const DIR_MODE = 0o700;
|
|
65
|
+
const DEVICE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
66
|
+
const LOCK_RETRY_MS = 25;
|
|
67
|
+
const LOCK_TIMEOUT_MS = 5_000;
|
|
68
|
+
// A lock is held for one small synchronous file write. A lock whose recorded
|
|
69
|
+
// holder PID is dead AND that is at least LOCK_MIN_STALE_MS old is stale; the
|
|
70
|
+
// age floor keeps a breaker from racing a holder that has only just written
|
|
71
|
+
// its token. A lock without a readable PID (the holder died between create
|
|
72
|
+
// and write) is stale after LOCK_UNOWNED_STALE_MS. A lock whose PID is alive
|
|
73
|
+
// (or was reused) is never broken automatically: the timeout error names the
|
|
74
|
+
// file for the user to remove.
|
|
75
|
+
const LOCK_MIN_STALE_MS = 1_000;
|
|
76
|
+
const LOCK_UNOWNED_STALE_MS = 30_000;
|
|
77
|
+
/**
|
|
78
|
+
* controllersDir returns `<XDG_CONFIG_HOME or ~/.config>/ff-cli/controllers`,
|
|
79
|
+
* next to the user config.json that getConfigPaths resolves.
|
|
80
|
+
*
|
|
81
|
+
* @param options - Optional directory override
|
|
82
|
+
* @returns Absolute directory path
|
|
83
|
+
*/
|
|
84
|
+
function controllersDir(options = {}) {
|
|
85
|
+
return options.dir ?? node_path_1.default.join(node_path_1.default.dirname((0, config_1.getConfigPaths)().userPath), 'controllers');
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* controllerFilePath returns the file for one device. The device ID becomes
|
|
89
|
+
* a file name, so it is restricted to a safe alphabet (FF1-XXXXXXXX fits).
|
|
90
|
+
*
|
|
91
|
+
* @param deviceId - FF1 device ID
|
|
92
|
+
* @param options - Optional directory override
|
|
93
|
+
* @returns Absolute file path
|
|
94
|
+
* @throws {Error} When the device ID is not file-name safe
|
|
95
|
+
*/
|
|
96
|
+
function controllerFilePath(deviceId, options = {}) {
|
|
97
|
+
const normalized = normalizeDeviceId(deviceId);
|
|
98
|
+
if (!DEVICE_ID_PATTERN.test(normalized)) {
|
|
99
|
+
throw new Error(`FF1 device ID "${deviceId}" cannot name a controller file`);
|
|
100
|
+
}
|
|
101
|
+
return node_path_1.default.join(controllersDir(options), `${normalized}.json`);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* normalizeDeviceId is the one spelling of a device ID the store uses: trimmed
|
|
105
|
+
* and upper case, matching the topic store and the device's own hostname
|
|
106
|
+
* (FF1-XXXXXXXX). A hand-edited config.json may carry `ff1-abc`; without this
|
|
107
|
+
* the file written under the profile's upper-case `dev` would not be found.
|
|
108
|
+
*
|
|
109
|
+
* @param deviceId - Configured or profile device ID
|
|
110
|
+
* @returns Normalized ID
|
|
111
|
+
*/
|
|
112
|
+
function normalizeDeviceId(deviceId) {
|
|
113
|
+
return deviceId.trim().toUpperCase();
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* sameIdentity reports whether two profiles are the same control identity.
|
|
117
|
+
*
|
|
118
|
+
* @param a - First profile
|
|
119
|
+
* @param b - Second profile
|
|
120
|
+
* @returns True when dev, gen and both keys match
|
|
121
|
+
*/
|
|
122
|
+
function sameIdentity(a, b) {
|
|
123
|
+
return a.dev === b.dev && a.gen === b.gen && (0, jwk_1.sameKey)(a.sig, b.sig) && (0, jwk_1.sameKey)(a.enc, b.enc);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* isEnrolled reports whether a record has completed enrollment.
|
|
127
|
+
*
|
|
128
|
+
* @param record - Controller record
|
|
129
|
+
* @returns True when role and nk are present
|
|
130
|
+
*/
|
|
131
|
+
function isEnrolled(record) {
|
|
132
|
+
return record.role !== undefined && record.nk !== undefined;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* parseControllerRecord validates a controller file strictly. A corrupt file
|
|
136
|
+
* is an error, never "no file": silently treating it as absent would send
|
|
137
|
+
* plaintext to a device the user secured.
|
|
138
|
+
*
|
|
139
|
+
* @param text - File contents
|
|
140
|
+
* @returns The record
|
|
141
|
+
* @throws {ControlFormatError} When malformed
|
|
142
|
+
*/
|
|
143
|
+
function parseControllerRecord(text) {
|
|
144
|
+
const obj = (0, strict_json_1.strictObject)((0, strict_json_1.parseStrictJson)(text), ['v', 'deviceId', 'gen', 'pins', 'profile', 'keys', 'label', 'ctr', 'createdAt'], ['role', 'nk', 'enrolledAt', 'expiresAt', 'registration', 'relayV2'], 'controller file');
|
|
145
|
+
if ((0, strict_json_1.uintMember)(obj, 'v') !== 1) {
|
|
146
|
+
throw new strict_json_1.ControlFormatError('controller file v must be 1');
|
|
147
|
+
}
|
|
148
|
+
const keysObj = (0, strict_json_1.strictObject)(obj.keys, ['sig', 'enc'], [], 'controller keys');
|
|
149
|
+
const record = {
|
|
150
|
+
v: 1,
|
|
151
|
+
deviceId: (0, strict_json_1.stringMember)(obj, 'deviceId'),
|
|
152
|
+
profile: (0, codec_1.parseProfile)(obj.profile),
|
|
153
|
+
keys: {
|
|
154
|
+
sig: (0, jwk_1.parsePrivateJwk)(keysObj.sig, 'controller sig key'),
|
|
155
|
+
enc: (0, jwk_1.parsePrivateJwk)(keysObj.enc, 'controller enc key'),
|
|
156
|
+
},
|
|
157
|
+
label: (0, strict_json_1.stringMember)(obj, 'label'),
|
|
158
|
+
ctr: (0, strict_json_1.uintMember)(obj, 'ctr'),
|
|
159
|
+
createdAt: (0, strict_json_1.uintMember)(obj, 'createdAt'),
|
|
160
|
+
};
|
|
161
|
+
if (record.profile.dev !== record.deviceId) {
|
|
162
|
+
throw new strict_json_1.ControlFormatError('controller file profile is for another device');
|
|
163
|
+
}
|
|
164
|
+
// gen and pins are redundant with the profile; a mismatch means the file
|
|
165
|
+
// was hand-edited, and which half to believe cannot be decided.
|
|
166
|
+
const pins = (0, strict_json_1.strictObject)(obj.pins, ['sig', 'enc'], [], 'controller pins');
|
|
167
|
+
if (obj.gen !== record.profile.gen ||
|
|
168
|
+
pins.sig !== (0, jwk_1.thumbprint)(record.profile.sig) ||
|
|
169
|
+
pins.enc !== (0, jwk_1.thumbprint)(record.profile.enc)) {
|
|
170
|
+
throw new strict_json_1.ControlFormatError('controller file pins do not match its profile');
|
|
171
|
+
}
|
|
172
|
+
if ('role' in obj) {
|
|
173
|
+
const role = (0, strict_json_1.stringMember)(obj, 'role');
|
|
174
|
+
if (role !== 'owner' && role !== 'controller') {
|
|
175
|
+
throw new strict_json_1.ControlFormatError('controller file role is invalid');
|
|
176
|
+
}
|
|
177
|
+
record.role = role;
|
|
178
|
+
}
|
|
179
|
+
if ('nk' in obj) {
|
|
180
|
+
if (!(0, strict_json_1.isB64uOfLength)(obj.nk, 32)) {
|
|
181
|
+
throw new strict_json_1.ControlFormatError('controller file nk is invalid');
|
|
182
|
+
}
|
|
183
|
+
record.nk = obj.nk;
|
|
184
|
+
}
|
|
185
|
+
if ('enrolledAt' in obj) {
|
|
186
|
+
record.enrolledAt = (0, strict_json_1.uintMember)(obj, 'enrolledAt');
|
|
187
|
+
}
|
|
188
|
+
if ('expiresAt' in obj) {
|
|
189
|
+
record.expiresAt = (0, strict_json_1.uintMember)(obj, 'expiresAt');
|
|
190
|
+
}
|
|
191
|
+
if ('registration' in obj) {
|
|
192
|
+
record.registration = parseRegistrationRecord(obj.registration);
|
|
193
|
+
}
|
|
194
|
+
if ('relayV2' in obj) {
|
|
195
|
+
if (typeof obj.relayV2 !== 'boolean') {
|
|
196
|
+
throw new strict_json_1.ControlFormatError('controller file relayV2 is invalid');
|
|
197
|
+
}
|
|
198
|
+
record.relayV2 = obj.relayV2;
|
|
199
|
+
}
|
|
200
|
+
return record;
|
|
201
|
+
}
|
|
202
|
+
/** parseRegistrationRecord validates the stored registration facts. */
|
|
203
|
+
function parseRegistrationRecord(value) {
|
|
204
|
+
const obj = (0, strict_json_1.strictObject)(value, ['channelId', 'deviceName', 'role', 'receivedAt'], ['expiresAt', 'topicId'], 'controller file registration');
|
|
205
|
+
const role = (0, strict_json_1.stringMember)(obj, 'role');
|
|
206
|
+
if (role !== 'owner' && role !== 'controller') {
|
|
207
|
+
throw new strict_json_1.ControlFormatError('controller file registration role is invalid');
|
|
208
|
+
}
|
|
209
|
+
const registration = {
|
|
210
|
+
channelId: (0, strict_json_1.stringMember)(obj, 'channelId'),
|
|
211
|
+
deviceName: (0, strict_json_1.stringMember)(obj, 'deviceName'),
|
|
212
|
+
role,
|
|
213
|
+
receivedAt: (0, strict_json_1.uintMember)(obj, 'receivedAt'),
|
|
214
|
+
};
|
|
215
|
+
if ('expiresAt' in obj) {
|
|
216
|
+
registration.expiresAt = (0, strict_json_1.uintMember)(obj, 'expiresAt');
|
|
217
|
+
}
|
|
218
|
+
if ('topicId' in obj) {
|
|
219
|
+
const topicId = (0, strict_json_1.stringMember)(obj, 'topicId');
|
|
220
|
+
if (!topicId) {
|
|
221
|
+
throw new strict_json_1.ControlFormatError('controller file registration topicId is empty');
|
|
222
|
+
}
|
|
223
|
+
registration.topicId = topicId;
|
|
224
|
+
}
|
|
225
|
+
return registration;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* serializeControllerRecord renders the file. gen and pins are derived from
|
|
229
|
+
* the profile and written for inspection; load checks they still agree.
|
|
230
|
+
*/
|
|
231
|
+
function serializeControllerRecord(record) {
|
|
232
|
+
const body = {
|
|
233
|
+
v: 1,
|
|
234
|
+
deviceId: record.deviceId,
|
|
235
|
+
gen: record.profile.gen,
|
|
236
|
+
pins: { sig: (0, jwk_1.thumbprint)(record.profile.sig), enc: (0, jwk_1.thumbprint)(record.profile.enc) },
|
|
237
|
+
profile: record.profile,
|
|
238
|
+
keys: record.keys,
|
|
239
|
+
label: record.label,
|
|
240
|
+
ctr: record.ctr,
|
|
241
|
+
createdAt: record.createdAt,
|
|
242
|
+
};
|
|
243
|
+
if (record.role !== undefined) {
|
|
244
|
+
body.role = record.role;
|
|
245
|
+
}
|
|
246
|
+
if (record.nk !== undefined) {
|
|
247
|
+
body.nk = record.nk;
|
|
248
|
+
}
|
|
249
|
+
if (record.enrolledAt !== undefined) {
|
|
250
|
+
body.enrolledAt = record.enrolledAt;
|
|
251
|
+
}
|
|
252
|
+
if (record.expiresAt !== undefined) {
|
|
253
|
+
body.expiresAt = record.expiresAt;
|
|
254
|
+
}
|
|
255
|
+
if (record.registration !== undefined) {
|
|
256
|
+
body.registration = record.registration;
|
|
257
|
+
}
|
|
258
|
+
if (record.relayV2 !== undefined) {
|
|
259
|
+
body.relayV2 = record.relayV2;
|
|
260
|
+
}
|
|
261
|
+
return `${JSON.stringify(body, null, 2)}\n`;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* ControllerFileCorruptError is thrown by loadController for a file that was
|
|
265
|
+
* read but does not parse. A file that could not be read at all (EACCES, EIO,
|
|
266
|
+
* a Windows lock) throws a plain Error instead: its keys may be fine, so no
|
|
267
|
+
* caller may treat it as absent and write over it.
|
|
268
|
+
*/
|
|
269
|
+
class ControllerFileCorruptError extends Error {
|
|
270
|
+
constructor(message) {
|
|
271
|
+
super(message);
|
|
272
|
+
this.name = 'ControllerFileCorruptError';
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
exports.ControllerFileCorruptError = ControllerFileCorruptError;
|
|
276
|
+
/**
|
|
277
|
+
* loadController reads the controller file for a device, or null when none.
|
|
278
|
+
*
|
|
279
|
+
* @param deviceId - FF1 device ID
|
|
280
|
+
* @param options - Optional directory override
|
|
281
|
+
* @returns The record, or null when the file does not exist
|
|
282
|
+
* @throws {ControllerFileCorruptError} When the file exists but is corrupt
|
|
283
|
+
* @throws {Error} When the file exists but cannot be read
|
|
284
|
+
*/
|
|
285
|
+
function loadController(deviceId, options = {}) {
|
|
286
|
+
const file = controllerFilePath(deviceId, options);
|
|
287
|
+
let text;
|
|
288
|
+
try {
|
|
289
|
+
text = node_fs_1.default.readFileSync(file, 'utf8');
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
if (error.code === 'ENOENT') {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
throw new Error(`Cannot read controller file ${file}: ${error.message}. Its keys were not changed; fix the file's permissions (or close the program holding it) and try again.`);
|
|
296
|
+
}
|
|
297
|
+
try {
|
|
298
|
+
const record = parseControllerRecord(text);
|
|
299
|
+
if (record.deviceId !== normalizeDeviceId(deviceId)) {
|
|
300
|
+
throw new strict_json_1.ControlFormatError('controller file is for another device');
|
|
301
|
+
}
|
|
302
|
+
return record;
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
// The message comes from the validator and never echoes key material.
|
|
306
|
+
throw new ControllerFileCorruptError(`Controller file ${file} is corrupt (${error.message}). Run \`ff-cli device pair\` again; it moves the file aside before pairing.`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* renameCorruptAside renames a corrupt controller file to
|
|
311
|
+
* `<id>.json.corrupt-<unix seconds>` so a new pairing never writes over it
|
|
312
|
+
* (it may still be the only copy of an owner key). The caller holds the
|
|
313
|
+
* file's lock.
|
|
314
|
+
*
|
|
315
|
+
* @param file - Controller file path
|
|
316
|
+
* @returns The new path
|
|
317
|
+
*/
|
|
318
|
+
function renameCorruptAside(file) {
|
|
319
|
+
const aside = `${file}.corrupt-${Math.floor(Date.now() / 1000)}`;
|
|
320
|
+
node_fs_1.default.renameSync(file, aside);
|
|
321
|
+
return aside;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* setAsideCorruptController moves the device's controller file aside when it
|
|
325
|
+
* is (still) corrupt; a readable or missing file is left alone.
|
|
326
|
+
*
|
|
327
|
+
* @param deviceId - FF1 device ID
|
|
328
|
+
* @param options - Optional directory override
|
|
329
|
+
* @returns The new path, or null when nothing was moved
|
|
330
|
+
* @throws {Error} When the file cannot be read
|
|
331
|
+
*/
|
|
332
|
+
function setAsideCorruptController(deviceId, options = {}) {
|
|
333
|
+
const file = controllerFilePath(deviceId, options);
|
|
334
|
+
return withControllerLock(file, () => {
|
|
335
|
+
try {
|
|
336
|
+
loadController(deviceId, options);
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
catch (error) {
|
|
340
|
+
if (!(error instanceof ControllerFileCorruptError)) {
|
|
341
|
+
throw error;
|
|
342
|
+
}
|
|
343
|
+
return renameCorruptAside(file);
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
/** ensurePrivateDir creates the controllers directory 0700 (tightening an existing one). */
|
|
348
|
+
function ensurePrivateDir(dir) {
|
|
349
|
+
node_fs_1.default.mkdirSync(dir, { recursive: true, mode: DIR_MODE });
|
|
350
|
+
if (process.platform !== 'win32') {
|
|
351
|
+
node_fs_1.default.chmodSync(dir, DIR_MODE);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* writeAtomic writes a file as tmp + fsync + rename + best-effort dir fsync.
|
|
356
|
+
* The tmp file is created exclusively with mode 0600, so the keys are never
|
|
357
|
+
* readable by others even for an instant (umask can only narrow it).
|
|
358
|
+
*/
|
|
359
|
+
function writeAtomic(file, contents) {
|
|
360
|
+
const dir = node_path_1.default.dirname(file);
|
|
361
|
+
ensurePrivateDir(dir);
|
|
362
|
+
const tmp = node_path_1.default.join(dir, `.${node_path_1.default.basename(file)}.${process.pid}.${Date.now()}.tmp`);
|
|
363
|
+
const fd = node_fs_1.default.openSync(tmp, 'wx', FILE_MODE);
|
|
364
|
+
// Any failure before the rename (ENOSPC on write, fsync, close, rename)
|
|
365
|
+
// removes the temp file: it holds private keys and must not linger.
|
|
366
|
+
try {
|
|
367
|
+
try {
|
|
368
|
+
node_fs_1.default.writeFileSync(fd, contents, 'utf8');
|
|
369
|
+
node_fs_1.default.fsyncSync(fd);
|
|
370
|
+
}
|
|
371
|
+
finally {
|
|
372
|
+
node_fs_1.default.closeSync(fd);
|
|
373
|
+
}
|
|
374
|
+
node_fs_1.default.renameSync(tmp, file);
|
|
375
|
+
}
|
|
376
|
+
catch (error) {
|
|
377
|
+
node_fs_1.default.rmSync(tmp, { force: true });
|
|
378
|
+
throw error;
|
|
379
|
+
}
|
|
380
|
+
if (process.platform !== 'win32') {
|
|
381
|
+
node_fs_1.default.chmodSync(file, FILE_MODE);
|
|
382
|
+
// Directory fsync makes the rename durable; Windows rejects it, and on
|
|
383
|
+
// POSIX a failure here only weakens durability, never consistency.
|
|
384
|
+
try {
|
|
385
|
+
const dfd = node_fs_1.default.openSync(dir, 'r');
|
|
386
|
+
try {
|
|
387
|
+
node_fs_1.default.fsyncSync(dfd);
|
|
388
|
+
}
|
|
389
|
+
finally {
|
|
390
|
+
node_fs_1.default.closeSync(dfd);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
// best effort
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* saveController persists a record atomically with private permissions.
|
|
400
|
+
*
|
|
401
|
+
* @param record - Record to save
|
|
402
|
+
* @param options - Optional directory override
|
|
403
|
+
*/
|
|
404
|
+
function saveController(record, options = {}) {
|
|
405
|
+
writeAtomic(controllerFilePath(record.deviceId, options), serializeControllerRecord(record));
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* createController saves a pending (not yet enrolled) record with ctr 0 for a
|
|
409
|
+
* pinned profile: with the given keys (the ones the owner registered), else
|
|
410
|
+
* freshly generated ones.
|
|
411
|
+
*
|
|
412
|
+
* @param input - Pinned profile, label, and optionally keys and registration facts
|
|
413
|
+
* @param options - Optional directory override
|
|
414
|
+
* @returns The saved record
|
|
415
|
+
*/
|
|
416
|
+
function createController(input, options = {}) {
|
|
417
|
+
const record = {
|
|
418
|
+
v: 1,
|
|
419
|
+
deviceId: input.profile.dev,
|
|
420
|
+
profile: input.profile,
|
|
421
|
+
keys: input.keys ?? { sig: (0, jwk_1.generatePrivateJwk)(), enc: (0, jwk_1.generatePrivateJwk)() },
|
|
422
|
+
label: input.label,
|
|
423
|
+
ctr: 0,
|
|
424
|
+
createdAt: input.now ?? Math.floor(Date.now() / 1000),
|
|
425
|
+
};
|
|
426
|
+
if (input.registration) {
|
|
427
|
+
record.registration = input.registration;
|
|
428
|
+
}
|
|
429
|
+
const file = controllerFilePath(record.deviceId, options);
|
|
430
|
+
withControllerLock(file, () => {
|
|
431
|
+
// Checked under the lock: a record another process created meanwhile
|
|
432
|
+
// may already have spent counters, and must never be written over.
|
|
433
|
+
if (input.exclusive && node_fs_1.default.existsSync(file)) {
|
|
434
|
+
throw new Error(`A controller file for ${record.deviceId} appeared while pairing (${file}); it was kept. Run \`ff-cli device pair\` again.`);
|
|
435
|
+
}
|
|
436
|
+
saveController(record, options);
|
|
437
|
+
});
|
|
438
|
+
return record;
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* stagingStore is where `ff-cli device pair` keeps a new controller while an
|
|
442
|
+
* existing record for the same FF1 stays in place: the old keys (possibly the
|
|
443
|
+
* last reachable owner grant) are replaced only once the new pairing is
|
|
444
|
+
* enrolled (promoteStagedController), never by a pairing that fails.
|
|
445
|
+
*
|
|
446
|
+
* @param options - Main store options
|
|
447
|
+
* @returns Store options for the staging directory
|
|
448
|
+
*/
|
|
449
|
+
function stagingStore(options = {}) {
|
|
450
|
+
return { dir: node_path_1.default.join(controllersDir(options), 'pairing') };
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* promoteStagedController moves an enrolled staged record over the device's
|
|
454
|
+
* controller file. Both locks are held (main, then staged; nothing takes them
|
|
455
|
+
* in the other order), so no counter allocated on either file is lost.
|
|
456
|
+
*
|
|
457
|
+
* @param deviceId - FF1 device ID
|
|
458
|
+
* @param options - Main store options
|
|
459
|
+
* @returns The promoted record
|
|
460
|
+
* @throws {Error} When the staged file is missing or corrupt
|
|
461
|
+
*/
|
|
462
|
+
function promoteStagedController(deviceId, options = {}) {
|
|
463
|
+
const staged = stagingStore(options);
|
|
464
|
+
const from = controllerFilePath(deviceId, staged);
|
|
465
|
+
const to = controllerFilePath(deviceId, options);
|
|
466
|
+
return withControllerLock(to, () => withControllerLock(from, () => {
|
|
467
|
+
const record = loadController(deviceId, staged);
|
|
468
|
+
if (!record) {
|
|
469
|
+
throw new Error(`No staged controller file for ${deviceId}`);
|
|
470
|
+
}
|
|
471
|
+
// Rewritten rather than renamed: the staging directory is a different
|
|
472
|
+
// directory, and writeAtomic gives the same durability as every write.
|
|
473
|
+
// An earlier promotion that crashed before the remove already wrote
|
|
474
|
+
// these keys; the main file may have advanced its counter since, so it
|
|
475
|
+
// is kept as is.
|
|
476
|
+
let current;
|
|
477
|
+
try {
|
|
478
|
+
current = loadController(deviceId, options);
|
|
479
|
+
}
|
|
480
|
+
catch (error) {
|
|
481
|
+
if (!(error instanceof ControllerFileCorruptError)) {
|
|
482
|
+
throw error; // unreadable: its keys may be fine, keep both files
|
|
483
|
+
}
|
|
484
|
+
// Corrupt: kept aside, and the enrolled staged record replaces it.
|
|
485
|
+
renameCorruptAside(to);
|
|
486
|
+
current = null;
|
|
487
|
+
}
|
|
488
|
+
if (!current || !sameControl(current, record)) {
|
|
489
|
+
saveController(record, options);
|
|
490
|
+
node_fs_1.default.rmSync(from, { force: true });
|
|
491
|
+
return record;
|
|
492
|
+
}
|
|
493
|
+
node_fs_1.default.rmSync(from, { force: true });
|
|
494
|
+
return current;
|
|
495
|
+
}));
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* dropStagedController removes the device's staged record when it is no
|
|
499
|
+
* longer the pairing to continue: `onlyIfPromoted` drops it only when the
|
|
500
|
+
* main file already holds its keys (a promotion that crashed before the
|
|
501
|
+
* remove), otherwise it is dropped unconditionally (a same-identity pairing
|
|
502
|
+
* superseded it).
|
|
503
|
+
*
|
|
504
|
+
* @param deviceId - FF1 device ID
|
|
505
|
+
* @param onlyIfPromoted - Drop only a staged record whose keys the main file holds
|
|
506
|
+
* @param options - Main store options
|
|
507
|
+
* @returns True when a staged file was removed
|
|
508
|
+
*/
|
|
509
|
+
function dropStagedController(deviceId, onlyIfPromoted, options = {}) {
|
|
510
|
+
const staged = stagingStore(options);
|
|
511
|
+
const from = controllerFilePath(deviceId, staged);
|
|
512
|
+
const to = controllerFilePath(deviceId, options);
|
|
513
|
+
return withControllerLock(to, () => withControllerLock(from, () => {
|
|
514
|
+
if (!node_fs_1.default.existsSync(from)) {
|
|
515
|
+
return false;
|
|
516
|
+
}
|
|
517
|
+
if (onlyIfPromoted) {
|
|
518
|
+
let record;
|
|
519
|
+
let current;
|
|
520
|
+
try {
|
|
521
|
+
record = loadController(deviceId, staged);
|
|
522
|
+
current = loadController(deviceId, options);
|
|
523
|
+
}
|
|
524
|
+
catch {
|
|
525
|
+
return false; // corrupt: leave both for the user to see
|
|
526
|
+
}
|
|
527
|
+
if (!record || !current || !sameControl(current, record)) {
|
|
528
|
+
return false;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
node_fs_1.default.rmSync(from, { force: true });
|
|
532
|
+
return true;
|
|
533
|
+
}));
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* sameControl reports whether two records are the same keys under the same
|
|
537
|
+
* control identity. Keys alone are not enough: a pairing after a factory
|
|
538
|
+
* reset may register this computer's existing keys with the new identity,
|
|
539
|
+
* and that staged record must still replace the old one.
|
|
540
|
+
*/
|
|
541
|
+
function sameControl(a, b) {
|
|
542
|
+
return (a.keys.sig.d === b.keys.sig.d &&
|
|
543
|
+
a.keys.enc.d === b.keys.enc.d &&
|
|
544
|
+
sameIdentity(a.profile, b.profile));
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* pairingKeysPath is where `ff-cli device pair` keeps the keys it shows in
|
|
548
|
+
* the controller QR before any owner answered: `pairing/<id>.keys.json`.
|
|
549
|
+
* The QR's keys must survive an interrupted pairing, because an owner may
|
|
550
|
+
* already have registered them (§5.6 step 5: a later retry with the same
|
|
551
|
+
* keys completes it), and a controller record cannot exist yet without the
|
|
552
|
+
* profile the owner's answer carries.
|
|
553
|
+
*
|
|
554
|
+
* @param deviceId - FF1 device ID
|
|
555
|
+
* @param options - Main store options
|
|
556
|
+
* @returns Absolute file path
|
|
557
|
+
*/
|
|
558
|
+
function pairingKeysPath(deviceId, options = {}) {
|
|
559
|
+
const file = controllerFilePath(deviceId, stagingStore(options));
|
|
560
|
+
return file.replace(/\.json$/, '.keys.json');
|
|
561
|
+
}
|
|
562
|
+
/** parsePairingKeys validates the pairing keys file strictly. */
|
|
563
|
+
function parsePairingKeys(text, deviceId) {
|
|
564
|
+
const obj = (0, strict_json_1.strictObject)((0, strict_json_1.parseStrictJson)(text), ['v', 'deviceId', 'keys', 'createdAt'], [], 'pairing keys file');
|
|
565
|
+
if ((0, strict_json_1.uintMember)(obj, 'v') !== 1 || obj.deviceId !== deviceId) {
|
|
566
|
+
throw new strict_json_1.ControlFormatError('pairing keys file is for another device or version');
|
|
567
|
+
}
|
|
568
|
+
(0, strict_json_1.uintMember)(obj, 'createdAt');
|
|
569
|
+
const keysObj = (0, strict_json_1.strictObject)(obj.keys, ['sig', 'enc'], [], 'pairing keys');
|
|
570
|
+
return {
|
|
571
|
+
sig: (0, jwk_1.parsePrivateJwk)(keysObj.sig, 'pairing sig key'),
|
|
572
|
+
enc: (0, jwk_1.parsePrivateJwk)(keysObj.enc, 'pairing enc key'),
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
/** readPairingKeysText reads the pairing keys file, null when absent. */
|
|
576
|
+
function readPairingKeysText(file) {
|
|
577
|
+
try {
|
|
578
|
+
return node_fs_1.default.readFileSync(file, 'utf8');
|
|
579
|
+
}
|
|
580
|
+
catch (error) {
|
|
581
|
+
if (error.code === 'ENOENT') {
|
|
582
|
+
return null;
|
|
583
|
+
}
|
|
584
|
+
throw new Error(`Cannot read pairing keys file ${file}: ${error.message}. Fix its permissions and try again.`);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* hasPairingKeys reports whether a pairing keys file exists for the device
|
|
589
|
+
* (readable, corrupt or not), without creating one.
|
|
590
|
+
*
|
|
591
|
+
* @param deviceId - FF1 device ID
|
|
592
|
+
* @param options - Main store options
|
|
593
|
+
* @returns True when the file exists
|
|
594
|
+
*/
|
|
595
|
+
function hasPairingKeys(deviceId, options = {}) {
|
|
596
|
+
return node_fs_1.default.existsSync(pairingKeysPath(normalizeDeviceId(deviceId), options));
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* loadPairingKeys returns the saved pairing keys without creating any.
|
|
600
|
+
*
|
|
601
|
+
* @param deviceId - FF1 device ID
|
|
602
|
+
* @param options - Main store options
|
|
603
|
+
* @returns The keys, or null when there is no readable, valid file
|
|
604
|
+
* @throws {Error} When the file exists but cannot be read
|
|
605
|
+
*/
|
|
606
|
+
function loadPairingKeys(deviceId, options = {}) {
|
|
607
|
+
const id = normalizeDeviceId(deviceId);
|
|
608
|
+
const text = readPairingKeysText(pairingKeysPath(id, options));
|
|
609
|
+
if (text === null) {
|
|
610
|
+
return null;
|
|
611
|
+
}
|
|
612
|
+
try {
|
|
613
|
+
return parsePairingKeys(text, id);
|
|
614
|
+
}
|
|
615
|
+
catch {
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* pairingKeys returns the keys an earlier interrupted pairing showed for the
|
|
621
|
+
* device, or creates and saves new ones (0600, atomic) before they are shown.
|
|
622
|
+
* A corrupt file is moved aside, never written over; an unreadable one
|
|
623
|
+
* throws, its keys may be fine.
|
|
624
|
+
*
|
|
625
|
+
* @param deviceId - FF1 device ID
|
|
626
|
+
* @param options - Main store options
|
|
627
|
+
* @returns The keys and whether they were already saved
|
|
628
|
+
*/
|
|
629
|
+
function pairingKeys(deviceId, options = {}) {
|
|
630
|
+
const id = normalizeDeviceId(deviceId);
|
|
631
|
+
const file = pairingKeysPath(id, options);
|
|
632
|
+
return withControllerLock(file, () => {
|
|
633
|
+
const text = readPairingKeysText(file);
|
|
634
|
+
if (text !== null) {
|
|
635
|
+
try {
|
|
636
|
+
return { keys: parsePairingKeys(text, id), reused: true };
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
renameCorruptAside(file);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
const keys = { sig: (0, jwk_1.generatePrivateJwk)(), enc: (0, jwk_1.generatePrivateJwk)() };
|
|
643
|
+
const body = { v: 1, deviceId: id, keys, createdAt: Math.floor(Date.now() / 1000) };
|
|
644
|
+
writeAtomic(file, `${JSON.stringify(body, null, 2)}\n`);
|
|
645
|
+
return { keys, reused: false };
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* deletePairingKeys removes the pairing keys file once its keys are in a
|
|
650
|
+
* controller record (or the controller is unpaired).
|
|
651
|
+
*
|
|
652
|
+
* @param deviceId - FF1 device ID
|
|
653
|
+
* @param options - Main store options
|
|
654
|
+
* @returns True when a file was removed
|
|
655
|
+
*/
|
|
656
|
+
function deletePairingKeys(deviceId, options = {}) {
|
|
657
|
+
const file = pairingKeysPath(normalizeDeviceId(deviceId), options);
|
|
658
|
+
return withControllerLock(file, () => {
|
|
659
|
+
if (!node_fs_1.default.existsSync(file)) {
|
|
660
|
+
return false;
|
|
661
|
+
}
|
|
662
|
+
node_fs_1.default.rmSync(file);
|
|
663
|
+
return true;
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* deleteController removes a device's controller file.
|
|
668
|
+
*
|
|
669
|
+
* @param deviceId - FF1 device ID
|
|
670
|
+
* @param options - Optional directory override
|
|
671
|
+
* @returns True when a file was removed
|
|
672
|
+
*/
|
|
673
|
+
function deleteController(deviceId, options = {}) {
|
|
674
|
+
const file = controllerFilePath(deviceId, options);
|
|
675
|
+
// Under the lock, so a concurrent nextCounter cannot re-create the file
|
|
676
|
+
// (resurrecting it) after the delete.
|
|
677
|
+
return withControllerLock(file, () => {
|
|
678
|
+
if (!node_fs_1.default.existsSync(file)) {
|
|
679
|
+
return false;
|
|
680
|
+
}
|
|
681
|
+
node_fs_1.default.rmSync(file);
|
|
682
|
+
return true;
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* recordRegistration stores a verified registration result on an existing
|
|
687
|
+
* record (same keys, same identity) as a locked re-read + save, so a counter
|
|
688
|
+
* allocated concurrently by another process is never rolled back.
|
|
689
|
+
*
|
|
690
|
+
* @param deviceId - FF1 device ID
|
|
691
|
+
* @param update - The label the owner registered and the result's facts
|
|
692
|
+
* @param options - Optional directory override
|
|
693
|
+
* @returns The updated record
|
|
694
|
+
* @throws {Error} When the file is missing or corrupt
|
|
695
|
+
*/
|
|
696
|
+
function recordRegistration(deviceId, update, options = {}) {
|
|
697
|
+
const file = controllerFilePath(deviceId, options);
|
|
698
|
+
return withControllerLock(file, () => {
|
|
699
|
+
const record = loadController(deviceId, options);
|
|
700
|
+
if (!record) {
|
|
701
|
+
throw new Error(`No controller file for ${deviceId}`);
|
|
702
|
+
}
|
|
703
|
+
record.label = update.label;
|
|
704
|
+
record.registration = update.registration;
|
|
705
|
+
saveController(record, options);
|
|
706
|
+
return record;
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
/** sleepSync blocks briefly without spinning the CPU (lock retry only). */
|
|
710
|
+
function sleepSync(ms) {
|
|
711
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* withControllerLock runs fn while holding `<file>.lock`, created with O_EXCL
|
|
715
|
+
* so two ff-cli processes cannot allocate the same counter. The lock records
|
|
716
|
+
* the holder's PID and a unique token; see isStaleLock for when it is broken.
|
|
717
|
+
* fn must be synchronous, so a JS-level signal handler can never run while
|
|
718
|
+
* the lock is held.
|
|
719
|
+
*/
|
|
720
|
+
function withControllerLock(file, fn) {
|
|
721
|
+
const lock = `${file}.lock`;
|
|
722
|
+
ensurePrivateDir(node_path_1.default.dirname(file));
|
|
723
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
724
|
+
// The token makes the lock ours: release removes it only if it still holds
|
|
725
|
+
// this token, so a process can never delete another process's live lock.
|
|
726
|
+
const token = `${process.pid}:${(0, node_crypto_1.randomUUID)()}`;
|
|
727
|
+
for (;;) {
|
|
728
|
+
try {
|
|
729
|
+
const fd = node_fs_1.default.openSync(lock, 'wx', FILE_MODE);
|
|
730
|
+
try {
|
|
731
|
+
node_fs_1.default.writeFileSync(fd, token);
|
|
732
|
+
}
|
|
733
|
+
finally {
|
|
734
|
+
node_fs_1.default.closeSync(fd);
|
|
735
|
+
}
|
|
736
|
+
break;
|
|
737
|
+
}
|
|
738
|
+
catch (error) {
|
|
739
|
+
if (error.code !== 'EEXIST') {
|
|
740
|
+
throw error;
|
|
741
|
+
}
|
|
742
|
+
const stale = readStaleLock(lock);
|
|
743
|
+
if (stale !== null) {
|
|
744
|
+
breakStaleLock(lock, stale);
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
if (Date.now() > deadline) {
|
|
748
|
+
throw new Error(`Timed out waiting for controller lock ${lock}. If no other ff-cli command is running, delete that file and retry.`);
|
|
749
|
+
}
|
|
750
|
+
sleepSync(LOCK_RETRY_MS);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
try {
|
|
754
|
+
return fn();
|
|
755
|
+
}
|
|
756
|
+
finally {
|
|
757
|
+
releaseLock(lock, token);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* breakStaleLock moves a lock judged stale aside with rename, which only one
|
|
762
|
+
* of several concurrent breakers can win (losers get ENOENT and retry the
|
|
763
|
+
* O_EXCL create). The moved file is then re-read: if it is no longer the lock
|
|
764
|
+
* that was judged stale, another process re-created the lock between the
|
|
765
|
+
* check and the rename, and we just moved a LIVE lock. It is linked back; if
|
|
766
|
+
* that fails (a third process created a new lock meanwhile) two holders could
|
|
767
|
+
* exist, so this fails with a lock error instead of proceeding.
|
|
768
|
+
*
|
|
769
|
+
* @param lock - Lock path
|
|
770
|
+
* @param judged - Contents of the lock when it was judged stale
|
|
771
|
+
* @throws {Error} When a live lock was displaced and cannot be restored
|
|
772
|
+
*/
|
|
773
|
+
function breakStaleLock(lock, judged) {
|
|
774
|
+
const aside = `${lock}.${process.pid}.${(0, node_crypto_1.randomUUID)()}.stale`;
|
|
775
|
+
try {
|
|
776
|
+
node_fs_1.default.renameSync(lock, aside);
|
|
777
|
+
}
|
|
778
|
+
catch {
|
|
779
|
+
return; // someone else broke it first
|
|
780
|
+
}
|
|
781
|
+
let moved;
|
|
782
|
+
try {
|
|
783
|
+
moved = node_fs_1.default.readFileSync(aside, 'utf8');
|
|
784
|
+
}
|
|
785
|
+
catch {
|
|
786
|
+
moved = null;
|
|
787
|
+
}
|
|
788
|
+
if (moved === judged) {
|
|
789
|
+
node_fs_1.default.rmSync(aside, { force: true });
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
try {
|
|
793
|
+
node_fs_1.default.linkSync(aside, lock);
|
|
794
|
+
}
|
|
795
|
+
catch {
|
|
796
|
+
node_fs_1.default.rmSync(aside, { force: true });
|
|
797
|
+
throw new Error(`Controller lock ${lock} changed while it was being recovered; retry the command.`);
|
|
798
|
+
}
|
|
799
|
+
node_fs_1.default.rmSync(aside, { force: true });
|
|
800
|
+
}
|
|
801
|
+
/** releaseLock removes the lock only while it still holds this process's token. */
|
|
802
|
+
function releaseLock(lock, token) {
|
|
803
|
+
try {
|
|
804
|
+
if (node_fs_1.default.readFileSync(lock, 'utf8') === token) {
|
|
805
|
+
node_fs_1.default.rmSync(lock, { force: true });
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
catch {
|
|
809
|
+
// already gone
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* readStaleLock returns the lock's contents when it is stale, else null.
|
|
814
|
+
* Stale means: the recorded holder PID is dead and the lock is at least
|
|
815
|
+
* LOCK_MIN_STALE_MS old, or there is no readable PID and the lock is older
|
|
816
|
+
* than LOCK_UNOWNED_STALE_MS.
|
|
817
|
+
*
|
|
818
|
+
* @param lock - Lock path
|
|
819
|
+
* @returns Contents judged stale, or null
|
|
820
|
+
*/
|
|
821
|
+
function readStaleLock(lock) {
|
|
822
|
+
try {
|
|
823
|
+
const contents = node_fs_1.default.readFileSync(lock, 'utf8');
|
|
824
|
+
const age = Date.now() - node_fs_1.default.statSync(lock).mtimeMs;
|
|
825
|
+
const pid = Number(contents.split(':')[0]);
|
|
826
|
+
const hasPid = contents !== '' && Number.isSafeInteger(pid) && pid > 0;
|
|
827
|
+
if (hasPid) {
|
|
828
|
+
const dead = pid !== process.pid && !processExists(pid);
|
|
829
|
+
return dead && age >= LOCK_MIN_STALE_MS ? contents : null;
|
|
830
|
+
}
|
|
831
|
+
return age > LOCK_UNOWNED_STALE_MS ? contents : null;
|
|
832
|
+
}
|
|
833
|
+
catch {
|
|
834
|
+
return null;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
/** processExists probes a PID with signal 0 (EPERM still means it exists). */
|
|
838
|
+
function processExists(pid) {
|
|
839
|
+
try {
|
|
840
|
+
process.kill(pid, 0);
|
|
841
|
+
return true;
|
|
842
|
+
}
|
|
843
|
+
catch (error) {
|
|
844
|
+
return error.code === 'EPERM';
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* nextCounter allocates the next request counter: under the lock it re-reads
|
|
849
|
+
* the file, increments ctr and saves, and only then returns. The caller sends
|
|
850
|
+
* a request with the returned counter at most once (§2.6: a lost reply is
|
|
851
|
+
* never replayed with the same ctr; a retry allocates again).
|
|
852
|
+
*
|
|
853
|
+
* @param deviceId - FF1 device ID
|
|
854
|
+
* @param options - Optional directory override
|
|
855
|
+
* @returns The updated record and the counter to use
|
|
856
|
+
* @throws {Error} When the file is missing or corrupt
|
|
857
|
+
*/
|
|
858
|
+
function nextCounter(deviceId, options = {}) {
|
|
859
|
+
const file = controllerFilePath(deviceId, options);
|
|
860
|
+
return withControllerLock(file, () => {
|
|
861
|
+
const record = loadController(deviceId, options);
|
|
862
|
+
if (!record) {
|
|
863
|
+
throw new Error(`No controller file for ${deviceId}; run \`ff-cli device pair\``);
|
|
864
|
+
}
|
|
865
|
+
if (!Number.isSafeInteger(record.ctr + 1)) {
|
|
866
|
+
throw new Error('Controller counter exhausted; run `ff-cli device unpair` and pair again');
|
|
867
|
+
}
|
|
868
|
+
record.ctr += 1;
|
|
869
|
+
saveController(record, options);
|
|
870
|
+
return { record, ctr: record.ctr };
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* skipCounterAhead moves the counter forward by `by` under the lock (§14.4
|
|
875
|
+
* replay recovery): a verified `replay` means the FF1's window is ahead of
|
|
876
|
+
* this file (a restored backup, a second copy of the keys), and the window
|
|
877
|
+
* has no upper bound, so jumping ahead is always safe.
|
|
878
|
+
*
|
|
879
|
+
* @param deviceId - FF1 device ID
|
|
880
|
+
* @param by - How far to move ctr
|
|
881
|
+
* @param options - Optional directory override
|
|
882
|
+
* @returns The updated record
|
|
883
|
+
* @throws {Error} When the file is missing or corrupt, or ctr would overflow
|
|
884
|
+
*/
|
|
885
|
+
function skipCounterAhead(deviceId, by, options = {}) {
|
|
886
|
+
const file = controllerFilePath(deviceId, options);
|
|
887
|
+
return withControllerLock(file, () => {
|
|
888
|
+
const record = loadController(deviceId, options);
|
|
889
|
+
if (!record) {
|
|
890
|
+
throw new Error(`No controller file for ${deviceId}; run \`ff-cli device pair\``);
|
|
891
|
+
}
|
|
892
|
+
if (!Number.isSafeInteger(record.ctr + by + 1)) {
|
|
893
|
+
throw new Error('Controller counter exhausted; run `ff-cli device unpair` and pair again');
|
|
894
|
+
}
|
|
895
|
+
record.ctr += by;
|
|
896
|
+
saveController(record, options);
|
|
897
|
+
return record;
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* markEnrolled stores the grant from a successful enroll reply. It re-reads
|
|
902
|
+
* under the lock so a counter allocated concurrently is not rolled back. A
|
|
903
|
+
* grant without expiresAt clears any expiry the record had (§5.5: the grant
|
|
904
|
+
* states its whole state).
|
|
905
|
+
*
|
|
906
|
+
* @param deviceId - FF1 device ID
|
|
907
|
+
* @param grant - Role, nk and expiry from the reply
|
|
908
|
+
* @param options - Optional directory override
|
|
909
|
+
* @returns The updated record
|
|
910
|
+
*/
|
|
911
|
+
function markEnrolled(deviceId, grant, options = {}) {
|
|
912
|
+
const file = controllerFilePath(deviceId, options);
|
|
913
|
+
return withControllerLock(file, () => {
|
|
914
|
+
const record = loadController(deviceId, options);
|
|
915
|
+
if (!record) {
|
|
916
|
+
throw new Error(`No controller file for ${deviceId}`);
|
|
917
|
+
}
|
|
918
|
+
record.role = grant.role;
|
|
919
|
+
record.nk = grant.nk;
|
|
920
|
+
record.enrolledAt = grant.now ?? Math.floor(Date.now() / 1000);
|
|
921
|
+
if (grant.expiresAt !== undefined) {
|
|
922
|
+
record.expiresAt = grant.expiresAt;
|
|
923
|
+
}
|
|
924
|
+
else {
|
|
925
|
+
delete record.expiresAt;
|
|
926
|
+
}
|
|
927
|
+
saveController(record, options);
|
|
928
|
+
return record;
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* recordRelayV2 stores whether the FF1's latest verified status advertises
|
|
933
|
+
* relay v2, under the controller lock so a concurrent counter allocation is
|
|
934
|
+
* not lost. A missing file (unpaired meanwhile) is left alone.
|
|
935
|
+
*
|
|
936
|
+
* @param deviceId - Device ID
|
|
937
|
+
* @param relayV2 - Whether `control.transports` contained "relayV2"
|
|
938
|
+
* @param options - Store options
|
|
939
|
+
*/
|
|
940
|
+
function recordRelayV2(deviceId, relayV2, options = {}) {
|
|
941
|
+
const file = controllerFilePath(deviceId, options);
|
|
942
|
+
withControllerLock(file, () => {
|
|
943
|
+
const record = loadController(deviceId, options);
|
|
944
|
+
if (record && record.relayV2 !== relayV2) {
|
|
945
|
+
record.relayV2 = relayV2;
|
|
946
|
+
saveController(record, options);
|
|
947
|
+
}
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* describeController returns the non-secret facts of a record for display.
|
|
952
|
+
*
|
|
953
|
+
* @param record - Controller record
|
|
954
|
+
* @returns controllerId, pins and grant facts; never private material
|
|
955
|
+
*/
|
|
956
|
+
function describeController(record) {
|
|
957
|
+
return {
|
|
958
|
+
controllerId: (0, codec_1.controllerIdOf)(record.keys),
|
|
959
|
+
gen: record.profile.gen,
|
|
960
|
+
sigPin: (0, jwk_1.thumbprint)(record.profile.sig),
|
|
961
|
+
encPin: (0, jwk_1.thumbprint)(record.profile.enc),
|
|
962
|
+
role: record.role,
|
|
963
|
+
};
|
|
964
|
+
}
|