@twinsearth/w2m-dsh-plugin 0.8.10 → 0.8.99-tokenprobe.1
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/CHANGELOG.md +122 -0
- package/bin/w2m-localside.mjs +325 -325
- package/package.json +3 -2
- package/src/chatops/account.mjs +432 -412
- package/src/chatops/index.mjs +440 -440
- package/src/plugin/auto-update.mjs +31 -2
- package/src/plugin/tools.mjs +5207 -4878
- package/src/relay/state.mjs +2277 -2277
package/src/chatops/account.mjs
CHANGED
|
@@ -1,412 +1,432 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Account identity, the serial number, and the hardware fingerprint (v0.7.5).
|
|
3
|
-
*
|
|
4
|
-
* ## What this is
|
|
5
|
-
*
|
|
6
|
-
* Stage 1 of `docs/ACCOUNT-FLEET-DESIGN.md`: the root of the whole scheme. Everything later —
|
|
7
|
-
* the device keypair, the relay's authorisation model, the fleet-scoped dispatch — depends on
|
|
8
|
-
* knowing *which account* a machine belongs to and *which machine* it is. Neither existed before
|
|
9
|
-
* this file: `machine_id` was a bare `randomUUID()` with no account and no hardware in it.
|
|
10
|
-
*
|
|
11
|
-
* ## The identity source is read, not fetched
|
|
12
|
-
*
|
|
13
|
-
* The account code comes from `ctx.deepseekAccount.getDeviceIdentity()`, whose contract is
|
|
14
|
-
* *"read existing login identity without creating a device or returning credentials"*. It
|
|
15
|
-
* returns `{ deviceId?, userId?, osVersion }`, and `userId` is the account's unique identifier.
|
|
16
|
-
*
|
|
17
|
-
* So there is **no account API call here**, and there is deliberately **no way to set the account
|
|
18
|
-
* code from configuration**. That second point is not a limitation to work around: a value an
|
|
19
|
-
* operator can type is a value an attacker can type, so a hand-entered "account code" would be a
|
|
20
|
-
* forgeable account code. The only accepted source is the one the host authenticates.
|
|
21
|
-
*
|
|
22
|
-
* ## Three things this file refuses to pretend
|
|
23
|
-
*
|
|
24
|
-
* **1. The serial number is not a credential.** It is computed from local inputs, so anyone who
|
|
25
|
-
* knows or guesses an account id can produce a well-formed one. It exists to be **displayed** and
|
|
26
|
-
* to be **grouped by prefix**, and the relay must never trust it. `isWellFormedSn` validates
|
|
27
|
-
* *shape*, and its name says shape — it is not and must not be called an authorisation check.
|
|
28
|
-
*
|
|
29
|
-
* **2. The timestamp is not what makes an SN unique.** Uniqueness is `(ACCT, HWID)`. The timestamp
|
|
30
|
-
* distinguishes a re-registration of the same hardware from a second machine, and makes the string
|
|
31
|
-
* auditable. Calling it a security feature would be an overclaim.
|
|
32
|
-
*
|
|
33
|
-
* **3. A fingerprint that changes is worse than none.** It makes one machine look like two, which
|
|
34
|
-
* fragments a fleet. So sources that change in normal use — MAC addresses, IPs, mount points,
|
|
35
|
-
* installed-package lists — are **excluded on purpose**, and each exclusion is a choice with a
|
|
36
|
-
* reason rather than an omission.
|
|
37
|
-
*/
|
|
38
|
-
|
|
39
|
-
import { createHash } from 'node:crypto';
|
|
40
|
-
// v0.8.1: the account-derived fleet key, so a machine joins its account's fleet with no human step.
|
|
41
|
-
import { deriveAccountFleetKey, encodeFleetKey } from './fleet-join.mjs';
|
|
42
|
-
import { execFile } from 'node:child_process';
|
|
43
|
-
import { readFile } from 'node:fs/promises';
|
|
44
|
-
import { promisify } from 'node:util';
|
|
45
|
-
|
|
46
|
-
const run = promisify(execFile);
|
|
47
|
-
|
|
48
|
-
/** SN shape. `W2M-` then three dash-separated hex groups of 8, 12 and 14 characters. */
|
|
49
|
-
export const SN_PATTERN = /^W2M-([0-9a-f]{8})-([0-9a-f]{12})-(\d{14})$/;
|
|
50
|
-
|
|
51
|
-
/** How much of a hash goes into the account code. 8 hex = 32 bits. See the collision note below. */
|
|
52
|
-
export const ACCT_HEX = 8;
|
|
53
|
-
/** How much goes into the hardware code. 12 hex = 48 bits. */
|
|
54
|
-
export const HWID_HEX = 12;
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Derive the account code.
|
|
58
|
-
*
|
|
59
|
-
* **Hashed, not raw.** The `userId` is an account identifier, and this code ends up in places a
|
|
60
|
-
* login identifier should not: device lists, log lines, chat messages, `w2m_devices` output.
|
|
61
|
-
* Taking a truncated hash keeps the one property grouping needs — the same account hashes the
|
|
62
|
-
* same way on every machine — without shipping the identifier itself around.
|
|
63
|
-
*
|
|
64
|
-
* **The cost of 32 bits, stated rather than hidden.** Two different accounts colliding would
|
|
65
|
-
* merge two fleets. That needs roughly 2^16 distinct accounts on one relay before it is likely,
|
|
66
|
-
* which is far beyond any real deployment; the design document records the detection step for the
|
|
67
|
-
* case where it is not.
|
|
68
|
-
*/
|
|
69
|
-
export function accountCode(userId) {
|
|
70
|
-
if (typeof userId !== 'string' || userId.trim() === '') return null;
|
|
71
|
-
return createHash('sha256').update(userId.trim(), 'utf8').digest('hex').slice(0, ACCT_HEX);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Hash a fingerprint string into the hardware code.
|
|
76
|
-
*
|
|
77
|
-
* Normalised first — trimmed, lower-cased, and with runs of whitespace collapsed — because the
|
|
78
|
-
* same machine reports the same value with different spacing depending on the tool that read it,
|
|
79
|
-
* and a fingerprint that changes with its reader is a fingerprint that changes.
|
|
80
|
-
*/
|
|
81
|
-
export function hardwareCode(...parts) {
|
|
82
|
-
const material = parts
|
|
83
|
-
.filter((p) => typeof p === 'string' && p.trim() !== '')
|
|
84
|
-
.map((p) => p.trim().toLowerCase().replace(/\s+/g, ' '))
|
|
85
|
-
.join('\n');
|
|
86
|
-
if (material === '') return null;
|
|
87
|
-
return createHash('sha256').update(material, 'utf8').digest('hex').slice(0, HWID_HEX);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** `YYYYMMDDhhmmss`, UTC. */
|
|
91
|
-
export function timeCode(when = new Date()) {
|
|
92
|
-
const d = when instanceof Date ? when : new Date(when);
|
|
93
|
-
if (Number.isNaN(d.getTime())) throw new TypeError('timeCode needs a valid date');
|
|
94
|
-
const p = (n, width = 2) => String(n).padStart(width, '0');
|
|
95
|
-
return (
|
|
96
|
-
`${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}` +
|
|
97
|
-
`${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}`
|
|
98
|
-
);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Build the serial number.
|
|
103
|
-
*
|
|
104
|
-
* `W2M-<ACCT>-<HWID>-<TS>`. Refuses to produce a partial one: an SN missing its account code
|
|
105
|
-
* would be a machine that claims no fleet, and silently emitting that is how a machine ends up
|
|
106
|
-
* in the wrong pool.
|
|
107
|
-
*/
|
|
108
|
-
export function buildSn({ accountCode: acct, hardwareCode: hwid, at = new Date() } = {}) {
|
|
109
|
-
if (typeof acct !== 'string' || !/^[0-9a-f]{8}$/.test(acct)) {
|
|
110
|
-
throw new TypeError('buildSn needs an 8-hex account code; without one a machine claims no fleet');
|
|
111
|
-
}
|
|
112
|
-
if (typeof hwid !== 'string' || !/^[0-9a-f]{12}$/.test(hwid)) {
|
|
113
|
-
throw new TypeError('buildSn needs a 12-hex hardware code');
|
|
114
|
-
}
|
|
115
|
-
return `W2M-${acct}-${hwid}-${timeCode(at)}`;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Does this string have the shape of an SN?
|
|
120
|
-
*
|
|
121
|
-
* **Shape, not authorisation.** The name is deliberate: this answers "is it well formed", and a
|
|
122
|
-
* caller that treats a `true` here as proof of anything is misusing it. Nothing about an SN is
|
|
123
|
-
* verifiable by inspection, because it was computed by the party being checked.
|
|
124
|
-
*/
|
|
125
|
-
export function isWellFormedSn(value) {
|
|
126
|
-
return typeof value === 'string' && SN_PATTERN.test(value);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** The parts of an SN, or null if it is not well formed. */
|
|
130
|
-
export function parseSn(value) {
|
|
131
|
-
const m = typeof value === 'string' ? SN_PATTERN.exec(value) : null;
|
|
132
|
-
if (m === null) return null;
|
|
133
|
-
return { accountCode: m[1], hardwareCode: m[2], timeCode: m[3] };
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/** The account prefix an SN belongs to, for grouping. Null when the SN is malformed. */
|
|
137
|
-
export function fleetOf(value) {
|
|
138
|
-
const parts = parseSn(value);
|
|
139
|
-
return parts === null ? null : parts.accountCode;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Hardware sources, per platform, in priority order.
|
|
144
|
-
*
|
|
145
|
-
* Each entry says how to read a stable machine identity. `key` names the field in the returned
|
|
146
|
-
* diagnostic so an operator can see *which* source was used — a fingerprint whose origin is
|
|
147
|
-
* unknown is one nobody can debug when it changes unexpectedly.
|
|
148
|
-
*
|
|
149
|
-
* **What is deliberately absent, and why:**
|
|
150
|
-
* * **MAC addresses / IPs** — change with a cable, a dock, a VPN, or a Wi-Fi toggle. A machine
|
|
151
|
-
* that re-fingerprints after a network change becomes a second device in the fleet.
|
|
152
|
-
* * **Mount points / partition layout** — change when a disk is added or a volume is resized.
|
|
153
|
-
* * **Installed packages / versions** — change on every update, so the fingerprint would churn.
|
|
154
|
-
* * **Hostname alone** — user-editable in one command on every platform.
|
|
155
|
-
*/
|
|
156
|
-
export const HW_SOURCES = Object.freeze({
|
|
157
|
-
win32: Object.freeze([
|
|
158
|
-
Object.freeze({ key: 'MachineGuid', kind: 'registry', path: 'HKLM\\SOFTWARE\\Microsoft\\Cryptography', name: 'MachineGuid' }),
|
|
159
|
-
Object.freeze({ key: 'SMBIOS-UUID', kind: 'exec', file: 'powershell', args: Object.freeze(['-NoProfile', '-Command', '(Get-CimInstance Win32_ComputerSystemProduct).UUID']) }),
|
|
160
|
-
]),
|
|
161
|
-
darwin: Object.freeze([
|
|
162
|
-
Object.freeze({ key: 'IOPlatformUUID', kind: 'exec', file: 'ioreg', args: Object.freeze(['-rd1', '-c', 'IOPlatformExpertDevice']), extract: /"IOPlatformUUID"\s*=\s*"([^"]+)"/ }),
|
|
163
|
-
]),
|
|
164
|
-
linux: Object.freeze([
|
|
165
|
-
Object.freeze({ key: 'machine-id', kind: 'file', path: '/etc/machine-id' }),
|
|
166
|
-
Object.freeze({ key: 'dbus-machine-id', kind: 'file', path: '/var/lib/dbus/machine-id' }),
|
|
167
|
-
]),
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Values that mean "this source gave nothing useful".
|
|
172
|
-
*
|
|
173
|
-
* A machine with no SMBIOS UUID reports all-zeroes or a placeholder depending on firmware, and
|
|
174
|
-
* hashing that would give **every such machine the same hardware code** — a far worse failure
|
|
175
|
-
* than having none, because it silently merges distinct machines.
|
|
176
|
-
*/
|
|
177
|
-
const USELESS = new Set([
|
|
178
|
-
'',
|
|
179
|
-
'0',
|
|
180
|
-
'none',
|
|
181
|
-
'null',
|
|
182
|
-
'undefined',
|
|
183
|
-
'00000000-0000-0000-0000-000000000000',
|
|
184
|
-
'ffffffff-ffff-ffff-ffff-ffffffffffff',
|
|
185
|
-
'03000200-0400-0500-0006-000700080009',
|
|
186
|
-
'to be filled by o.e.m.',
|
|
187
|
-
'default string',
|
|
188
|
-
'system serial number',
|
|
189
|
-
]);
|
|
190
|
-
|
|
191
|
-
/** Is this a value worth hashing? */
|
|
192
|
-
export function isUsefulHardwareValue(value) {
|
|
193
|
-
if (typeof value !== 'string') return false;
|
|
194
|
-
const v = value.trim().toLowerCase();
|
|
195
|
-
return v !== '' && !USELESS.has(v);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* Read the raw hardware values, in priority order, and report which were usable.
|
|
200
|
-
*
|
|
201
|
-
* Everything is injectable so a test can drive a Windows-shaped machine on Linux, or a Darwin
|
|
202
|
-
* fixture in CI, without the host having that platform's tools — the alternative is a suite that
|
|
203
|
-
* only exercises whichever platform it happens to run on.
|
|
204
|
-
*
|
|
205
|
-
* @param {object} [options]
|
|
206
|
-
* @param {string} [options.platform]
|
|
207
|
-
* @param {(spec: object) => Promise<string|null>} [options.read] Default reader; override wholly in tests.
|
|
208
|
-
* @param {(file: string, args: string[]) => Promise<string>} [options.exec]
|
|
209
|
-
* @param {(path: string) => Promise<string>} [options.readFile]
|
|
210
|
-
* @param {(event: string, detail?: object) => void} [options.audit]
|
|
211
|
-
*/
|
|
212
|
-
export async function readHardwareValues(options = {}) {
|
|
213
|
-
const platform = options.platform ?? process.platform;
|
|
214
|
-
const sources = HW_SOURCES[platform] ?? [];
|
|
215
|
-
const execImpl = options.exec ?? (async (file, args) => {
|
|
216
|
-
const { stdout } = await run(file, args, { windowsHide: true, timeout: 5000 });
|
|
217
|
-
return stdout;
|
|
218
|
-
});
|
|
219
|
-
const readFileImpl = options.readFile ?? ((path) => readFile(path, 'utf8'));
|
|
220
|
-
const audit = options.audit ?? (() => {});
|
|
221
|
-
|
|
222
|
-
const readOne = options.read ?? (async (spec) => {
|
|
223
|
-
if (spec.kind === 'file') return readFileImpl(spec.path);
|
|
224
|
-
if (spec.kind === 'exec') {
|
|
225
|
-
const out = await execImpl(spec.file, spec.args);
|
|
226
|
-
if (spec.extract) {
|
|
227
|
-
const m = spec.extract.exec(out);
|
|
228
|
-
return m === null ? null : m[1];
|
|
229
|
-
}
|
|
230
|
-
return out;
|
|
231
|
-
}
|
|
232
|
-
if (spec.kind === 'registry') {
|
|
233
|
-
// `reg query` is used rather than a native call so there is no dependency beyond Windows
|
|
234
|
-
// itself, and so the same `exec` seam covers every source.
|
|
235
|
-
const out = await execImpl('reg', ['query', spec.path, '/v', spec.name]);
|
|
236
|
-
const m = /REG_SZ\s+(\S+)/.exec(out);
|
|
237
|
-
return m === null ? null : m[1];
|
|
238
|
-
}
|
|
239
|
-
return null;
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
const values = [];
|
|
243
|
-
for (const spec of sources) {
|
|
244
|
-
let raw = null;
|
|
245
|
-
try {
|
|
246
|
-
raw = await readOne(spec);
|
|
247
|
-
} catch (error) {
|
|
248
|
-
// A source that cannot be read is not a failure of the fingerprint: the next source is the
|
|
249
|
-
// point of having a list. It is recorded so an operator can see the list was cut short.
|
|
250
|
-
audit('hw/source-failed', { source: spec.key, detail: error instanceof Error ? error.message : String(error) });
|
|
251
|
-
continue;
|
|
252
|
-
}
|
|
253
|
-
const value = typeof raw === 'string' ? raw.trim() : '';
|
|
254
|
-
if (!isUsefulHardwareValue(value)) {
|
|
255
|
-
audit('hw/source-unusable', { source: spec.key, value: value.slice(0, 40) });
|
|
256
|
-
continue;
|
|
257
|
-
}
|
|
258
|
-
values.push({ source: spec.key, value });
|
|
259
|
-
}
|
|
260
|
-
return { platform, values };
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/**
|
|
264
|
-
* The host-facing identity service.
|
|
265
|
-
*
|
|
266
|
-
* `getDeviceIdentity()` is read off the context rather than imported, because it is a Cordis
|
|
267
|
-
* service and an optional one: a host without it must produce a named refusal, not a crash. That
|
|
268
|
-
* is what `ctx.get('deepseekAccount')` is for.
|
|
269
|
-
*
|
|
270
|
-
* @param {object} options
|
|
271
|
-
* @param {object} [options.ctx] A Cordis context exposing `get('deepseekAccount')`.
|
|
272
|
-
* @param {object} [options.account] The service itself, injected directly in tests.
|
|
273
|
-
* @param {string} [options.platform]
|
|
274
|
-
* @param {object} [options.hardware] Overrides for `readHardwareValues`.
|
|
275
|
-
* @param {object} [options.stored] An SN already on disk, so it is never regenerated.
|
|
276
|
-
* @param {(event: string, detail?: object) => void} [options.audit]
|
|
277
|
-
*/
|
|
278
|
-
export function createAccountIdentity(options = {}) {
|
|
279
|
-
const audit = options.audit ?? (() => {});
|
|
280
|
-
const platform = options.platform ?? process.platform;
|
|
281
|
-
|
|
282
|
-
function accountService() {
|
|
283
|
-
if (options.account) return options.account;
|
|
284
|
-
if (typeof options.ctx?.get === 'function') return options.ctx.get('deepseekAccount') ?? null;
|
|
285
|
-
return null;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/**
|
|
289
|
-
* The account's own identifier, or a named reason there is none.
|
|
290
|
-
*
|
|
291
|
-
* `signed-out` is a **normal state**, not an error: a machine with no account has no fleet, and
|
|
292
|
-
* the design says it must not be given a random one instead. Fabricating a placeholder here
|
|
293
|
-
* would put two unrelated machines in the same pool, which is the exact defect being removed.
|
|
294
|
-
*/
|
|
295
|
-
async function identify() {
|
|
296
|
-
const service = accountService();
|
|
297
|
-
if (service === null || typeof service.getDeviceIdentity !== 'function') {
|
|
298
|
-
return { ok: false, reason: 'no_account_service', detail: 'the host exposes no deepseekAccount service' };
|
|
299
|
-
}
|
|
300
|
-
let identity;
|
|
301
|
-
try {
|
|
302
|
-
identity = await service.getDeviceIdentity();
|
|
303
|
-
} catch (error) {
|
|
304
|
-
return { ok: false, reason: 'identity_read_failed', detail: error instanceof Error ? error.message : String(error) };
|
|
305
|
-
}
|
|
306
|
-
const userId = typeof identity?.userId === 'string' && identity.userId.trim() !== '' ? identity.userId : null;
|
|
307
|
-
if (userId === null) {
|
|
308
|
-
return { ok: false, reason: 'signed-out', detail: 'no DeepSeek account is signed in on this machine' };
|
|
309
|
-
}
|
|
310
|
-
const acct = accountCode(userId);
|
|
311
|
-
if (acct === null) return { ok: false, reason: 'bad_account_id', detail: 'the account id hashed to nothing' };
|
|
312
|
-
audit('account/identified', { account_code: acct });
|
|
313
|
-
// `userId` is returned so the fleet key can be derived from it. It is deliberately NOT put on
|
|
314
|
-
// the wire: the key travels, the raw account id does not, so a relay learns a fleet's identity
|
|
315
|
-
// without learning the account's.
|
|
316
|
-
return {
|
|
317
|
-
ok: true,
|
|
318
|
-
accountCode: acct,
|
|
319
|
-
userId,
|
|
320
|
-
deviceId: identity.deviceId ?? null,
|
|
321
|
-
osVersion: identity.osVersion ?? null,
|
|
322
|
-
};
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
/** The hardware code, and which source produced it. */
|
|
326
|
-
async function fingerprint() {
|
|
327
|
-
const { values } = await readHardwareValues({ platform, ...options.hardware });
|
|
328
|
-
if (values.length === 0) {
|
|
329
|
-
return { ok: false, reason: 'no_hardware_source', detail: `no usable hardware identity on ${platform}` };
|
|
330
|
-
}
|
|
331
|
-
const hwid = hardwareCode(...values.map((v) => v.value));
|
|
332
|
-
if (hwid === null) return { ok: false, reason: 'no_hardware_source', detail: 'the values hashed to nothing' };
|
|
333
|
-
return { ok: true, hardwareCode: hwid, sources: values.map((v) => v.source) };
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
/**
|
|
337
|
-
* The identity this machine should present.
|
|
338
|
-
*
|
|
339
|
-
* A stored SN is returned unchanged, always. Regenerating it would change the machine's identity
|
|
340
|
-
* on every start — every restart would look like a new device, and the fleet would fill with
|
|
341
|
-
* ghosts of one machine.
|
|
342
|
-
*/
|
|
343
|
-
async function resolve() {
|
|
344
|
-
/**
|
|
345
|
-
* The fleet key is derived on **every** resolve, including the stored-SN path.
|
|
346
|
-
*
|
|
347
|
-
* A first version returned early when an SN was already stored and never read the account
|
|
348
|
-
* at all. That is right for the SN -- it must not be regenerated, or every restart would
|
|
349
|
-
* look like a new device -- but it is **wrong for the key**: a machine has a stored SN from
|
|
350
|
-
* its second start onward, so it would have derived a key exactly once and then never
|
|
351
|
-
* again.
|
|
352
|
-
*
|
|
353
|
-
* The auto-join would therefore have worked on a machine's first boot and silently stopped
|
|
354
|
-
* afterwards -- the hardest kind of bug to see, because it works when you test it. So the
|
|
355
|
-
* account is read in both paths, and a signed-out machine yields no key rather than an
|
|
356
|
-
* error.
|
|
357
|
-
*/
|
|
358
|
-
const account = await identify();
|
|
359
|
-
let accountFields = { accountCode: null, fleetKey: null, fleetKeyReason: null };
|
|
360
|
-
if (account.ok) {
|
|
361
|
-
try {
|
|
362
|
-
// The KEY travels; the raw `userId` does not.
|
|
363
|
-
accountFields = { accountCode: account.accountCode, fleetKey: encodeFleetKey(deriveAccountFleetKey(account.userId)) };
|
|
364
|
-
} catch (error) {
|
|
365
|
-
/**
|
|
366
|
-
* v0.8.10: **carry the reason out, do not just log it.**
|
|
367
|
-
*
|
|
368
|
-
* This catch used to set `fleetKey: null` and nothing else, which made a derivation
|
|
369
|
-
* failure *indistinguishable from being signed out* at the call site: both arrive as
|
|
370
|
-
* "ok: true (or false) with no key". The caller could not say which had happened, so the
|
|
371
|
-
* one branch that was actually failing had no voice -- the same silent-no-op class fixed
|
|
372
|
-
* in v0.8.5 and v0.8.9, in a third place.
|
|
373
|
-
*
|
|
374
|
-
* `audit` was already called here, but audit output is not somewhere an operator looks.
|
|
375
|
-
* The reason now travels with the result so whoever receives it can name it.
|
|
376
|
-
*/
|
|
377
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
378
|
-
audit('account/fleet-key-failed', { detail });
|
|
379
|
-
accountFields = { accountCode: account.accountCode, fleetKey: null, fleetKeyReason: `fleet_key_derivation_failed: ${detail}` };
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
if (options.stored !== undefined && options.stored !== null) {
|
|
384
|
-
const parsed = parseSn(options.stored);
|
|
385
|
-
if (parsed === null) {
|
|
386
|
-
return { ok: false, reason: 'stored_sn_malformed', detail: options.stored };
|
|
387
|
-
}
|
|
388
|
-
// The SN is returned unchanged; the key is fresh, because it comes from the account.
|
|
389
|
-
return { ok: true, sn: options.stored, ...parsed, ...accountFields, reused: true };
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
if (!account.ok) return account;
|
|
393
|
-
const hardware = await fingerprint();
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Account identity, the serial number, and the hardware fingerprint (v0.7.5).
|
|
3
|
+
*
|
|
4
|
+
* ## What this is
|
|
5
|
+
*
|
|
6
|
+
* Stage 1 of `docs/ACCOUNT-FLEET-DESIGN.md`: the root of the whole scheme. Everything later —
|
|
7
|
+
* the device keypair, the relay's authorisation model, the fleet-scoped dispatch — depends on
|
|
8
|
+
* knowing *which account* a machine belongs to and *which machine* it is. Neither existed before
|
|
9
|
+
* this file: `machine_id` was a bare `randomUUID()` with no account and no hardware in it.
|
|
10
|
+
*
|
|
11
|
+
* ## The identity source is read, not fetched
|
|
12
|
+
*
|
|
13
|
+
* The account code comes from `ctx.deepseekAccount.getDeviceIdentity()`, whose contract is
|
|
14
|
+
* *"read existing login identity without creating a device or returning credentials"*. It
|
|
15
|
+
* returns `{ deviceId?, userId?, osVersion }`, and `userId` is the account's unique identifier.
|
|
16
|
+
*
|
|
17
|
+
* So there is **no account API call here**, and there is deliberately **no way to set the account
|
|
18
|
+
* code from configuration**. That second point is not a limitation to work around: a value an
|
|
19
|
+
* operator can type is a value an attacker can type, so a hand-entered "account code" would be a
|
|
20
|
+
* forgeable account code. The only accepted source is the one the host authenticates.
|
|
21
|
+
*
|
|
22
|
+
* ## Three things this file refuses to pretend
|
|
23
|
+
*
|
|
24
|
+
* **1. The serial number is not a credential.** It is computed from local inputs, so anyone who
|
|
25
|
+
* knows or guesses an account id can produce a well-formed one. It exists to be **displayed** and
|
|
26
|
+
* to be **grouped by prefix**, and the relay must never trust it. `isWellFormedSn` validates
|
|
27
|
+
* *shape*, and its name says shape — it is not and must not be called an authorisation check.
|
|
28
|
+
*
|
|
29
|
+
* **2. The timestamp is not what makes an SN unique.** Uniqueness is `(ACCT, HWID)`. The timestamp
|
|
30
|
+
* distinguishes a re-registration of the same hardware from a second machine, and makes the string
|
|
31
|
+
* auditable. Calling it a security feature would be an overclaim.
|
|
32
|
+
*
|
|
33
|
+
* **3. A fingerprint that changes is worse than none.** It makes one machine look like two, which
|
|
34
|
+
* fragments a fleet. So sources that change in normal use — MAC addresses, IPs, mount points,
|
|
35
|
+
* installed-package lists — are **excluded on purpose**, and each exclusion is a choice with a
|
|
36
|
+
* reason rather than an omission.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { createHash } from 'node:crypto';
|
|
40
|
+
// v0.8.1: the account-derived fleet key, so a machine joins its account's fleet with no human step.
|
|
41
|
+
import { deriveAccountFleetKey, encodeFleetKey } from './fleet-join.mjs';
|
|
42
|
+
import { execFile } from 'node:child_process';
|
|
43
|
+
import { readFile } from 'node:fs/promises';
|
|
44
|
+
import { promisify } from 'node:util';
|
|
45
|
+
|
|
46
|
+
const run = promisify(execFile);
|
|
47
|
+
|
|
48
|
+
/** SN shape. `W2M-` then three dash-separated hex groups of 8, 12 and 14 characters. */
|
|
49
|
+
export const SN_PATTERN = /^W2M-([0-9a-f]{8})-([0-9a-f]{12})-(\d{14})$/;
|
|
50
|
+
|
|
51
|
+
/** How much of a hash goes into the account code. 8 hex = 32 bits. See the collision note below. */
|
|
52
|
+
export const ACCT_HEX = 8;
|
|
53
|
+
/** How much goes into the hardware code. 12 hex = 48 bits. */
|
|
54
|
+
export const HWID_HEX = 12;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Derive the account code.
|
|
58
|
+
*
|
|
59
|
+
* **Hashed, not raw.** The `userId` is an account identifier, and this code ends up in places a
|
|
60
|
+
* login identifier should not: device lists, log lines, chat messages, `w2m_devices` output.
|
|
61
|
+
* Taking a truncated hash keeps the one property grouping needs — the same account hashes the
|
|
62
|
+
* same way on every machine — without shipping the identifier itself around.
|
|
63
|
+
*
|
|
64
|
+
* **The cost of 32 bits, stated rather than hidden.** Two different accounts colliding would
|
|
65
|
+
* merge two fleets. That needs roughly 2^16 distinct accounts on one relay before it is likely,
|
|
66
|
+
* which is far beyond any real deployment; the design document records the detection step for the
|
|
67
|
+
* case where it is not.
|
|
68
|
+
*/
|
|
69
|
+
export function accountCode(userId) {
|
|
70
|
+
if (typeof userId !== 'string' || userId.trim() === '') return null;
|
|
71
|
+
return createHash('sha256').update(userId.trim(), 'utf8').digest('hex').slice(0, ACCT_HEX);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Hash a fingerprint string into the hardware code.
|
|
76
|
+
*
|
|
77
|
+
* Normalised first — trimmed, lower-cased, and with runs of whitespace collapsed — because the
|
|
78
|
+
* same machine reports the same value with different spacing depending on the tool that read it,
|
|
79
|
+
* and a fingerprint that changes with its reader is a fingerprint that changes.
|
|
80
|
+
*/
|
|
81
|
+
export function hardwareCode(...parts) {
|
|
82
|
+
const material = parts
|
|
83
|
+
.filter((p) => typeof p === 'string' && p.trim() !== '')
|
|
84
|
+
.map((p) => p.trim().toLowerCase().replace(/\s+/g, ' '))
|
|
85
|
+
.join('\n');
|
|
86
|
+
if (material === '') return null;
|
|
87
|
+
return createHash('sha256').update(material, 'utf8').digest('hex').slice(0, HWID_HEX);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** `YYYYMMDDhhmmss`, UTC. */
|
|
91
|
+
export function timeCode(when = new Date()) {
|
|
92
|
+
const d = when instanceof Date ? when : new Date(when);
|
|
93
|
+
if (Number.isNaN(d.getTime())) throw new TypeError('timeCode needs a valid date');
|
|
94
|
+
const p = (n, width = 2) => String(n).padStart(width, '0');
|
|
95
|
+
return (
|
|
96
|
+
`${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}` +
|
|
97
|
+
`${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}`
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Build the serial number.
|
|
103
|
+
*
|
|
104
|
+
* `W2M-<ACCT>-<HWID>-<TS>`. Refuses to produce a partial one: an SN missing its account code
|
|
105
|
+
* would be a machine that claims no fleet, and silently emitting that is how a machine ends up
|
|
106
|
+
* in the wrong pool.
|
|
107
|
+
*/
|
|
108
|
+
export function buildSn({ accountCode: acct, hardwareCode: hwid, at = new Date() } = {}) {
|
|
109
|
+
if (typeof acct !== 'string' || !/^[0-9a-f]{8}$/.test(acct)) {
|
|
110
|
+
throw new TypeError('buildSn needs an 8-hex account code; without one a machine claims no fleet');
|
|
111
|
+
}
|
|
112
|
+
if (typeof hwid !== 'string' || !/^[0-9a-f]{12}$/.test(hwid)) {
|
|
113
|
+
throw new TypeError('buildSn needs a 12-hex hardware code');
|
|
114
|
+
}
|
|
115
|
+
return `W2M-${acct}-${hwid}-${timeCode(at)}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Does this string have the shape of an SN?
|
|
120
|
+
*
|
|
121
|
+
* **Shape, not authorisation.** The name is deliberate: this answers "is it well formed", and a
|
|
122
|
+
* caller that treats a `true` here as proof of anything is misusing it. Nothing about an SN is
|
|
123
|
+
* verifiable by inspection, because it was computed by the party being checked.
|
|
124
|
+
*/
|
|
125
|
+
export function isWellFormedSn(value) {
|
|
126
|
+
return typeof value === 'string' && SN_PATTERN.test(value);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The parts of an SN, or null if it is not well formed. */
|
|
130
|
+
export function parseSn(value) {
|
|
131
|
+
const m = typeof value === 'string' ? SN_PATTERN.exec(value) : null;
|
|
132
|
+
if (m === null) return null;
|
|
133
|
+
return { accountCode: m[1], hardwareCode: m[2], timeCode: m[3] };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The account prefix an SN belongs to, for grouping. Null when the SN is malformed. */
|
|
137
|
+
export function fleetOf(value) {
|
|
138
|
+
const parts = parseSn(value);
|
|
139
|
+
return parts === null ? null : parts.accountCode;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Hardware sources, per platform, in priority order.
|
|
144
|
+
*
|
|
145
|
+
* Each entry says how to read a stable machine identity. `key` names the field in the returned
|
|
146
|
+
* diagnostic so an operator can see *which* source was used — a fingerprint whose origin is
|
|
147
|
+
* unknown is one nobody can debug when it changes unexpectedly.
|
|
148
|
+
*
|
|
149
|
+
* **What is deliberately absent, and why:**
|
|
150
|
+
* * **MAC addresses / IPs** — change with a cable, a dock, a VPN, or a Wi-Fi toggle. A machine
|
|
151
|
+
* that re-fingerprints after a network change becomes a second device in the fleet.
|
|
152
|
+
* * **Mount points / partition layout** — change when a disk is added or a volume is resized.
|
|
153
|
+
* * **Installed packages / versions** — change on every update, so the fingerprint would churn.
|
|
154
|
+
* * **Hostname alone** — user-editable in one command on every platform.
|
|
155
|
+
*/
|
|
156
|
+
export const HW_SOURCES = Object.freeze({
|
|
157
|
+
win32: Object.freeze([
|
|
158
|
+
Object.freeze({ key: 'MachineGuid', kind: 'registry', path: 'HKLM\\SOFTWARE\\Microsoft\\Cryptography', name: 'MachineGuid' }),
|
|
159
|
+
Object.freeze({ key: 'SMBIOS-UUID', kind: 'exec', file: 'powershell', args: Object.freeze(['-NoProfile', '-Command', '(Get-CimInstance Win32_ComputerSystemProduct).UUID']) }),
|
|
160
|
+
]),
|
|
161
|
+
darwin: Object.freeze([
|
|
162
|
+
Object.freeze({ key: 'IOPlatformUUID', kind: 'exec', file: 'ioreg', args: Object.freeze(['-rd1', '-c', 'IOPlatformExpertDevice']), extract: /"IOPlatformUUID"\s*=\s*"([^"]+)"/ }),
|
|
163
|
+
]),
|
|
164
|
+
linux: Object.freeze([
|
|
165
|
+
Object.freeze({ key: 'machine-id', kind: 'file', path: '/etc/machine-id' }),
|
|
166
|
+
Object.freeze({ key: 'dbus-machine-id', kind: 'file', path: '/var/lib/dbus/machine-id' }),
|
|
167
|
+
]),
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Values that mean "this source gave nothing useful".
|
|
172
|
+
*
|
|
173
|
+
* A machine with no SMBIOS UUID reports all-zeroes or a placeholder depending on firmware, and
|
|
174
|
+
* hashing that would give **every such machine the same hardware code** — a far worse failure
|
|
175
|
+
* than having none, because it silently merges distinct machines.
|
|
176
|
+
*/
|
|
177
|
+
const USELESS = new Set([
|
|
178
|
+
'',
|
|
179
|
+
'0',
|
|
180
|
+
'none',
|
|
181
|
+
'null',
|
|
182
|
+
'undefined',
|
|
183
|
+
'00000000-0000-0000-0000-000000000000',
|
|
184
|
+
'ffffffff-ffff-ffff-ffff-ffffffffffff',
|
|
185
|
+
'03000200-0400-0500-0006-000700080009',
|
|
186
|
+
'to be filled by o.e.m.',
|
|
187
|
+
'default string',
|
|
188
|
+
'system serial number',
|
|
189
|
+
]);
|
|
190
|
+
|
|
191
|
+
/** Is this a value worth hashing? */
|
|
192
|
+
export function isUsefulHardwareValue(value) {
|
|
193
|
+
if (typeof value !== 'string') return false;
|
|
194
|
+
const v = value.trim().toLowerCase();
|
|
195
|
+
return v !== '' && !USELESS.has(v);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Read the raw hardware values, in priority order, and report which were usable.
|
|
200
|
+
*
|
|
201
|
+
* Everything is injectable so a test can drive a Windows-shaped machine on Linux, or a Darwin
|
|
202
|
+
* fixture in CI, without the host having that platform's tools — the alternative is a suite that
|
|
203
|
+
* only exercises whichever platform it happens to run on.
|
|
204
|
+
*
|
|
205
|
+
* @param {object} [options]
|
|
206
|
+
* @param {string} [options.platform]
|
|
207
|
+
* @param {(spec: object) => Promise<string|null>} [options.read] Default reader; override wholly in tests.
|
|
208
|
+
* @param {(file: string, args: string[]) => Promise<string>} [options.exec]
|
|
209
|
+
* @param {(path: string) => Promise<string>} [options.readFile]
|
|
210
|
+
* @param {(event: string, detail?: object) => void} [options.audit]
|
|
211
|
+
*/
|
|
212
|
+
export async function readHardwareValues(options = {}) {
|
|
213
|
+
const platform = options.platform ?? process.platform;
|
|
214
|
+
const sources = HW_SOURCES[platform] ?? [];
|
|
215
|
+
const execImpl = options.exec ?? (async (file, args) => {
|
|
216
|
+
const { stdout } = await run(file, args, { windowsHide: true, timeout: 5000 });
|
|
217
|
+
return stdout;
|
|
218
|
+
});
|
|
219
|
+
const readFileImpl = options.readFile ?? ((path) => readFile(path, 'utf8'));
|
|
220
|
+
const audit = options.audit ?? (() => {});
|
|
221
|
+
|
|
222
|
+
const readOne = options.read ?? (async (spec) => {
|
|
223
|
+
if (spec.kind === 'file') return readFileImpl(spec.path);
|
|
224
|
+
if (spec.kind === 'exec') {
|
|
225
|
+
const out = await execImpl(spec.file, spec.args);
|
|
226
|
+
if (spec.extract) {
|
|
227
|
+
const m = spec.extract.exec(out);
|
|
228
|
+
return m === null ? null : m[1];
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
if (spec.kind === 'registry') {
|
|
233
|
+
// `reg query` is used rather than a native call so there is no dependency beyond Windows
|
|
234
|
+
// itself, and so the same `exec` seam covers every source.
|
|
235
|
+
const out = await execImpl('reg', ['query', spec.path, '/v', spec.name]);
|
|
236
|
+
const m = /REG_SZ\s+(\S+)/.exec(out);
|
|
237
|
+
return m === null ? null : m[1];
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const values = [];
|
|
243
|
+
for (const spec of sources) {
|
|
244
|
+
let raw = null;
|
|
245
|
+
try {
|
|
246
|
+
raw = await readOne(spec);
|
|
247
|
+
} catch (error) {
|
|
248
|
+
// A source that cannot be read is not a failure of the fingerprint: the next source is the
|
|
249
|
+
// point of having a list. It is recorded so an operator can see the list was cut short.
|
|
250
|
+
audit('hw/source-failed', { source: spec.key, detail: error instanceof Error ? error.message : String(error) });
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const value = typeof raw === 'string' ? raw.trim() : '';
|
|
254
|
+
if (!isUsefulHardwareValue(value)) {
|
|
255
|
+
audit('hw/source-unusable', { source: spec.key, value: value.slice(0, 40) });
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
values.push({ source: spec.key, value });
|
|
259
|
+
}
|
|
260
|
+
return { platform, values };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* The host-facing identity service.
|
|
265
|
+
*
|
|
266
|
+
* `getDeviceIdentity()` is read off the context rather than imported, because it is a Cordis
|
|
267
|
+
* service and an optional one: a host without it must produce a named refusal, not a crash. That
|
|
268
|
+
* is what `ctx.get('deepseekAccount')` is for.
|
|
269
|
+
*
|
|
270
|
+
* @param {object} options
|
|
271
|
+
* @param {object} [options.ctx] A Cordis context exposing `get('deepseekAccount')`.
|
|
272
|
+
* @param {object} [options.account] The service itself, injected directly in tests.
|
|
273
|
+
* @param {string} [options.platform]
|
|
274
|
+
* @param {object} [options.hardware] Overrides for `readHardwareValues`.
|
|
275
|
+
* @param {object} [options.stored] An SN already on disk, so it is never regenerated.
|
|
276
|
+
* @param {(event: string, detail?: object) => void} [options.audit]
|
|
277
|
+
*/
|
|
278
|
+
export function createAccountIdentity(options = {}) {
|
|
279
|
+
const audit = options.audit ?? (() => {});
|
|
280
|
+
const platform = options.platform ?? process.platform;
|
|
281
|
+
|
|
282
|
+
function accountService() {
|
|
283
|
+
if (options.account) return options.account;
|
|
284
|
+
if (typeof options.ctx?.get === 'function') return options.ctx.get('deepseekAccount') ?? null;
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The account's own identifier, or a named reason there is none.
|
|
290
|
+
*
|
|
291
|
+
* `signed-out` is a **normal state**, not an error: a machine with no account has no fleet, and
|
|
292
|
+
* the design says it must not be given a random one instead. Fabricating a placeholder here
|
|
293
|
+
* would put two unrelated machines in the same pool, which is the exact defect being removed.
|
|
294
|
+
*/
|
|
295
|
+
async function identify() {
|
|
296
|
+
const service = accountService();
|
|
297
|
+
if (service === null || typeof service.getDeviceIdentity !== 'function') {
|
|
298
|
+
return { ok: false, reason: 'no_account_service', detail: 'the host exposes no deepseekAccount service' };
|
|
299
|
+
}
|
|
300
|
+
let identity;
|
|
301
|
+
try {
|
|
302
|
+
identity = await service.getDeviceIdentity();
|
|
303
|
+
} catch (error) {
|
|
304
|
+
return { ok: false, reason: 'identity_read_failed', detail: error instanceof Error ? error.message : String(error) };
|
|
305
|
+
}
|
|
306
|
+
const userId = typeof identity?.userId === 'string' && identity.userId.trim() !== '' ? identity.userId : null;
|
|
307
|
+
if (userId === null) {
|
|
308
|
+
return { ok: false, reason: 'signed-out', detail: 'no DeepSeek account is signed in on this machine' };
|
|
309
|
+
}
|
|
310
|
+
const acct = accountCode(userId);
|
|
311
|
+
if (acct === null) return { ok: false, reason: 'bad_account_id', detail: 'the account id hashed to nothing' };
|
|
312
|
+
audit('account/identified', { account_code: acct });
|
|
313
|
+
// `userId` is returned so the fleet key can be derived from it. It is deliberately NOT put on
|
|
314
|
+
// the wire: the key travels, the raw account id does not, so a relay learns a fleet's identity
|
|
315
|
+
// without learning the account's.
|
|
316
|
+
return {
|
|
317
|
+
ok: true,
|
|
318
|
+
accountCode: acct,
|
|
319
|
+
userId,
|
|
320
|
+
deviceId: identity.deviceId ?? null,
|
|
321
|
+
osVersion: identity.osVersion ?? null,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** The hardware code, and which source produced it. */
|
|
326
|
+
async function fingerprint() {
|
|
327
|
+
const { values } = await readHardwareValues({ platform, ...options.hardware });
|
|
328
|
+
if (values.length === 0) {
|
|
329
|
+
return { ok: false, reason: 'no_hardware_source', detail: `no usable hardware identity on ${platform}` };
|
|
330
|
+
}
|
|
331
|
+
const hwid = hardwareCode(...values.map((v) => v.value));
|
|
332
|
+
if (hwid === null) return { ok: false, reason: 'no_hardware_source', detail: 'the values hashed to nothing' };
|
|
333
|
+
return { ok: true, hardwareCode: hwid, sources: values.map((v) => v.source) };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* The identity this machine should present.
|
|
338
|
+
*
|
|
339
|
+
* A stored SN is returned unchanged, always. Regenerating it would change the machine's identity
|
|
340
|
+
* on every start — every restart would look like a new device, and the fleet would fill with
|
|
341
|
+
* ghosts of one machine.
|
|
342
|
+
*/
|
|
343
|
+
async function resolve() {
|
|
344
|
+
/**
|
|
345
|
+
* The fleet key is derived on **every** resolve, including the stored-SN path.
|
|
346
|
+
*
|
|
347
|
+
* A first version returned early when an SN was already stored and never read the account
|
|
348
|
+
* at all. That is right for the SN -- it must not be regenerated, or every restart would
|
|
349
|
+
* look like a new device -- but it is **wrong for the key**: a machine has a stored SN from
|
|
350
|
+
* its second start onward, so it would have derived a key exactly once and then never
|
|
351
|
+
* again.
|
|
352
|
+
*
|
|
353
|
+
* The auto-join would therefore have worked on a machine's first boot and silently stopped
|
|
354
|
+
* afterwards -- the hardest kind of bug to see, because it works when you test it. So the
|
|
355
|
+
* account is read in both paths, and a signed-out machine yields no key rather than an
|
|
356
|
+
* error.
|
|
357
|
+
*/
|
|
358
|
+
const account = await identify();
|
|
359
|
+
let accountFields = { accountCode: null, fleetKey: null, fleetKeyReason: null };
|
|
360
|
+
if (account.ok) {
|
|
361
|
+
try {
|
|
362
|
+
// The KEY travels; the raw `userId` does not.
|
|
363
|
+
accountFields = { accountCode: account.accountCode, fleetKey: encodeFleetKey(deriveAccountFleetKey(account.userId)) };
|
|
364
|
+
} catch (error) {
|
|
365
|
+
/**
|
|
366
|
+
* v0.8.10: **carry the reason out, do not just log it.**
|
|
367
|
+
*
|
|
368
|
+
* This catch used to set `fleetKey: null` and nothing else, which made a derivation
|
|
369
|
+
* failure *indistinguishable from being signed out* at the call site: both arrive as
|
|
370
|
+
* "ok: true (or false) with no key". The caller could not say which had happened, so the
|
|
371
|
+
* one branch that was actually failing had no voice -- the same silent-no-op class fixed
|
|
372
|
+
* in v0.8.5 and v0.8.9, in a third place.
|
|
373
|
+
*
|
|
374
|
+
* `audit` was already called here, but audit output is not somewhere an operator looks.
|
|
375
|
+
* The reason now travels with the result so whoever receives it can name it.
|
|
376
|
+
*/
|
|
377
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
378
|
+
audit('account/fleet-key-failed', { detail });
|
|
379
|
+
accountFields = { accountCode: account.accountCode, fleetKey: null, fleetKeyReason: `fleet_key_derivation_failed: ${detail}` };
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (options.stored !== undefined && options.stored !== null) {
|
|
384
|
+
const parsed = parseSn(options.stored);
|
|
385
|
+
if (parsed === null) {
|
|
386
|
+
return { ok: false, reason: 'stored_sn_malformed', detail: options.stored };
|
|
387
|
+
}
|
|
388
|
+
// The SN is returned unchanged; the key is fresh, because it comes from the account.
|
|
389
|
+
return { ok: true, sn: options.stored, ...parsed, ...accountFields, reused: true };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (!account.ok) return account;
|
|
393
|
+
const hardware = await fingerprint();
|
|
394
|
+
/**
|
|
395
|
+
* v0.8.12: **a missing serial number must not discard a fleet key that was already derived.**
|
|
396
|
+
*
|
|
397
|
+
* The SN needs the hardware fingerprint; the fleet key does not. The key comes from the
|
|
398
|
+
* account's `userId`, and it is computed above, before this point. Returning the bare hardware
|
|
399
|
+
* failure threw that work away -- and because the whole `resolve()` result was then
|
|
400
|
+
* `ok: false` with a hardware reason, the caller could not tell that the *key* had been fine.
|
|
401
|
+
*
|
|
402
|
+
* Measured on the machine this was reported from: `readHardwareValues({ platform: 'win32' })`
|
|
403
|
+
* returns **zero** usable values (both `MachineGuid` and the SMBIOS UUID source come back
|
|
404
|
+
* empty), so `fingerprint()` always failed there and the fleet key was always discarded --
|
|
405
|
+
* which is exactly the reported symptom, a signed-in machine that joins nothing. Retrying the
|
|
406
|
+
* derivation could never have helped: the account was readable and the key *was* computed, then
|
|
407
|
+
* dropped one line later.
|
|
408
|
+
*
|
|
409
|
+
* So the failure is reported as what it is -- `no_hardware_source`, with `ok: false` and the SN
|
|
410
|
+
* fields absent, because there genuinely is no SN -- while `accountFields` still travels, so a
|
|
411
|
+
* caller that only needs to join the account's fleet is not held hostage by a display
|
|
412
|
+
* identifier.
|
|
413
|
+
*/
|
|
414
|
+
if (!hardware.ok) return { ...hardware, ...accountFields };
|
|
415
|
+
const sn = buildSn({ accountCode: account.accountCode, hardwareCode: hardware.hardwareCode, at: options.at ?? new Date() });
|
|
416
|
+
audit('account/sn-created', { sn, sources: hardware.sources });
|
|
417
|
+
return {
|
|
418
|
+
ok: true,
|
|
419
|
+
sn,
|
|
420
|
+
...accountFields,
|
|
421
|
+
hardwareCode: hardware.hardwareCode,
|
|
422
|
+
timeCode: parseSn(sn).timeCode,
|
|
423
|
+
hardwareSources: hardware.sources,
|
|
424
|
+
reused: false,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return { identify, fingerprint, resolve };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** The config keys an SN needs in order to survive a restart. */
|
|
432
|
+
export const SN_STATE_KEY = 'sn';
|