@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,141 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* P-256 JWK handling for the FF1 control protocol v1 (§2).
|
|
4
|
+
*
|
|
5
|
+
* Every key the protocol carries (device, controller, ephemeral) is a P-256
|
|
6
|
+
* JWK with EXACTLY the members kty, crv, x, y. A private `d` in a public slot
|
|
7
|
+
* is an error, never ignored: a client that ships its private key has a bug
|
|
8
|
+
* this module must not paper over. The Go reference is control/jwk.go.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.parsePublicJwk = parsePublicJwk;
|
|
12
|
+
exports.parsePrivateJwk = parsePrivateJwk;
|
|
13
|
+
exports.publicOf = publicOf;
|
|
14
|
+
exports.canonicalJwkJson = canonicalJwkJson;
|
|
15
|
+
exports.thumbprint = thumbprint;
|
|
16
|
+
exports.isThumbprint = isThumbprint;
|
|
17
|
+
exports.generatePrivateJwk = generatePrivateJwk;
|
|
18
|
+
exports.sameKey = sameKey;
|
|
19
|
+
const node_crypto_1 = require("node:crypto");
|
|
20
|
+
const strict_json_1 = require("./strict-json");
|
|
21
|
+
const COORD_LEN = 32;
|
|
22
|
+
/**
|
|
23
|
+
* parsePublicJwk validates a parsed JWK object: exact member set, `EC`/`P-256`,
|
|
24
|
+
* 32-byte canonical base64url coordinates, and a point on the curve (Node's
|
|
25
|
+
* key import runs the SEC 1 on-curve check and rejects the identity).
|
|
26
|
+
*
|
|
27
|
+
* @param value - Parsed JSON value (from parseStrictJson or a trusted literal)
|
|
28
|
+
* @param what - Name used in error messages
|
|
29
|
+
* @returns The key in canonical member order
|
|
30
|
+
* @throws {ControlFormatError} When the key is malformed or off-curve
|
|
31
|
+
*/
|
|
32
|
+
function parsePublicJwk(value, what = 'JWK') {
|
|
33
|
+
const obj = (0, strict_json_1.strictObject)(value, ['kty', 'crv', 'x', 'y'], [], what);
|
|
34
|
+
if ((0, strict_json_1.stringMember)(obj, 'kty') !== 'EC' || (0, strict_json_1.stringMember)(obj, 'crv') !== 'P-256') {
|
|
35
|
+
throw new strict_json_1.ControlFormatError(`${what} is not an EC P-256 key`);
|
|
36
|
+
}
|
|
37
|
+
const x = (0, strict_json_1.stringMember)(obj, 'x');
|
|
38
|
+
const y = (0, strict_json_1.stringMember)(obj, 'y');
|
|
39
|
+
if (!(0, strict_json_1.isB64uOfLength)(x, COORD_LEN) || !(0, strict_json_1.isB64uOfLength)(y, COORD_LEN)) {
|
|
40
|
+
throw new strict_json_1.ControlFormatError(`${what} coordinates must be 32-byte base64url`);
|
|
41
|
+
}
|
|
42
|
+
const jwk = { crv: 'P-256', kty: 'EC', x, y };
|
|
43
|
+
try {
|
|
44
|
+
(0, node_crypto_1.createPublicKey)({ key: { ...jwk }, format: 'jwk' });
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new strict_json_1.ControlFormatError(`${what} is not a point on P-256`);
|
|
48
|
+
}
|
|
49
|
+
return jwk;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* parsePrivateJwk validates a stored private JWK: exact members kty, crv, x,
|
|
53
|
+
* y, d, and `d` must reproduce `x`,`y`. A mismatch means the file was edited
|
|
54
|
+
* or corrupted, and trusting either half would sign with a key the device does
|
|
55
|
+
* not have on record.
|
|
56
|
+
*
|
|
57
|
+
* @param value - Parsed JSON value
|
|
58
|
+
* @param what - Name used in error messages
|
|
59
|
+
* @returns The private key
|
|
60
|
+
* @throws {ControlFormatError} When malformed or inconsistent
|
|
61
|
+
*/
|
|
62
|
+
function parsePrivateJwk(value, what = 'private JWK') {
|
|
63
|
+
const obj = (0, strict_json_1.strictObject)(value, ['kty', 'crv', 'x', 'y', 'd'], [], what);
|
|
64
|
+
const pub = parsePublicJwk({ kty: obj.kty, crv: obj.crv, x: obj.x, y: obj.y }, what);
|
|
65
|
+
const d = (0, strict_json_1.stringMember)(obj, 'd');
|
|
66
|
+
if (!(0, strict_json_1.isB64uOfLength)(d, COORD_LEN)) {
|
|
67
|
+
throw new strict_json_1.ControlFormatError(`${what} d must be 32-byte base64url`);
|
|
68
|
+
}
|
|
69
|
+
let derived;
|
|
70
|
+
try {
|
|
71
|
+
derived = (0, node_crypto_1.createPublicKey)((0, node_crypto_1.createPrivateKey)({ key: { ...pub, d }, format: 'jwk' })).export({
|
|
72
|
+
format: 'jwk',
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
throw new strict_json_1.ControlFormatError(`${what} is not a valid P-256 private key`);
|
|
77
|
+
}
|
|
78
|
+
if (derived.x !== pub.x || derived.y !== pub.y) {
|
|
79
|
+
throw new strict_json_1.ControlFormatError(`${what} d does not match x,y`);
|
|
80
|
+
}
|
|
81
|
+
return { ...pub, d };
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* publicOf strips a private JWK down to its canonical public members.
|
|
85
|
+
*
|
|
86
|
+
* @param key - Private or public JWK
|
|
87
|
+
* @returns The public JWK in thumbprint member order
|
|
88
|
+
*/
|
|
89
|
+
function publicOf(key) {
|
|
90
|
+
return { crv: 'P-256', kty: 'EC', x: key.x, y: key.y };
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* canonicalJwkJson is the RFC 7638 required-member serialization, which is
|
|
94
|
+
* also the wire form this CLI emits, so a JWK it writes is byte-identical to
|
|
95
|
+
* the input of its own thumbprint (§2 "SHOULD emit in thumbprint order").
|
|
96
|
+
*
|
|
97
|
+
* @param key - JWK
|
|
98
|
+
* @returns `{"crv":"P-256","kty":"EC","x":"…","y":"…"}`
|
|
99
|
+
*/
|
|
100
|
+
function canonicalJwkJson(key) {
|
|
101
|
+
return `{"crv":"P-256","kty":"EC","x":${JSON.stringify(key.x)},"y":${JSON.stringify(key.y)}}`;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* thumbprint is the RFC 7638 JWK thumbprint of §2: b64u(SHA-256) of the
|
|
105
|
+
* canonical member serialization. It is always 43 characters.
|
|
106
|
+
*
|
|
107
|
+
* @param key - JWK (private members are ignored)
|
|
108
|
+
* @returns The thumbprint
|
|
109
|
+
*/
|
|
110
|
+
function thumbprint(key) {
|
|
111
|
+
return (0, strict_json_1.b64uEncode)((0, node_crypto_1.createHash)('sha256').update(canonicalJwkJson(key), 'utf8').digest());
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* isThumbprint reports whether value is thumbprint-shaped (b64u of 32 bytes).
|
|
115
|
+
*
|
|
116
|
+
* @param value - Candidate value
|
|
117
|
+
* @returns True for a 43-character base64url SHA-256
|
|
118
|
+
*/
|
|
119
|
+
function isThumbprint(value) {
|
|
120
|
+
return typeof value === 'string' && value.length === 43 && (0, strict_json_1.isB64uOfLength)(value, 32);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* generatePrivateJwk creates a fresh P-256 key pair as a private JWK.
|
|
124
|
+
*
|
|
125
|
+
* @returns A private JWK in canonical member order plus `d`
|
|
126
|
+
*/
|
|
127
|
+
function generatePrivateJwk() {
|
|
128
|
+
const { privateKey } = (0, node_crypto_1.generateKeyPairSync)('ec', { namedCurve: 'P-256' });
|
|
129
|
+
const jwk = privateKey.export({ format: 'jwk' });
|
|
130
|
+
return { crv: 'P-256', kty: 'EC', x: jwk.x, y: jwk.y, d: jwk.d };
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* sameKey reports whether two JWKs are the same point.
|
|
134
|
+
*
|
|
135
|
+
* @param a - First key
|
|
136
|
+
* @param b - Second key
|
|
137
|
+
* @returns True when x and y match
|
|
138
|
+
*/
|
|
139
|
+
function sameKey(a, b) {
|
|
140
|
+
return a.x === b.x && a.y === b.y;
|
|
141
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The pairing channel of owner-initiated registration
|
|
4
|
+
* (ffos-user docs/ff1-control-v1.md §5.6 step 1): a relay channel named by
|
|
5
|
+
* the random channelId in the controller QR, on which the owner's app sends
|
|
6
|
+
* the sealed registration result.
|
|
7
|
+
*
|
|
8
|
+
* ff-cli only consumes this interface. The relay v2 carrier
|
|
9
|
+
* (`/v2/channels/{channelId}/connections`) is implemented by
|
|
10
|
+
* relay-pairing-channel.ts, which plugs in with setPairingChannelOpener at
|
|
11
|
+
* startup when a v2 endpoint is configured. Without one (or with invalid
|
|
12
|
+
* relayer settings) the default opener
|
|
13
|
+
* fails with PairingTransportUnavailableError, so the CLI says so instead of
|
|
14
|
+
* waiting on nothing.
|
|
15
|
+
*
|
|
16
|
+
* The channel is untrusted: anyone who saw the QR can join it and send bytes.
|
|
17
|
+
* Every message is opened and checked by registration.ts before it means
|
|
18
|
+
* anything; the channel carries no authority of its own.
|
|
19
|
+
*/
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.PairingTransportUnavailableError = void 0;
|
|
22
|
+
exports.setPairingChannelOpener = setPairingChannelOpener;
|
|
23
|
+
exports.pairingChannelOpener = pairingChannelOpener;
|
|
24
|
+
/** PairingTransportUnavailableError is thrown when relay v2 is off, so no pairing channel is installed. */
|
|
25
|
+
class PairingTransportUnavailableError extends Error {
|
|
26
|
+
constructor() {
|
|
27
|
+
super('owner-initiated pairing needs the relay v2 pairing channel, and relay v2 is turned off (check ff1Relayer.v2Endpoint, FF1_RELAY_V2_ENDPOINT and ff1Relayer.baseUrl)');
|
|
28
|
+
this.name = 'PairingTransportUnavailableError';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
exports.PairingTransportUnavailableError = PairingTransportUnavailableError;
|
|
32
|
+
const unavailableOpener = async () => {
|
|
33
|
+
throw new PairingTransportUnavailableError();
|
|
34
|
+
};
|
|
35
|
+
let currentOpener = unavailableOpener;
|
|
36
|
+
/**
|
|
37
|
+
* setPairingChannelOpener installs the relay adapter's opener; undefined
|
|
38
|
+
* restores the default (unavailable) one.
|
|
39
|
+
*
|
|
40
|
+
* @param opener - Opener to use for `ff-cli device pair`
|
|
41
|
+
*/
|
|
42
|
+
function setPairingChannelOpener(opener) {
|
|
43
|
+
currentOpener = opener ?? unavailableOpener;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* pairingChannelOpener returns the installed opener.
|
|
47
|
+
*
|
|
48
|
+
* @returns The opener `device pair` uses unless a caller injects one
|
|
49
|
+
*/
|
|
50
|
+
function pairingChannelOpener() {
|
|
51
|
+
return currentOpener;
|
|
52
|
+
}
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Controller pairing for ff-cli: owner-initiated registration
|
|
4
|
+
* (ffos-user docs/ff1-control-v1.md §5.6, §14.8; feral-file#3551).
|
|
5
|
+
*
|
|
6
|
+
* Sequence:
|
|
7
|
+
* 1. chooseControllerKeys: this computer's existing keys for the FF1 when it
|
|
8
|
+
* has a readable controller file (so a re-pair after revocation, a role
|
|
9
|
+
* change or a factory reset keeps one controllerId on the device instead
|
|
10
|
+
* of orphaning a grant), else the pairing keys saved before the QR was
|
|
11
|
+
* first shown (so a retry after a timeout or Ctrl-C completes a grant an
|
|
12
|
+
* owner already registered), else new pairing keys, saved before use.
|
|
13
|
+
* 2. The caller shows the controller QR (registration.ts) and waits on the
|
|
14
|
+
* pairing channel for a sealed result that passes every §5.6.3 check.
|
|
15
|
+
* 3. prepareRegisteredController pins the result's profile: on the existing
|
|
16
|
+
* record when it is the same keys and identity, else on a new record that
|
|
17
|
+
* replaces an existing one only once enrolled (the staging store).
|
|
18
|
+
* 4. enrollRegistered sends a protected op:enroll without inv (§5.3) and
|
|
19
|
+
* stores role, nk and expiry from the verified reply.
|
|
20
|
+
*
|
|
21
|
+
* State is always consistent on interruption: before a verified result only
|
|
22
|
+
* the pairing keys are written, the record is written atomically before the first
|
|
23
|
+
* request, every counter is persisted before it is sent, and the grant is
|
|
24
|
+
* written only from a verified enroll reply. A record with a verified result
|
|
25
|
+
* that has not enrolled yet is finished by `ff-cli device pair --resume`.
|
|
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.CONTROL_PROBE_NAME_TIMEOUT_MS = exports.CONTROL_PROBE_TIMEOUT_MS = exports.ENROLL_RETRY_MS = exports.ENROLL_ATTEMPTS = exports.MAX_PAIR_TIMEOUT_MS = exports.DEFAULT_PAIR_TIMEOUT_MS = void 0;
|
|
32
|
+
exports.defaultControllerLabel = defaultControllerLabel;
|
|
33
|
+
exports.chooseControllerKeys = chooseControllerKeys;
|
|
34
|
+
exports.lanReadTimeoutMs = lanReadTimeoutMs;
|
|
35
|
+
exports.probeControlSupport = probeControlSupport;
|
|
36
|
+
exports.registrationRecordOf = registrationRecordOf;
|
|
37
|
+
exports.prepareRegisteredController = prepareRegisteredController;
|
|
38
|
+
exports.enrollRegistered = enrollRegistered;
|
|
39
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
40
|
+
const http_1 = require("../http");
|
|
41
|
+
const codec_1 = require("./codec");
|
|
42
|
+
const jwk_1 = require("./jwk");
|
|
43
|
+
const controller_store_1 = require("./controller-store");
|
|
44
|
+
const secure_transport_1 = require("./secure-transport");
|
|
45
|
+
const control_errors_1 = require("./control-errors");
|
|
46
|
+
const strict_json_1 = require("./strict-json");
|
|
47
|
+
/** DEFAULT_PAIR_TIMEOUT_MS is how long `device pair` waits for an owner by default. */
|
|
48
|
+
exports.DEFAULT_PAIR_TIMEOUT_MS = 5 * 60_000;
|
|
49
|
+
/** MAX_PAIR_TIMEOUT_MS bounds `--timeout`: the relay keeps nothing, so a QR is a short-lived offer. */
|
|
50
|
+
exports.MAX_PAIR_TIMEOUT_MS = 10 * 60_000;
|
|
51
|
+
/** ENROLL_ATTEMPTS bounds the enroll retries after a verified result (network blips). */
|
|
52
|
+
exports.ENROLL_ATTEMPTS = 5;
|
|
53
|
+
/** ENROLL_RETRY_MS is the pause between enroll attempts. */
|
|
54
|
+
exports.ENROLL_RETRY_MS = 2_000;
|
|
55
|
+
/**
|
|
56
|
+
* defaultControllerLabel is the label the owner sees when adding ff-cli:
|
|
57
|
+
* this machine's hostname, trimmed to the protocol's 64 characters.
|
|
58
|
+
*
|
|
59
|
+
* @returns A 1-64 character label
|
|
60
|
+
*/
|
|
61
|
+
function defaultControllerLabel() {
|
|
62
|
+
const cleaned = (0, codec_1.stripUnsafeLabelCharacters)(node_os_1.default.hostname()).trim();
|
|
63
|
+
const label = [...cleaned].slice(0, 64).join('').trim();
|
|
64
|
+
return label || 'ff-cli';
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* chooseControllerKeys picks the keys the controller QR shows, always keys
|
|
68
|
+
* already on disk: those of the device's controller file when it is readable
|
|
69
|
+
* (enrolled or not), else the saved pairing keys (created and saved now when
|
|
70
|
+
* there are none). A corrupt controller file is moved aside, never written
|
|
71
|
+
* over, once a result arrives. An unreadable file throws: its keys may be
|
|
72
|
+
* fine, so nothing may pair past it.
|
|
73
|
+
*
|
|
74
|
+
* @param deviceId - FF1 device ID
|
|
75
|
+
* @param options - Main store options
|
|
76
|
+
* @returns The keys, whether they were already saved, and whether they are
|
|
77
|
+
* the controller file's (else the pairing keys file's)
|
|
78
|
+
*/
|
|
79
|
+
function chooseControllerKeys(deviceId, options = {}) {
|
|
80
|
+
let existing = null;
|
|
81
|
+
try {
|
|
82
|
+
existing = (0, controller_store_1.loadController)(deviceId, options);
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
if (!(error instanceof controller_store_1.ControllerFileCorruptError)) {
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (existing) {
|
|
90
|
+
return { keys: existing.keys, reused: true, fromRecord: true };
|
|
91
|
+
}
|
|
92
|
+
return { ...(0, controller_store_1.pairingKeys)(deviceId, options), fromRecord: false };
|
|
93
|
+
}
|
|
94
|
+
/** CONTROL_PROBE_TIMEOUT_MS bounds the LAN status read that picks the pairing path. */
|
|
95
|
+
exports.CONTROL_PROBE_TIMEOUT_MS = 5_000;
|
|
96
|
+
/**
|
|
97
|
+
* CONTROL_PROBE_NAME_TIMEOUT_MS bounds the read by a `.local` name. macOS answers such a lookup
|
|
98
|
+
* after about 5 s, the whole of CONTROL_PROBE_TIMEOUT_MS, so the name gets room for the lookup.
|
|
99
|
+
*/
|
|
100
|
+
exports.CONTROL_PROBE_NAME_TIMEOUT_MS = 12_000;
|
|
101
|
+
/**
|
|
102
|
+
* lanReadTimeoutMs is the deadline for one LAN read of host: a `.local` name gets room for the
|
|
103
|
+
* macOS lookup (CONTROL_PROBE_NAME_TIMEOUT_MS), anything else shortMs.
|
|
104
|
+
*
|
|
105
|
+
* @param host - Device base URL, e.g. http://ff1-abcd1234.local:1111
|
|
106
|
+
* @param shortMs - Deadline for an address or a unicast name
|
|
107
|
+
* @returns Milliseconds
|
|
108
|
+
*/
|
|
109
|
+
function lanReadTimeoutMs(host, shortMs = exports.CONTROL_PROBE_TIMEOUT_MS) {
|
|
110
|
+
try {
|
|
111
|
+
return new URL(host).hostname.toLowerCase().endsWith('.local')
|
|
112
|
+
? exports.CONTROL_PROBE_NAME_TIMEOUT_MS
|
|
113
|
+
: shortMs;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return shortMs;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* probeBases lists where to read the status: for a `.local` host, an address discovery
|
|
121
|
+
* recorded comes first (no name lookup; IPv4 preferred, since a first-listed IPv6 entry may be
|
|
122
|
+
* link-local or unreachable), then the name itself.
|
|
123
|
+
*
|
|
124
|
+
* Trade-off: unlike the name, which carries the device ID, a stale address may now belong to
|
|
125
|
+
* another FF1 in the same home, whose answer then picks the UI. That is a wrong prompt, never
|
|
126
|
+
* a grant: the status only chooses which pairing to offer, and both paths verify the device.
|
|
127
|
+
* The read carries the configured API key, so such a device would see it, as one answering on
|
|
128
|
+
* a reused name would.
|
|
129
|
+
* Worst case before the code shows: CONTROL_PROBE_TIMEOUT_MS + CONTROL_PROBE_NAME_TIMEOUT_MS.
|
|
130
|
+
*/
|
|
131
|
+
function probeBases(device) {
|
|
132
|
+
let url = null;
|
|
133
|
+
try {
|
|
134
|
+
url = new URL(device.host);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
url = null;
|
|
138
|
+
}
|
|
139
|
+
const byName = url !== null && url.hostname.toLowerCase().endsWith('.local');
|
|
140
|
+
const bases = [];
|
|
141
|
+
const recorded = byName ? (device.addresses ?? []).filter((value) => value.trim() !== '') : [];
|
|
142
|
+
const address = recorded.find((value) => !value.includes(':')) ?? recorded[0];
|
|
143
|
+
if (url && address) {
|
|
144
|
+
const host = address.includes(':') ? `[${address}]` : address;
|
|
145
|
+
bases.push({
|
|
146
|
+
base: `${url.protocol}//${host}${url.port ? `:${url.port}` : ''}`,
|
|
147
|
+
byName: false,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
bases.push({ base: device.host, byName });
|
|
151
|
+
return bases;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* probeControlSupport reads `getDeviceStatus` in plaintext over the LAN
|
|
155
|
+
* (§11: the `control` block is the capability signal). An owned FF1 refuses
|
|
156
|
+
* plaintext with a public `enrollment_required` (§7.2), which also means
|
|
157
|
+
* secure. The answer is unauthenticated and only picks the pairing UI; no
|
|
158
|
+
* profile is ever taken from it.
|
|
159
|
+
*
|
|
160
|
+
* A `.local` host is read at the address discovery recorded first, then by
|
|
161
|
+
* name (see probeBases); the first read that is not `unknown` decides.
|
|
162
|
+
*
|
|
163
|
+
* @param device - Device with host, optional addresses and API key
|
|
164
|
+
* @param fetchFn - Fetch (tests); defaults to a per-read deadline
|
|
165
|
+
* @returns The pairing path the status points to
|
|
166
|
+
*/
|
|
167
|
+
async function probeControlSupport(device, fetchFn) {
|
|
168
|
+
let support = 'unknown';
|
|
169
|
+
for (const { base, byName } of probeBases(device)) {
|
|
170
|
+
const fetchAt = fetchFn ??
|
|
171
|
+
((input, init) => (0, http_1.fetchWithTimeout)(input, init ?? {}, byName ? lanReadTimeoutMs(base) : exports.CONTROL_PROBE_TIMEOUT_MS));
|
|
172
|
+
support = await probeControlSupportAt(device, base, fetchAt);
|
|
173
|
+
if (support !== 'unknown') {
|
|
174
|
+
return support;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return support;
|
|
178
|
+
}
|
|
179
|
+
/** probeControlSupportAt is one status read at base (see probeControlSupport). */
|
|
180
|
+
async function probeControlSupportAt(device, base, fetchFn) {
|
|
181
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
182
|
+
if (device.apiKey) {
|
|
183
|
+
headers['API-KEY'] = device.apiKey;
|
|
184
|
+
}
|
|
185
|
+
let body;
|
|
186
|
+
let ok;
|
|
187
|
+
try {
|
|
188
|
+
const response = await fetchFn(`${base}/api/cast`, {
|
|
189
|
+
method: 'POST',
|
|
190
|
+
headers,
|
|
191
|
+
body: JSON.stringify({ command: 'getDeviceStatus', request: {} }),
|
|
192
|
+
});
|
|
193
|
+
ok = response.ok;
|
|
194
|
+
body = JSON.parse(await response.text());
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return 'unknown';
|
|
198
|
+
}
|
|
199
|
+
if (!(0, strict_json_1.isPlainObject)(body)) {
|
|
200
|
+
return 'unknown';
|
|
201
|
+
}
|
|
202
|
+
const error = body.error;
|
|
203
|
+
if ((0, strict_json_1.isPlainObject)(error) && typeof error.code === 'string') {
|
|
204
|
+
return ['enrollment_required', 'envelope_required', 'locked'].includes(error.code)
|
|
205
|
+
? 'secure'
|
|
206
|
+
: 'unknown';
|
|
207
|
+
}
|
|
208
|
+
// The LAN hub answers with the bare status object (FF OS 2.0.12 and
|
|
209
|
+
// later); a relayer-shaped `{message: status}` is accepted too.
|
|
210
|
+
const status = (0, strict_json_1.isPlainObject)(body.message) ? body.message : body;
|
|
211
|
+
if (!ok || status.ok === false) {
|
|
212
|
+
return 'unknown';
|
|
213
|
+
}
|
|
214
|
+
if (status.control === undefined) {
|
|
215
|
+
return 'plaintext';
|
|
216
|
+
}
|
|
217
|
+
return (0, strict_json_1.isPlainObject)(status.control) && status.control.state === 'legacy'
|
|
218
|
+
? 'plaintext'
|
|
219
|
+
: 'secure';
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* registrationRecordOf keeps the facts of a verified approved result.
|
|
223
|
+
*
|
|
224
|
+
* @param result - Verified approved result
|
|
225
|
+
* @param now - Unix seconds
|
|
226
|
+
* @returns The facts stored in the controller file
|
|
227
|
+
*/
|
|
228
|
+
function registrationRecordOf(result, now) {
|
|
229
|
+
const registration = {
|
|
230
|
+
channelId: result.channelId,
|
|
231
|
+
deviceName: result.deviceName,
|
|
232
|
+
role: result.role,
|
|
233
|
+
receivedAt: now,
|
|
234
|
+
};
|
|
235
|
+
if (result.expiresAt !== undefined) {
|
|
236
|
+
registration.expiresAt = result.expiresAt;
|
|
237
|
+
}
|
|
238
|
+
if (result.topicId !== undefined) {
|
|
239
|
+
registration.topicId = result.topicId;
|
|
240
|
+
}
|
|
241
|
+
return registration;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* prepareRegisteredController pins a verified approved result. The existing
|
|
245
|
+
* record keeps its file (and counter) when it is the same keys under the same
|
|
246
|
+
* identity. Otherwise the result goes to a new record: the main file when
|
|
247
|
+
* there is none (a corrupt one is moved aside first, it may hold the only
|
|
248
|
+
* copy of an owner key), else the staging store, which replaces the existing
|
|
249
|
+
* record only once enrolled, so a failed pairing never costs a grant.
|
|
250
|
+
*
|
|
251
|
+
* @param input - Pinned profile, the keys the owner registered, label and result facts
|
|
252
|
+
* @param options - Main store options
|
|
253
|
+
* @returns The record and whether it is staged
|
|
254
|
+
*/
|
|
255
|
+
function prepareRegisteredController(input, options = {}) {
|
|
256
|
+
const deviceId = input.profile.dev;
|
|
257
|
+
let existing = null;
|
|
258
|
+
try {
|
|
259
|
+
existing = (0, controller_store_1.loadController)(deviceId, options);
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
if (!(error instanceof controller_store_1.ControllerFileCorruptError)) {
|
|
263
|
+
throw error; // unreadable: its keys may be fine, never write over them
|
|
264
|
+
}
|
|
265
|
+
(0, controller_store_1.setAsideCorruptController)(deviceId, options);
|
|
266
|
+
}
|
|
267
|
+
if (existing &&
|
|
268
|
+
(0, jwk_1.sameKey)(existing.keys.sig, input.keys.sig) &&
|
|
269
|
+
(0, jwk_1.sameKey)(existing.keys.enc, input.keys.enc) &&
|
|
270
|
+
(0, controller_store_1.sameIdentity)(existing.profile, input.profile)) {
|
|
271
|
+
// A staged record left by an abandoned replacement pairing is superseded
|
|
272
|
+
// by this result, and --resume must not pick it up later.
|
|
273
|
+
(0, controller_store_1.dropStagedController)(deviceId, false, options);
|
|
274
|
+
const record = (0, controller_store_1.recordRegistration)(deviceId, { label: input.label, registration: input.registration }, options);
|
|
275
|
+
return { record, staged: false };
|
|
276
|
+
}
|
|
277
|
+
const staged = existing !== null;
|
|
278
|
+
const record = (0, controller_store_1.createController)({
|
|
279
|
+
profile: input.profile,
|
|
280
|
+
label: input.label,
|
|
281
|
+
keys: input.keys,
|
|
282
|
+
registration: input.registration,
|
|
283
|
+
exclusive: !staged,
|
|
284
|
+
}, staged ? (0, controller_store_1.stagingStore)(options) : options);
|
|
285
|
+
return { record, staged };
|
|
286
|
+
}
|
|
287
|
+
/** enrollObject builds the §5 enroll claim for this controller. */
|
|
288
|
+
function enrollObject(record) {
|
|
289
|
+
return {
|
|
290
|
+
sig: (0, jwk_1.publicOf)(record.keys.sig),
|
|
291
|
+
enc: (0, jwk_1.publicOf)(record.keys.enc),
|
|
292
|
+
label: record.label,
|
|
293
|
+
platform: 'cli',
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
function asRole(value) {
|
|
297
|
+
return value === 'owner' || value === 'controller' ? value : undefined;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* parseGrant validates an enroll success `res` (§5.3): role, nk, optional
|
|
301
|
+
* expiresAt, and a profile that must be the pinned one (a device whose
|
|
302
|
+
* profile changed under the same keys is a bug or an attack; either way
|
|
303
|
+
* nothing is stored).
|
|
304
|
+
*/
|
|
305
|
+
function parseGrant(res, pinned) {
|
|
306
|
+
if (!(0, strict_json_1.isPlainObject)(res)) {
|
|
307
|
+
return 'enroll reply has no grant';
|
|
308
|
+
}
|
|
309
|
+
const role = asRole(res.role);
|
|
310
|
+
if (!role || !(0, strict_json_1.isB64uOfLength)(res.nk, 32)) {
|
|
311
|
+
return 'enroll reply has an invalid role or nk';
|
|
312
|
+
}
|
|
313
|
+
const expiresAt = res.expiresAt;
|
|
314
|
+
if (expiresAt !== undefined && (!Number.isSafeInteger(expiresAt) || expiresAt < 0)) {
|
|
315
|
+
return 'enroll reply has an invalid expiresAt';
|
|
316
|
+
}
|
|
317
|
+
const profile = res.profile;
|
|
318
|
+
if (!profile ||
|
|
319
|
+
profile.gen !== pinned.gen ||
|
|
320
|
+
profile.dev !== pinned.dev ||
|
|
321
|
+
!profile.sig ||
|
|
322
|
+
!(0, jwk_1.sameKey)(profile.sig, pinned.sig) ||
|
|
323
|
+
!profile.enc ||
|
|
324
|
+
!(0, jwk_1.sameKey)(profile.enc, pinned.enc)) {
|
|
325
|
+
return 'enroll reply carries a different FF1 profile than the one the owner sent';
|
|
326
|
+
}
|
|
327
|
+
const grant = {
|
|
328
|
+
role,
|
|
329
|
+
nk: res.nk,
|
|
330
|
+
};
|
|
331
|
+
if (expiresAt !== undefined) {
|
|
332
|
+
grant.expiresAt = expiresAt;
|
|
333
|
+
}
|
|
334
|
+
return grant;
|
|
335
|
+
}
|
|
336
|
+
/** failureFrom maps a non-success outcome to a failure. */
|
|
337
|
+
function failureFrom(outcome, label) {
|
|
338
|
+
if (outcome.kind === 'failed') {
|
|
339
|
+
return { code: outcome.code ?? 'failed', error: outcome.error, details: outcome.details };
|
|
340
|
+
}
|
|
341
|
+
const code = outcome.kind === 'public' ? outcome.code : (outcome.reply.code ?? 'unknown');
|
|
342
|
+
return (0, control_errors_1.describeControlCode)(code, label);
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* enrollRegistered sends op:enroll (no inv) until the FF1 returns the grant
|
|
346
|
+
* an owner registered, a definite refusal arrives, the attempts run out, or
|
|
347
|
+
* the user cancels. Each attempt is a new request with a new counter; enroll
|
|
348
|
+
* is idempotent (§5.3), so a lost reply is simply asked again.
|
|
349
|
+
*
|
|
350
|
+
* @param device - Device with ID
|
|
351
|
+
* @param record - Controller record with a pinned profile
|
|
352
|
+
* @param deps - Transport seams; controllerStore is the store the record is in
|
|
353
|
+
* @param options - Attempt tuning
|
|
354
|
+
* @returns The enrolled record or why enrollment stopped
|
|
355
|
+
*/
|
|
356
|
+
async function enrollRegistered(device, record, deps = {}, options = {}) {
|
|
357
|
+
const attempts = options.attempts ?? exports.ENROLL_ATTEMPTS;
|
|
358
|
+
const wait = deps.waitFn ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
359
|
+
const label = device.name || device.id;
|
|
360
|
+
let last = null;
|
|
361
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
362
|
+
if (options.cancelled?.()) {
|
|
363
|
+
return { status: 'cancelled' };
|
|
364
|
+
}
|
|
365
|
+
const outcome = await (0, secure_transport_1.sendSecureRequest)({ device, op: 'enroll', enroll: enrollObject(record), relayerFallback: true, retry: false }, record, { ...deps, noticeFn: () => undefined });
|
|
366
|
+
const step = interpretEnrollOutcome(outcome, record, label, options.storeOptions);
|
|
367
|
+
if (step.status !== 'transient') {
|
|
368
|
+
return step;
|
|
369
|
+
}
|
|
370
|
+
last = { status: 'failed', failure: step.failure };
|
|
371
|
+
if (attempt < attempts) {
|
|
372
|
+
await wait(options.retryMs ?? exports.ENROLL_RETRY_MS);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return last;
|
|
376
|
+
}
|
|
377
|
+
/** interpretEnrollOutcome returns a final result, or a transient failure worth asking again. */
|
|
378
|
+
function interpretEnrollOutcome(outcome, record, label, storeOptions) {
|
|
379
|
+
if (outcome.kind === 'reply' && outcome.reply.ok) {
|
|
380
|
+
const grant = parseGrant(outcome.reply.res, record.profile);
|
|
381
|
+
if (typeof grant === 'string') {
|
|
382
|
+
return { status: 'failed', failure: { code: 'invalid_grant', error: grant } };
|
|
383
|
+
}
|
|
384
|
+
return { status: 'enrolled', record: (0, controller_store_1.markEnrolled)(record.deviceId, grant, storeOptions) };
|
|
385
|
+
}
|
|
386
|
+
// busy (§4.4): the FF1 was authenticating too many envelopes and did not
|
|
387
|
+
// run this one; the next attempt is a new request anyway.
|
|
388
|
+
if (outcome.kind === 'public' && outcome.code === 'busy') {
|
|
389
|
+
return { status: 'transient', failure: failureFrom(outcome, label) };
|
|
390
|
+
}
|
|
391
|
+
if (outcome.kind === 'reply') {
|
|
392
|
+
const code = outcome.reply.code;
|
|
393
|
+
if (code === 'rate_limited' || code === 'internal_error') {
|
|
394
|
+
return { status: 'transient', failure: failureFrom(outcome, label) };
|
|
395
|
+
}
|
|
396
|
+
if (code === 'not_approved' || code === 'pending') {
|
|
397
|
+
return {
|
|
398
|
+
status: 'failed',
|
|
399
|
+
failure: {
|
|
400
|
+
code,
|
|
401
|
+
error: 'The FF1 has no grant for ff-cli, although an owner’s Feral File app reported adding it.',
|
|
402
|
+
details: `An owner may have removed it again. Run \`${(0, control_errors_1.pairCommand)(label)}\` to show a new code.`,
|
|
403
|
+
},
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
// The FF1 or the relayer may come back (Wi-Fi blip, relayer restart).
|
|
408
|
+
// Protocol answers (codes) are never transient.
|
|
409
|
+
if (outcome.kind === 'failed' &&
|
|
410
|
+
(outcome.unreachable ||
|
|
411
|
+
outcome.timedOut || // enroll is idempotent (§5.3): asking again is safe
|
|
412
|
+
(outcome.transport === 'relayer' && !outcome.code))) {
|
|
413
|
+
return { status: 'transient', failure: failureFrom(outcome, label) };
|
|
414
|
+
}
|
|
415
|
+
return { status: 'failed', failure: failureFrom(outcome, label) };
|
|
416
|
+
}
|