@twinsearth/w2m-dsh-plugin 0.8.9 → 0.8.11

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.
@@ -1,401 +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
-
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
39
  import { createHash } from 'node:crypto';
40
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 };
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
- // Reported rather than folded into "no fleet". Reaching here means `userId` passed
366
- // `identify` and was still unusable, which is a contradiction worth seeing.
367
- audit('account/fleet-key-failed', { detail: error instanceof Error ? error.message : String(error) });
368
- accountFields = { accountCode: account.accountCode, fleetKey: null };
369
- }
370
- }
371
-
372
- if (options.stored !== undefined && options.stored !== null) {
373
- const parsed = parseSn(options.stored);
374
- if (parsed === null) {
375
- return { ok: false, reason: 'stored_sn_malformed', detail: options.stored };
376
- }
377
- // The SN is returned unchanged; the key is fresh, because it comes from the account.
378
- return { ok: true, sn: options.stored, ...parsed, ...accountFields, reused: true };
379
- }
380
-
381
- if (!account.ok) return account;
382
- const hardware = await fingerprint();
383
- if (!hardware.ok) return hardware;
384
- const sn = buildSn({ accountCode: account.accountCode, hardwareCode: hardware.hardwareCode, at: options.at ?? new Date() });
385
- audit('account/sn-created', { sn, sources: hardware.sources });
386
- return {
387
- ok: true,
388
- sn,
389
- ...accountFields,
390
- hardwareCode: hardware.hardwareCode,
391
- timeCode: parseSn(sn).timeCode,
392
- hardwareSources: hardware.sources,
393
- reused: false,
394
- };
395
- }
396
-
397
- return { identify, fingerprint, resolve };
398
- }
399
-
400
- /** The config keys an SN needs in order to survive a restart. */
401
- export const SN_STATE_KEY = 'sn';
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';