aegis-desktop 0.8.12 → 0.8.13
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/lib/auto.js +423 -0
- package/lib/avatar/packs/core-wardrobe.pack.json +1 -1
- package/lib/avatar/packs/local-voice-core.pack.json +1 -1
- package/lib/avatar/packs/seasonal-winter.pack.json +1 -1
- package/lib/avatar/profile.js +10 -1
- package/lib/fingerprint.js +327 -0
- package/lib/local/autonomous.js +1 -0
- package/lib/local/prompt.js +6 -0
- package/lib/settings.js +154 -1
- package/main.js +316 -17
- package/package.json +2 -2
- package/preload.js +84 -4
- package/renderer/app.js +208 -4
- package/renderer/fingerprint.css +143 -0
- package/renderer/fingerprint.js +583 -0
- package/renderer/index.html +163 -1
- package/vendor/aegis.js +180 -18
- package/vendor/attest.js +439 -0
- package/vendor/authorship-key.js +366 -0
- package/vendor/device-fingerprint.js +85 -3
- package/vendor/fingerprint.js +1061 -0
- package/vendor/presence-constants.js +104 -0
- package/vendor/presence.js +435 -0
- package/vendor/record-vault.js +834 -0
- package/vendor/touch.js +455 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* authorship-key.js — the key half of φ(α): keygen, and where `--secret` comes
|
|
5
|
+
* from.
|
|
6
|
+
*
|
|
7
|
+
* `client/fingerprint.js` is the vendored engine module and answers "is this
|
|
8
|
+
* record intact?". With the *public* covenant hash as key, that answer is
|
|
9
|
+
* "intact, and by the way anyone holding the covenant can also forge it" —
|
|
10
|
+
* integrity, not authorship. This file is the other half of that sentence: a
|
|
11
|
+
* private key under the user's own directory, at mode 600, that no one else
|
|
12
|
+
* has, so a valid record means "minted by whoever holds this key".
|
|
13
|
+
*
|
|
14
|
+
* ## The ladder (`resolveKey`)
|
|
15
|
+
*
|
|
16
|
+
* 1. `--secret <file>` (alias `--secret-file`, the engine's name)
|
|
17
|
+
* 2. `AEGIS_FINGERPRINT_SECRET` in the environment
|
|
18
|
+
* 3. `$AEGIS_HOME/keys/authorship.key` — the same path `keygen` writes
|
|
19
|
+
* 4. nothing: refuse, and say which command mints one.
|
|
20
|
+
*
|
|
21
|
+
* Step 4 is where this deliberately *diverges* from the engine. The engine's
|
|
22
|
+
* last rung is the published covenant hash, which keeps its tools runnable with
|
|
23
|
+
* no setup at all. Here an unconfigured host gets an error instead, because the
|
|
24
|
+
* plugin's records are meant to travel — a plugin record that silently keyed
|
|
25
|
+
* itself to a public constant would verify on every covenant holder's machine,
|
|
26
|
+
* which reads as authorship to everyone who did not look up how it was keyed.
|
|
27
|
+
* A caller who genuinely wants the engine's public mode says so explicitly:
|
|
28
|
+
* `--public --covenant <file>`, and that mode is labelled on every record it
|
|
29
|
+
* mints (`keySource` in the tool output, never inside the HMAC body — see
|
|
30
|
+
* below).
|
|
31
|
+
*
|
|
32
|
+
* ## What never happens
|
|
33
|
+
*
|
|
34
|
+
* The raw secret is never printed, never returned in a plain-object field that
|
|
35
|
+
* a `console.log` would surface, and never written anywhere but the key file.
|
|
36
|
+
* `keygen` returns a *derived* fingerprint — `keyFingerprint(secret)` from the
|
|
37
|
+
* vendored module, i.e. `sha256('aegiscode-agent-v1\n' + secret)[0:12]`, the
|
|
38
|
+
* same 12 hex the avatar registry indexes by — so a human can confirm two
|
|
39
|
+
* machines hold the same key without either machine revealing it. The
|
|
40
|
+
* derivation is spelled out here because the engine's own comment calls it a
|
|
41
|
+
* bare `sha256(secret)`, which it is not; the code is the contract, and both
|
|
42
|
+
* host tests re-derive the formula rather than trusting either description.
|
|
43
|
+
*
|
|
44
|
+
* Mode 600 is enforced twice on purpose: `fs.writeFileSync`'s `mode` is masked
|
|
45
|
+
* by the process umask, so passing it is not the same as getting it. The write
|
|
46
|
+
* is followed by an explicit `chmodSync` and a re-`stat` that throws if the
|
|
47
|
+
* result is not 0600 — a key that lands 644 must fail the operation, not warn
|
|
48
|
+
* about it. Both `resolveKey` and `backup` refuse a file that is group- or
|
|
49
|
+
* other-readable (any bit of 0o077); a secret the filesystem will hand to
|
|
50
|
+
* another user is not a secret. On Windows there is no POSIX mode to check, so
|
|
51
|
+
* the guarantee there is the directory (0700 under the user's profile) rather
|
|
52
|
+
* than the file — stated here because the tests can only assert the POSIX half.
|
|
53
|
+
*
|
|
54
|
+
* No privilege is involved anywhere: `node:fs`/`node:crypto` under `$HOME`, and
|
|
55
|
+
* one `git check-ignore` spawn in `backup` that is bounded and failure-tolerant.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
const crypto = require('node:crypto');
|
|
59
|
+
const fs = require('node:fs');
|
|
60
|
+
const os = require('node:os');
|
|
61
|
+
const path = require('node:path');
|
|
62
|
+
const { execFileSync } = require('node:child_process');
|
|
63
|
+
|
|
64
|
+
const fp = require('./fingerprint.js');
|
|
65
|
+
|
|
66
|
+
/** 0600 — owner read/write only. Enforced after the write, and re-checked. */
|
|
67
|
+
const KEY_FILE_MODE = 0o600;
|
|
68
|
+
const DIR_MODE = 0o700;
|
|
69
|
+
|
|
70
|
+
/** Any of these bits set means the file is readable by someone else. */
|
|
71
|
+
const GROUP_OTHER_BITS = 0o077;
|
|
72
|
+
|
|
73
|
+
/** 32 random bytes, base64url: 256 bits, matching the engine's keygen exactly. */
|
|
74
|
+
const KEY_BYTES = 32;
|
|
75
|
+
|
|
76
|
+
const SECRET_ENV = 'AEGIS_FINGERPRINT_SECRET';
|
|
77
|
+
const HOME_ENV = 'AEGIS_HOME';
|
|
78
|
+
const KEY_FILENAME = 'authorship.key';
|
|
79
|
+
|
|
80
|
+
const isWin = process.platform === 'win32';
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// paths
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
/** `$AEGIS_HOME`, else `~/.aegiscode` — the plugin's private store. */
|
|
87
|
+
function dataDir(env = process.env) {
|
|
88
|
+
const home = env[HOME_ENV];
|
|
89
|
+
if (home && String(home).trim()) return path.resolve(String(home).trim());
|
|
90
|
+
return path.join(os.homedir(), '.aegiscode');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Where `keygen` writes with no `--out`, and the ladder's third rung. */
|
|
94
|
+
function defaultKeyPath(env = process.env) {
|
|
95
|
+
return path.join(dataDir(env), 'keys', KEY_FILENAME);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function modeOf(file) {
|
|
99
|
+
return fs.statSync(file).mode & 0o777;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function modeString(mode) {
|
|
103
|
+
return mode.toString(8).padStart(3, '0');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Refuse a secret file that other users can read.
|
|
108
|
+
*
|
|
109
|
+
* Not a warning: `resolveKey` has already chosen this file, so continuing means
|
|
110
|
+
* minting records under a key the filesystem publishes. The message names the
|
|
111
|
+
* one-line fix, because the alternative fix a user reaches for first — deleting
|
|
112
|
+
* the key and starting over — orphans every record already minted under it.
|
|
113
|
+
*/
|
|
114
|
+
function assertPrivate(file) {
|
|
115
|
+
if (isWin) return; // no POSIX mode; the directory is the boundary (see header)
|
|
116
|
+
const mode = modeOf(file);
|
|
117
|
+
if (mode & GROUP_OTHER_BITS) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`authorship-key: ${file} is mode ${modeString(mode)} — a secret file must not be ` +
|
|
120
|
+
`group/other readable. chmod 600 ${file}`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A key file is one line of base64url. Anything else is not one. */
|
|
126
|
+
function readSecretFile(file) {
|
|
127
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
128
|
+
const secret = raw.replace(/\r?\n$/, '');
|
|
129
|
+
if (!secret.trim()) throw new Error(`authorship-key: ${file} is empty`);
|
|
130
|
+
if (/\s/.test(secret.trim())) {
|
|
131
|
+
throw new Error(`authorship-key: ${file} holds more than one line — not a key file`);
|
|
132
|
+
}
|
|
133
|
+
return secret.trim();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// argv
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* `--secret` and `--secret-file` are the same flag. The engine spells it
|
|
142
|
+
* `--secret-file`; the plugin's docs and the request that produced this module
|
|
143
|
+
* both say `--secret`. Accepting one name and rejecting the other would be a
|
|
144
|
+
* parse error where a user means the same thing, so both are read here — once,
|
|
145
|
+
* in the shared module, so the CLI and the desktop cannot disagree about it.
|
|
146
|
+
*/
|
|
147
|
+
function flag(argv, name, fallback = null) {
|
|
148
|
+
const names = name === '--secret' ? ['--secret', '--secret-file'] : [name];
|
|
149
|
+
for (const n of names) {
|
|
150
|
+
const i = argv.indexOf(n);
|
|
151
|
+
if (i !== -1 && argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')) return argv[i + 1];
|
|
152
|
+
}
|
|
153
|
+
return fallback;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function hasFlag(argv, name) {
|
|
157
|
+
const names = name === '--secret' ? ['--secret', '--secret-file'] : [name];
|
|
158
|
+
return names.some((n) => argv.includes(n));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// the ladder
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Resolve the key to mint/verify with.
|
|
167
|
+
*
|
|
168
|
+
* @returns {{secret: string|null, source: string, public: boolean, path: string|null}}
|
|
169
|
+
* `secret: null` means "no key configured" — the caller decides whether
|
|
170
|
+
* that is a refusal (mint/verify) or an ordinary unconfigured state
|
|
171
|
+
* (status), exactly as presence does with a missing API key.
|
|
172
|
+
*/
|
|
173
|
+
function resolveKey(o = {}) {
|
|
174
|
+
const argv = o.argv || [];
|
|
175
|
+
const env = o.env || process.env;
|
|
176
|
+
|
|
177
|
+
const file = flag(argv, '--secret');
|
|
178
|
+
if (file) {
|
|
179
|
+
if (!fs.existsSync(file)) throw new Error(`authorship-key: no such secret file: ${file}`);
|
|
180
|
+
assertPrivate(file);
|
|
181
|
+
return { secret: readSecretFile(file), source: file, public: false, path: file };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (hasFlag(argv, '--public')) {
|
|
185
|
+
// Explicit public mode, and only with the document that defines the key.
|
|
186
|
+
const covenant = flag(argv, '--covenant');
|
|
187
|
+
if (!covenant) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
'authorship-key: --public requires --covenant <file> — the public key IS the ' +
|
|
190
|
+
"covenant document's hash, so it cannot be assumed",
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
if (!fs.existsSync(covenant)) {
|
|
194
|
+
throw new Error(`authorship-key: no such covenant file: ${covenant}`);
|
|
195
|
+
}
|
|
196
|
+
const secret = crypto.createHash('sha256').update(fs.readFileSync(covenant)).digest('hex');
|
|
197
|
+
return { secret, source: `covenant (public): ${covenant}`, public: true, path: null };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const envSecret = env[SECRET_ENV];
|
|
201
|
+
if (envSecret && String(envSecret).trim()) {
|
|
202
|
+
return { secret: String(envSecret).trim(), source: SECRET_ENV, public: false, path: null };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const def = defaultKeyPath(env);
|
|
206
|
+
if (fs.existsSync(def)) {
|
|
207
|
+
assertPrivate(def);
|
|
208
|
+
return { secret: readSecretFile(def), source: def, public: false, path: def };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return { secret: null, source: null, public: false, path: null };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// keygen
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Mint a private key.
|
|
220
|
+
*
|
|
221
|
+
* `--out F` chooses the path (default `$AEGIS_HOME/keys/authorship.key`);
|
|
222
|
+
* `--force` overwrites an existing one. Without `--force` an existing key is an
|
|
223
|
+
* error, never a silent replacement: overwriting the key orphans every record
|
|
224
|
+
* ever minted under it, and the loss is invisible until someone verifies an old
|
|
225
|
+
* record. Replacing a key is a deliberate act, so it is a flag.
|
|
226
|
+
*
|
|
227
|
+
* @returns {{out: string, created: boolean, replaced: boolean,
|
|
228
|
+
* keyFingerprint: string, bytes: number, secretPrinted: false, mode: string}}
|
|
229
|
+
*/
|
|
230
|
+
function keygen(o = {}) {
|
|
231
|
+
const argv = o.argv || [];
|
|
232
|
+
const env = o.env || process.env;
|
|
233
|
+
const out = path.resolve(flag(argv, '--out') || defaultKeyPath(env));
|
|
234
|
+
const force = hasFlag(argv, '--force') || o.force === true;
|
|
235
|
+
|
|
236
|
+
const exists = fs.existsSync(out);
|
|
237
|
+
if (exists && !force) {
|
|
238
|
+
throw new Error(`authorship-key: ${out} already exists — pass --force to replace it`);
|
|
239
|
+
}
|
|
240
|
+
if (exists && !isWin) {
|
|
241
|
+
// Replacing a key that is itself world-readable is how a *second* party
|
|
242
|
+
// ends up holding the old one; refuse rather than rotate on top of it.
|
|
243
|
+
assertPrivate(out);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const secret = crypto.randomBytes(KEY_BYTES).toString('base64url');
|
|
247
|
+
fs.mkdirSync(path.dirname(out), { recursive: true, mode: DIR_MODE });
|
|
248
|
+
fs.writeFileSync(out, secret + '\n', { mode: KEY_FILE_MODE });
|
|
249
|
+
// writeFileSync's mode is ANDed with the complement of the umask, so a 0022
|
|
250
|
+
// umask turns 0600 into 0644 and the call still "succeeds". Set it outright.
|
|
251
|
+
fs.chmodSync(out, KEY_FILE_MODE);
|
|
252
|
+
if (!isWin) {
|
|
253
|
+
const mode = modeOf(out);
|
|
254
|
+
if (mode !== KEY_FILE_MODE) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`authorship-key: ${out} landed mode ${modeString(mode)}, expected 600 — refusing to ` +
|
|
257
|
+
'report success for a key another user may be able to read',
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Read it back: a key that does not survive the round trip is worse than no
|
|
263
|
+
// key, because the fingerprint below would name something unrecoverable.
|
|
264
|
+
const readBack = readSecretFile(out);
|
|
265
|
+
if (readBack !== secret) throw new Error(`authorship-key: ${out} does not read back as written`);
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
out,
|
|
269
|
+
created: !exists,
|
|
270
|
+
replaced: exists,
|
|
271
|
+
keyFingerprint: fp.keyFingerprint(secret),
|
|
272
|
+
bytes: KEY_BYTES,
|
|
273
|
+
secretPrinted: false,
|
|
274
|
+
mode: isWin ? 'n/a' : modeString(modeOf(out)),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
// backup
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
function git(args, cwd) {
|
|
283
|
+
try {
|
|
284
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
285
|
+
} catch {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function insideGitTree(dir) {
|
|
291
|
+
return git(['rev-parse', '--is-inside-work-tree'], dir) === 'true\n';
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Copy the key to a destination that is not inside a git working tree, unless
|
|
296
|
+
* the destination is covered by that repo's `.gitignore`.
|
|
297
|
+
*
|
|
298
|
+
* The check exists because the failure it prevents is unrecoverable in the
|
|
299
|
+
* wrong direction: a key committed once is public forever, and rotating it does
|
|
300
|
+
* not un-publish it. `--allow-git` is the escape hatch for a user who has
|
|
301
|
+
* deliberately encrypted or otherwise protected the destination.
|
|
302
|
+
*/
|
|
303
|
+
function backup(o = {}) {
|
|
304
|
+
const argv = o.argv || [];
|
|
305
|
+
const env = o.env || process.env;
|
|
306
|
+
const to = flag(argv, '--to');
|
|
307
|
+
if (!to) throw new Error('authorship-key: backup requires --to DIR');
|
|
308
|
+
|
|
309
|
+
const from = flag(argv, '--secret') || defaultKeyPath(env);
|
|
310
|
+
if (!fs.existsSync(from)) throw new Error(`authorship-key: no key at ${from} — run keygen first`);
|
|
311
|
+
assertPrivate(from);
|
|
312
|
+
|
|
313
|
+
const destDir = path.resolve(to);
|
|
314
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
315
|
+
const name = flag(argv, '--name') || path.basename(from);
|
|
316
|
+
const dest = path.join(destDir, name);
|
|
317
|
+
|
|
318
|
+
if (insideGitTree(destDir) && !hasFlag(argv, '--allow-git') && !o.allowGit) {
|
|
319
|
+
const ignored = git(['check-ignore', '-q', dest], destDir);
|
|
320
|
+
if (ignored === null) {
|
|
321
|
+
throw new Error(
|
|
322
|
+
`authorship-key: ${dest} is inside a git working tree and is not gitignored — ` +
|
|
323
|
+
'a committed key cannot be un-published. Add it to .gitignore, choose another ' +
|
|
324
|
+
'--to, or pass --allow-git if you know the destination is protected',
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const secret = readSecretFile(from);
|
|
330
|
+
fs.writeFileSync(dest, secret + '\n', { mode: KEY_FILE_MODE });
|
|
331
|
+
fs.chmodSync(dest, KEY_FILE_MODE);
|
|
332
|
+
if (!isWin && modeOf(dest) !== KEY_FILE_MODE) {
|
|
333
|
+
throw new Error(`authorship-key: ${dest} landed mode ${modeString(modeOf(dest))}, expected 600`);
|
|
334
|
+
}
|
|
335
|
+
if (readSecretFile(dest) !== secret) {
|
|
336
|
+
throw new Error(`authorship-key: ${dest} does not read back as the same key`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
from,
|
|
341
|
+
to: dest,
|
|
342
|
+
keyFingerprint: fp.keyFingerprint(secret),
|
|
343
|
+
matchesSource: true,
|
|
344
|
+
secretPrinted: false,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
module.exports = {
|
|
349
|
+
KEY_FILE_MODE,
|
|
350
|
+
DIR_MODE,
|
|
351
|
+
GROUP_OTHER_BITS,
|
|
352
|
+
KEY_BYTES,
|
|
353
|
+
SECRET_ENV,
|
|
354
|
+
HOME_ENV,
|
|
355
|
+
KEY_FILENAME,
|
|
356
|
+
dataDir,
|
|
357
|
+
defaultKeyPath,
|
|
358
|
+
assertPrivate,
|
|
359
|
+
readSecretFile,
|
|
360
|
+
flag,
|
|
361
|
+
hasFlag,
|
|
362
|
+
resolveKey,
|
|
363
|
+
keygen,
|
|
364
|
+
backup,
|
|
365
|
+
modeString,
|
|
366
|
+
};
|
|
@@ -14,6 +14,21 @@
|
|
|
14
14
|
*/
|
|
15
15
|
const crypto = require('node:crypto');
|
|
16
16
|
const os = require('node:os');
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Where a per-install random id is persisted when the OS exposes no machine
|
|
22
|
+
* identity at all. Deliberately inside the same private store as the
|
|
23
|
+
* credentials, so `readMachineId()`-less hosts (minimal Linux containers
|
|
24
|
+
* without /etc/machine-id, hardened images, some CI runners) still get a
|
|
25
|
+
* collision-resistant fingerprint instead of a guessable one — while the
|
|
26
|
+
* credential store as a whole remains the single thing a wipe destroys.
|
|
27
|
+
*/
|
|
28
|
+
function installIdPath() {
|
|
29
|
+
const home = process.env.AEGISCODEX_HOME || path.join(os.homedir(), '.aegiscode');
|
|
30
|
+
return path.join(home, 'install-id');
|
|
31
|
+
}
|
|
17
32
|
|
|
18
33
|
/** Best-effort OS machine identity. Empty string, never throws, if none is reachable. */
|
|
19
34
|
function readMachineId() {
|
|
@@ -50,14 +65,81 @@ function readMachineId() {
|
|
|
50
65
|
return '';
|
|
51
66
|
}
|
|
52
67
|
|
|
68
|
+
/**
|
|
69
|
+
* A random 128-bit id persisted 0600 in the private store, minted on first
|
|
70
|
+
* need. Distinct from the machine identity: it survives a credential wipe
|
|
71
|
+
* only if the store itself survives, which is exactly the guarantee we can
|
|
72
|
+
* honestly make on a host that reports no machine id at all.
|
|
73
|
+
*
|
|
74
|
+
* @returns {string} hex id, or '' if the store is not writable.
|
|
75
|
+
*/
|
|
76
|
+
function persistedInstallId() {
|
|
77
|
+
const file = installIdPath();
|
|
78
|
+
const read = () => {
|
|
79
|
+
try {
|
|
80
|
+
const v = fs.readFileSync(file, 'utf8').trim();
|
|
81
|
+
return /^[0-9a-f]{32}$/.test(v) ? v : '';
|
|
82
|
+
} catch {
|
|
83
|
+
return '';
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
const existing = read();
|
|
87
|
+
if (existing) return existing;
|
|
88
|
+
try {
|
|
89
|
+
const fresh = crypto.randomBytes(16).toString('hex');
|
|
90
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
91
|
+
fs.writeFileSync(file, fresh, { mode: 0o600, flag: 'wx' });
|
|
92
|
+
return fresh;
|
|
93
|
+
} catch {
|
|
94
|
+
// Lost a race with a concurrent host process, or the store is read-only.
|
|
95
|
+
return read();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
53
99
|
/**
|
|
54
100
|
* sha256("aegiscode-plugin-device-v1:" + machine identity), hex.
|
|
55
101
|
*
|
|
102
|
+
* Identity precedence, strongest first:
|
|
103
|
+
* 1. an explicit override (test-only),
|
|
104
|
+
* 2. the OS machine identity (`readMachineId()`),
|
|
105
|
+
* 3. a persisted random install id,
|
|
106
|
+
* 4. `hostname:platform:arch` — last resort, kept only so this never throws.
|
|
107
|
+
*
|
|
108
|
+
* Only step 4 is guessable by a third party; steps 2 and 3 are both per-host
|
|
109
|
+
* unique. `machineIdentityKind()` reports which one was used, so aegis1-side
|
|
110
|
+
* abuse review can tell a genuine re-registration from a forged one.
|
|
111
|
+
*
|
|
56
112
|
* @param {string} [overrideId] - test-only override for the raw machine identity.
|
|
57
113
|
*/
|
|
58
114
|
function machineFingerprint(overrideId) {
|
|
59
|
-
|
|
60
|
-
|
|
115
|
+
return crypto
|
|
116
|
+
.createHash('sha256')
|
|
117
|
+
.update(`aegiscode-plugin-device-v1:${machineIdentity(overrideId).id}`)
|
|
118
|
+
.digest('hex');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** @returns {{kind: 'override'|'machine-id'|'install-id'|'hostname', id: string}} */
|
|
122
|
+
function machineIdentity(overrideId) {
|
|
123
|
+
if (overrideId) return { kind: 'override', id: String(overrideId) };
|
|
124
|
+
const machine = readMachineId();
|
|
125
|
+
if (machine) return { kind: 'machine-id', id: machine };
|
|
126
|
+
const install = persistedInstallId();
|
|
127
|
+
if (install) return { kind: 'install-id', id: install };
|
|
128
|
+
return {
|
|
129
|
+
kind: 'hostname',
|
|
130
|
+
id: `${os.hostname()}:${os.platform()}:${os.arch()}`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Which identity class the fingerprint on THIS host is derived from. */
|
|
135
|
+
function machineIdentityKind() {
|
|
136
|
+
return machineIdentity().kind;
|
|
61
137
|
}
|
|
62
138
|
|
|
63
|
-
module.exports = {
|
|
139
|
+
module.exports = {
|
|
140
|
+
machineFingerprint,
|
|
141
|
+
machineIdentity,
|
|
142
|
+
machineIdentityKind,
|
|
143
|
+
readMachineId,
|
|
144
|
+
persistedInstallId,
|
|
145
|
+
};
|