agmsg-cloud 0.0.1 → 0.1.0-rc.4

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.
Files changed (44) hide show
  1. package/README.md +39 -2
  2. package/dist/src/api.js +517 -0
  3. package/dist/src/authenticated-digest.js +234 -0
  4. package/dist/src/browser.js +241 -0
  5. package/dist/src/ceremony.js +181 -0
  6. package/dist/src/commands/approve.js +392 -0
  7. package/dist/src/commands/connect.js +273 -0
  8. package/dist/src/commands/fetch.js +249 -0
  9. package/dist/src/commands/login.js +334 -0
  10. package/dist/src/commands/logout.js +74 -0
  11. package/dist/src/commands/pull.js +80 -0
  12. package/dist/src/commands/request.js +371 -0
  13. package/dist/src/commands/sync.js +138 -0
  14. package/dist/src/commands/vault.js +478 -0
  15. package/dist/src/commands/watch.js +47 -0
  16. package/dist/src/config.js +34 -0
  17. package/dist/src/credentials.js +374 -0
  18. package/dist/src/device-slot.js +148 -0
  19. package/dist/src/filelock.js +167 -0
  20. package/dist/src/index.js +242 -0
  21. package/dist/src/ledger.js +296 -0
  22. package/dist/src/machine-name.js +90 -0
  23. package/dist/src/oss-env.js +49 -0
  24. package/dist/src/oss.js +289 -0
  25. package/dist/src/paths.js +8 -0
  26. package/dist/src/pending.js +330 -0
  27. package/dist/src/pick-request.js +56 -0
  28. package/dist/src/preflight.js +257 -0
  29. package/dist/src/recovery-key.js +386 -0
  30. package/dist/src/sas.js +18 -0
  31. package/dist/src/secure-store.js +176 -0
  32. package/dist/src/shell-arg.js +18 -0
  33. package/dist/src/slot-advice.js +74 -0
  34. package/dist/src/vault-container.js +115 -0
  35. package/dist/src/vault-crypto.js +190 -0
  36. package/dist/src/vault-protocol.js +358 -0
  37. package/dist/src/version.js +57 -0
  38. package/node_modules/@agmsg-cloud/sas-core/dist/src/bech32.d.ts +17 -0
  39. package/node_modules/@agmsg-cloud/sas-core/dist/src/bech32.js +103 -0
  40. package/node_modules/@agmsg-cloud/sas-core/dist/src/index.d.ts +17 -0
  41. package/node_modules/@agmsg-cloud/sas-core/dist/src/index.js +147 -0
  42. package/node_modules/@agmsg-cloud/sas-core/package.json +30 -0
  43. package/package.json +50 -7
  44. package/bin/agmsg-cloud.js +0 -4
@@ -0,0 +1,374 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { closeSync, constants, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, chmodSync, unlinkSync, writeFileSync, writeSync, } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ /**
6
+ * A server-minted org address: `org_` followed by a canonical uuid.
7
+ *
8
+ * Exported because the shape is a contract between the two places that care —
9
+ * the caller that receives one over the wire and the key that is built from it.
10
+ * Stating it in a comment next to the key was not enough: the comment justified
11
+ * a delimiter by a property nothing enforced.
12
+ */
13
+ export function isOrgAddress(value) {
14
+ return /^org_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value);
15
+ }
16
+ /**
17
+ * The storage key: one slot per (host, org).
18
+ *
19
+ * Both halves are derived rather than taken as typed — `https://x.test/` and
20
+ * `https://x.test` are one host and must not become two slots holding two
21
+ * secrets. The separator is a space because neither half can contain one: an
22
+ * origin has no spaces by construction, and an org address is `org_<uuid>`.
23
+ *
24
+ * That second half is now checked here rather than assumed. A malformed org —
25
+ * `x`, or anything carrying a space or a newline — would not be caught by a
26
+ * non-empty test upstream, and it does not fail loudly either: it writes a slot
27
+ * under a key nothing will ever look up again, which is worse than the
28
+ * `undefined` key this guarded against in the first place. The invariant is
29
+ * enforced where it is relied upon, so a future caller cannot skip it.
30
+ */
31
+ function keyFor(endpoint, org) {
32
+ if (!isOrgAddress(org)) {
33
+ throw new Error(`refusing to store a credential under a malformed org address`);
34
+ }
35
+ return `${originOf(endpoint)} ${org}`;
36
+ }
37
+ const EMPTY = { version: 1, active: null, credentials: {} };
38
+ export function credentialsPath(env = process.env) {
39
+ const base = env.AGMSG_CLOUD_HOME ?? join(homedir(), '.agmsg-cloud');
40
+ return join(base, 'credentials.json');
41
+ }
42
+ // An origin is the credential's key, so it must be derived — never taken as the
43
+ // caller typed it. `https://x.test/` and `https://x.test` are one host and must
44
+ // not become two slots holding two secrets.
45
+ export function originOf(endpoint) {
46
+ return new URL(endpoint).origin;
47
+ }
48
+ function readFile(path) {
49
+ let fd;
50
+ try {
51
+ fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
52
+ }
53
+ catch (err) {
54
+ const code = err.code;
55
+ // ELOOP: the path is a symlink. That is not "no credentials yet" — it is a
56
+ // credential file that someone else controls the destination of, so it
57
+ // fails loudly rather than being treated as absent and overwritten.
58
+ if (code === 'ELOOP')
59
+ throw new Error(`${path} is a symlink; refusing to read a credential through it`);
60
+ if (code === 'ENOENT')
61
+ return { ...EMPTY, credentials: {} };
62
+ throw err;
63
+ }
64
+ try {
65
+ const parsed = JSON.parse(readFileSync(fd, 'utf8'));
66
+ if (typeof parsed !== 'object' ||
67
+ parsed === null ||
68
+ parsed.version !== 1 ||
69
+ typeof parsed.credentials !== 'object') {
70
+ throw new Error(`${path} is not a v1 credential file`);
71
+ }
72
+ return parsed;
73
+ }
74
+ finally {
75
+ closeSync(fd);
76
+ }
77
+ }
78
+ /**
79
+ * The credential for a host, or for whatever the last login wrote.
80
+ *
81
+ * Callers ask by ORIGIN, because that is all they know: `connect` and `pull`
82
+ * have an endpoint, not an org. One host can now hold several credentials, so
83
+ * this has to choose, and there is exactly one honest way to choose between
84
+ * two secrets that both fit: don't.
85
+ *
86
+ * - one credential for the host — return it
87
+ * - several, and `active` is one of them — return that, the last login
88
+ * - several, and `active` is elsewhere — THROW, naming the orgs
89
+ *
90
+ * The last case is the one worth being careful about. Returning `null` there
91
+ * would say "no credential for this host", which is false and sends the caller
92
+ * down the sign-in path — the same collapse of "ambiguous" into "absent" that
93
+ * hid the original defect. It throws instead, and the message names the orgs so
94
+ * the person can say which one they meant.
95
+ */
96
+ export function readCredential(origin, env = process.env) {
97
+ const file = readFile(credentialsPath(env));
98
+ if (origin === null) {
99
+ return file.active ? (file.credentials[file.active] ?? null) : null;
100
+ }
101
+ const matches = Object.entries(file.credentials).filter(([key]) => key.startsWith(`${origin} `));
102
+ if (matches.length === 0)
103
+ return null;
104
+ if (matches.length === 1)
105
+ return matches[0][1];
106
+ if (file.active && file.credentials[file.active] && file.active.startsWith(`${origin} `)) {
107
+ return file.credentials[file.active];
108
+ }
109
+ const orgs = matches
110
+ .map(([, c]) => c.org)
111
+ .sort()
112
+ .join(', ');
113
+ throw new Error(`${origin} has credentials for more than one org (${orgs}) and none of them is the active one. ` +
114
+ `Run agmsg-cloud login again for the org you want, which makes it active.`);
115
+ }
116
+ // An atomic rename prevents a half-written FILE. It does not serialize an
117
+ // update: two logins to different origins both read the same old JSON, both
118
+ // rename, and the last one silently drops the other's entry — while both have
119
+ // already been told to activate. The loser is then in exactly the state the
120
+ // write-before-activate ordering exists to prevent (the server holds a live
121
+ // credential this machine no longer has). So the read-modify-write runs under a
122
+ // cross-process lock, not just an atomic replace.
123
+ // `mkdirSync({ mode })` only applies to a directory it creates, and a file
124
+ // rewritten in place keeps the mode it already had. So the 0700/0600 claim is
125
+ // enforced on every write rather than assumed from the first one — an
126
+ // `~/.agmsg-cloud` left group-readable by an older build, or by a user, would
127
+ // otherwise keep that mode forever while the comment claimed otherwise.
128
+ function enforceDirMode(dir) {
129
+ const mode = statSync(dir).mode & 0o777;
130
+ if (mode !== 0o700)
131
+ chmodSync(dir, 0o700);
132
+ }
133
+ function enforceFileMode(path) {
134
+ try {
135
+ const mode = statSync(path).mode & 0o777;
136
+ if (mode !== 0o600)
137
+ chmodSync(path, 0o600);
138
+ }
139
+ catch (err) {
140
+ if (err.code !== 'ENOENT')
141
+ throw err;
142
+ }
143
+ }
144
+ const LOCK_STALE_MS = 30_000;
145
+ const LOCK_RETRY_MS = 25;
146
+ const LOCK_TIMEOUT_MS = 10_000;
147
+ // The holder's pid goes IN the lock. Without it, "make sure no login is
148
+ // running" is advice the operator cannot act on; with it, the check is a
149
+ // concrete one they can perform.
150
+ function tryCreateExclusive(path) {
151
+ let fd;
152
+ try {
153
+ fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
154
+ }
155
+ catch (err) {
156
+ if (err.code === 'EEXIST')
157
+ return false;
158
+ throw err;
159
+ }
160
+ // Past a successful O_EXCL open, this call created the file and no one else
161
+ // can be holding it — which is what makes removing it here safe, and why the
162
+ // cleanup belongs on this side of the open and nowhere else.
163
+ //
164
+ // Without it a failed write (ENOSPC, EIO) leaves a lock with no owner and no
165
+ // release callback. Since this file stopped reclaiming abandoned locks, the
166
+ // next login would wait the full stale window and then ask a person to delete
167
+ // it by hand. The pid is written to help diagnose an abandoned lock; it must
168
+ // not be able to manufacture one.
169
+ try {
170
+ try {
171
+ writeSync(fd, `${process.pid}\n`);
172
+ }
173
+ finally {
174
+ closeSync(fd);
175
+ }
176
+ }
177
+ catch (err) {
178
+ try {
179
+ unlinkSync(path);
180
+ }
181
+ catch {
182
+ // Best effort. If the lock cannot be removed the original write error is
183
+ // still the one worth reporting, and a second failure here would bury it.
184
+ }
185
+ throw err;
186
+ }
187
+ return true;
188
+ }
189
+ function holderPid(path) {
190
+ try {
191
+ const text = readFileSync(path, 'utf8').trim();
192
+ return /^\d+$/.test(text) ? text : null;
193
+ }
194
+ catch {
195
+ // Best effort only: a lock we cannot read still gets the safety wording.
196
+ return null;
197
+ }
198
+ }
199
+ // ENOENT means the lock is gone, which is a real and expected answer. Any other
200
+ // stat error means something is wrong with the path, and rounding that to "gone"
201
+ // would spin the retry loop past its own deadline, silently. Broken is not absent.
202
+ function ageMs(path) {
203
+ try {
204
+ return Date.now() - statSync(path).mtimeMs;
205
+ }
206
+ catch (err) {
207
+ if (err.code === 'ENOENT')
208
+ return null;
209
+ throw err;
210
+ }
211
+ }
212
+ function spin(ms) {
213
+ // Deliberate: the lock is held for one small file rewrite, and sleeping would
214
+ // need an async signature the synchronous callers do not have.
215
+ const until = Date.now() + ms;
216
+ while (Date.now() < until) {
217
+ /* spin */
218
+ }
219
+ }
220
+ // Serializes the read-modify-write across PROCESSES, which is what two
221
+ // concurrent `agmsg-cloud login` runs are.
222
+ //
223
+ // An abandoned lock is NOT reclaimed automatically. An earlier version did
224
+ // that, correctly as far as anyone could argue — the right to break a stale
225
+ // lock was claimed with O_EXCL and held until the replacement was taken — but
226
+ // no test could tell it apart from the naive "stat it, then unlink it" version
227
+ // that races. Measured at 6 and at 24 concurrent writers, both implementations
228
+ // passed. A protection nothing can distinguish from its absence is one that
229
+ // stops protecting the moment somebody edits it, without anything going red.
230
+ //
231
+ // So the reclaim is gone. A lock older than the stale window stops the run and
232
+ // names the file to delete: one manual step after a crash, in exchange for
233
+ // behaviour a test can pin. (The same conclusion was reached independently on
234
+ // the OSS side for its mkdir lock.)
235
+ function acquireLock(path) {
236
+ const lockPath = `${path}.lock`;
237
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
238
+ for (;;) {
239
+ if (tryCreateExclusive(lockPath)) {
240
+ let released = false;
241
+ return () => {
242
+ if (released)
243
+ return;
244
+ released = true;
245
+ try {
246
+ unlinkSync(lockPath);
247
+ }
248
+ catch {
249
+ // Already gone; nothing to undo.
250
+ }
251
+ };
252
+ }
253
+ const age = ageMs(lockPath);
254
+ if (age === null)
255
+ continue; // vanished while being inspected
256
+ if (age > LOCK_STALE_MS) {
257
+ // A held lock is a stale CANDIDATE, never a proof that its owner died: a
258
+ // paused process, a sleeping machine, a scheduler stall or a slow fsync
259
+ // all pass this age. Saying otherwise would hand the operator the very
260
+ // act this code stopped doing automatically — deleting a live holder's
261
+ // lock, which puts two read-modify-writes back in flight by hand.
262
+ const pid = holderPid(lockPath);
263
+ throw new Error(`${lockPath} has been held for ${Math.round(age / 1000)}s. That usually means the process that took it ` +
264
+ `exited without cleaning up, but a suspended or very slow process looks identical from here` +
265
+ (pid === null ? '' : ` (it was taken by pid ${pid})`) +
266
+ `. Delete it ONLY if you can confirm no agmsg-cloud login is running or stopped on this machine; ` +
267
+ `otherwise leave it and try again.`);
268
+ }
269
+ if (Date.now() > deadline) {
270
+ throw new Error(`timed out waiting for ${lockPath}; another login is writing. Try again.`);
271
+ }
272
+ spin(LOCK_RETRY_MS);
273
+ }
274
+ }
275
+ export function writeCredential(credential, env = process.env) {
276
+ const path = credentialsPath(env);
277
+ const dir = dirname(path);
278
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
279
+ // `mkdirSync` mode applies only when it creates the directory; an existing one
280
+ // keeps whatever mode it has.
281
+ enforceDirMode(dir);
282
+ const release = acquireLock(path);
283
+ try {
284
+ writeLocked(path, dir, credential);
285
+ }
286
+ finally {
287
+ release();
288
+ }
289
+ }
290
+ function writeLocked(path, dir, credential) {
291
+ const key = keyFor(credential.endpoint, credential.org);
292
+ // Read INSIDE the lock: a copy read before acquiring it is exactly the stale
293
+ // base that loses another entry.
294
+ const file = readFile(path);
295
+ const next = {
296
+ version: 1,
297
+ active: key,
298
+ credentials: { ...file.credentials, [key]: credential },
299
+ };
300
+ commit(path, dir, next);
301
+ }
302
+ /**
303
+ * Remove every credential this origin holds, and say which they were.
304
+ *
305
+ * Same lock and same durable write as a login, for the same reason: two
306
+ * processes that read before locking both write a stale base, and the loser's
307
+ * entry comes back from the dead. Removal has to be as careful as writing —
308
+ * a sign-out that half-worked leaves a secret on disk that its owner believes
309
+ * is gone.
310
+ *
311
+ * Returns what it removed rather than a count. The caller names the machine on
312
+ * screen, and the person signing out is entitled to know which identity just
313
+ * left this machine.
314
+ */
315
+ export function removeCredentials(origin, env = process.env) {
316
+ const path = credentialsPath(env);
317
+ if (!existsSync(path))
318
+ return [];
319
+ const dir = dirname(path);
320
+ const release = acquireLock(path);
321
+ try {
322
+ const file = readFile(path);
323
+ const doomed = Object.entries(file.credentials).filter(([key]) => key.startsWith(`${origin} `));
324
+ if (doomed.length === 0)
325
+ return [];
326
+ const kept = Object.fromEntries(Object.entries(file.credentials).filter(([key]) => !key.startsWith(`${origin} `)));
327
+ // `active` is a KEY, and the key it names may be one of the ones going. A
328
+ // stale pointer would make `readCredential(null)` answer with a credential
329
+ // that is no longer in the file — null, not the first survivor: which org
330
+ // becomes current is the person's to say, and guessing it here would sign
331
+ // them into an account they did not choose.
332
+ const active = file.active && file.active in kept ? file.active : null;
333
+ commit(path, dir, { version: 1, active, credentials: kept });
334
+ return doomed.map(([, credential]) => credential);
335
+ }
336
+ finally {
337
+ release();
338
+ }
339
+ }
340
+ /** The atomic, durable write both paths share. Caller holds the lock. */
341
+ function commit(path, dir, next) {
342
+ // Unpredictable temp name: a name an attacker can guess is a name they can
343
+ // pre-create as a symlink pointing somewhere else.
344
+ const tmp = `${path}.${randomBytes(8).toString('hex')}.tmp`;
345
+ const fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
346
+ try {
347
+ writeSync(fd, `${JSON.stringify(next, null, 2)}\n`);
348
+ fsyncSync(fd);
349
+ }
350
+ catch (err) {
351
+ closeSync(fd);
352
+ unlinkSync(tmp);
353
+ throw err;
354
+ }
355
+ closeSync(fd);
356
+ renameSync(tmp, path);
357
+ enforceFileMode(path);
358
+ // Rename is atomic but not durable until the DIRECTORY entry is flushed. The
359
+ // whole point of writing before activating is that a crash here still leaves
360
+ // the credential on disk, so this fsync is load-bearing, not hygiene.
361
+ const dirFd = openSync(dir, constants.O_RDONLY);
362
+ try {
363
+ fsyncSync(dirFd);
364
+ }
365
+ finally {
366
+ closeSync(dirFd);
367
+ }
368
+ }
369
+ // Used only by the tests that need a file on disk without going through a
370
+ // login; kept here so the 0600/0700 rules live in exactly one place.
371
+ export function writeRawForTest(path, contents) {
372
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
373
+ writeFileSync(path, contents, { mode: 0o600 });
374
+ }
@@ -0,0 +1,148 @@
1
+ import { randomBytes, randomUUID } from 'node:crypto';
2
+ import { newVdk, unwrapVdk, wrapVdk, } from './vault-crypto.js';
3
+ import { deleteSecret, readSecret, secureStoreStatus, storeSecret, } from './secure-store.js';
4
+ export const DEVICE_WRAP_PROFILE = 'aes256gcm-v1';
5
+ // One account name per (deployment, account, vault, generation). The
6
+ // generation is in the name on purpose: after a re-issuance the old slot does
7
+ // not answer for the new vault, and the design requires exactly that — an
8
+ // old-generation slot must not open the current vault. Making it part of the
9
+ // address means the wrong slot is not found rather than found and refused.
10
+ function accountFor(address) {
11
+ // Length-prefixed, not delimiter-joined. These are strings at runtime
12
+ // whatever the upstream types promise, and a value containing the separator
13
+ // could otherwise make two different addresses produce one account name —
14
+ // which would hand one vault's slot to another. Length prefixes cannot be
15
+ // spelled around.
16
+ return [
17
+ 'vault-slot',
18
+ address.vaultServiceId,
19
+ address.accountId,
20
+ address.vaultId,
21
+ String(address.recoveryGeneration),
22
+ ]
23
+ .map((part) => `${part.length}:${part}`)
24
+ .join('');
25
+ }
26
+ function contextFor(address, slotId) {
27
+ return {
28
+ vaultId: address.vaultId,
29
+ recoveryGeneration: address.recoveryGeneration,
30
+ slotId,
31
+ slotType: 'device',
32
+ wrapProfile: DEVICE_WRAP_PROFILE,
33
+ vaultServiceId: address.vaultServiceId,
34
+ accountId: address.accountId,
35
+ };
36
+ }
37
+ /**
38
+ * Mint a device KEK, wrap this vault's VDK under it, and keep both.
39
+ *
40
+ * Returns rather than throws when there is no store: a machine without one is
41
+ * a machine that keeps typing its recovery key, which is the behaviour that
42
+ * exists today. What it must never become is a machine that writes the VDK to
43
+ * a file — the design forbids that in the same sentence that asks for the
44
+ * store, and there is no code path here that could.
45
+ */
46
+ export async function saveDeviceSlot(address, vdk) {
47
+ const status = await secureStoreStatus();
48
+ if (status.kind !== 'available')
49
+ return { ok: false, reason: describe(status) };
50
+ const slotId = randomUUID();
51
+ const kek = randomBytes(32);
52
+ const wrapped = wrapVdk(kek, vdk, contextFor(address, slotId));
53
+ const slot = {
54
+ slotId,
55
+ kek: kek.toString('base64'),
56
+ wrappedVdk: wrapped.toString('base64'),
57
+ wrapProfile: DEVICE_WRAP_PROFILE,
58
+ };
59
+ try {
60
+ await storeSecret(accountFor(address), JSON.stringify(slot));
61
+ }
62
+ catch (err) {
63
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) };
64
+ }
65
+ return { ok: true, slotId };
66
+ }
67
+ /**
68
+ * Recover the VDK from this machine's slot, or say there is none.
69
+ *
70
+ * "None" is a normal answer: it is what a machine looks like before setup, and
71
+ * what every machine looks like after a re-issuance moves the generation. The
72
+ * caller falls back to asking for the recovery key — it never guesses.
73
+ */
74
+ export async function openDeviceSlot(address) {
75
+ // The store's own state is reported as itself. Collapsing "this platform has
76
+ // none" into the same answer as "the slot is corrupt" would make every
77
+ // command on Windows print the warning that is supposed to mean something has
78
+ // gone wrong (raised in review).
79
+ const status = await secureStoreStatus();
80
+ if (status.kind === 'unsupported')
81
+ return { ok: false, reason: 'no-store', detail: status.reason };
82
+ if (status.kind === 'locked')
83
+ return { ok: false, reason: 'store-locked', detail: status.reason };
84
+ let raw;
85
+ try {
86
+ raw = await readSecret(accountFor(address));
87
+ }
88
+ catch (err) {
89
+ // The store was available a moment ago and the read still failed, so this
90
+ // is the store refusing rather than a bad slot.
91
+ return { ok: false, reason: 'store-locked', detail: err instanceof Error ? err.message : String(err) };
92
+ }
93
+ if (raw === null)
94
+ return { ok: false, reason: 'no-slot' };
95
+ let slot;
96
+ try {
97
+ slot = JSON.parse(raw);
98
+ }
99
+ catch {
100
+ return { ok: false, reason: 'unusable', detail: 'the stored slot is not readable' };
101
+ }
102
+ if (typeof slot.slotId !== 'string' ||
103
+ typeof slot.kek !== 'string' ||
104
+ typeof slot.wrappedVdk !== 'string' ||
105
+ slot.wrapProfile !== DEVICE_WRAP_PROFILE) {
106
+ // A slot written by a version that wrapped differently is refused, not
107
+ // guessed at. Opening it under today's assumptions would produce bytes
108
+ // that are not the VDK and fail much later, somewhere less obvious.
109
+ return { ok: false, reason: 'unusable', detail: 'the stored slot is not a shape this version wraps' };
110
+ }
111
+ try {
112
+ const vdk = unwrapVdk(Buffer.from(slot.kek, 'base64'), Buffer.from(slot.wrappedVdk, 'base64'), contextFor(address, slot.slotId));
113
+ return { ok: true, vdk };
114
+ }
115
+ catch (err) {
116
+ // The AAD did not match — a slot for another account, deployment, vault or
117
+ // generation, or a tampered store. Either way this machine cannot open
118
+ // this vault without the recovery key.
119
+ return { ok: false, reason: 'unusable', detail: err instanceof Error ? err.message : String(err) };
120
+ }
121
+ }
122
+ /**
123
+ * Forget this machine's slot, used when a re-issuance retires a generation.
124
+ *
125
+ * Returns a result rather than void, and never reports success for a store it
126
+ * could not reach. Deleting is the one operation where "no store" cannot mean
127
+ * "nothing to do": a locked keychain still holds the item, and a caller told
128
+ * the old KEK is gone would carry on believing the old generation can no
129
+ * longer be opened on this machine. Read can treat absence as an answer;
130
+ * delete cannot.
131
+ */
132
+ export async function forgetDeviceSlot(address) {
133
+ const status = await secureStoreStatus();
134
+ if (status.kind !== 'available')
135
+ return { ok: false, reason: describe(status) };
136
+ try {
137
+ await deleteSecret(accountFor(address));
138
+ return { ok: true };
139
+ }
140
+ catch (err) {
141
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) };
142
+ }
143
+ }
144
+ function describe(status) {
145
+ return status.kind === 'available' ? 'available' : `${status.kind}: ${status.reason}`;
146
+ }
147
+ /** Exposed for tests that need a VDK without reaching into vault-crypto. */
148
+ export { newVdk };
@@ -0,0 +1,167 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ // A cross-process lock for the local security files.
5
+ //
6
+ // The attempt ledger and the pending-nonce records are read, decided on, and
7
+ // written back. Inside one process that is a single synchronous stretch; across
8
+ // two it is not. Two CLI invocations started together both read a ledger with
9
+ // budget left, both decide they may proceed, and both send a commitment — so the
10
+ // rule that bounds an attacker's guesses is broken by running the command twice.
11
+ //
12
+ // mkdir is the primitive because it is atomic on every platform this runs on and
13
+ // needs no dependency: it either creates the directory or fails because someone
14
+ // else holds it. A file opened with 'wx' would do as well; a lock built out of
15
+ // "check then create" would not, since that is the very race being closed.
16
+ const RETRY_MS = 15;
17
+ const DEFAULT_TIMEOUT_MS = 5_000;
18
+ // A lock older than this is assumed to belong to a process that died holding it.
19
+ // Long enough that no honest holder is still working, short enough that a crash
20
+ // does not lock a user out of enrolling for the rest of the day.
21
+ const STALE_MS = 30_000;
22
+ export class StaleLock extends Error {
23
+ path;
24
+ constructor(path, ageMs) {
25
+ super(`a lock left behind ${Math.round(ageMs / 1000)}s ago is blocking this command.\n` +
26
+ ` Another copy may still be running. If none is, remove:\n ${path}\n` +
27
+ ' Nothing has been changed; no enrollment attempt was spent.');
28
+ this.path = path;
29
+ this.name = 'StaleLock';
30
+ }
31
+ }
32
+ export class LockTimeout extends Error {
33
+ constructor(path) {
34
+ super(`timed out waiting for the lock at ${path}`);
35
+ this.name = 'LockTimeout';
36
+ }
37
+ }
38
+ function ownerFile(lockDir) {
39
+ return join(lockDir, 'owner');
40
+ }
41
+ function heldFor(lockDir, now) {
42
+ try {
43
+ return now - statSync(ownerFile(lockDir)).mtimeMs;
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
49
+ // A value no other holder will produce. Written into the lock and read back
50
+ // after acquiring, so a holder can tell whether the lock it created is still the
51
+ // lock that exists.
52
+ let tokenCounter = 0;
53
+ function mintToken() {
54
+ tokenCounter += 1;
55
+ return `${process.pid}-${tokenCounter}-${randomBytes(8).toString('hex')}`;
56
+ }
57
+ function tryAcquire(lockDir, token) {
58
+ try {
59
+ // Without `recursive`, this fails when the directory exists. That failure IS
60
+ // the mutual exclusion; there is no check-then-create step to lose a race in.
61
+ mkdirSync(lockDir, { mode: 0o700 });
62
+ }
63
+ catch {
64
+ return false;
65
+ }
66
+ // Identifies the holder and carries the mtime that decides staleness.
67
+ writeFileSync(ownerFile(lockDir), token, { mode: 0o600 });
68
+ return true;
69
+ }
70
+ function currentToken(lockDir) {
71
+ try {
72
+ return readFileSync(ownerFile(lockDir), 'utf8');
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ }
78
+ /**
79
+ * Run `fn` with exclusive access to `key`, across processes.
80
+ *
81
+ * The callback must do the whole read-decide-write, not just the write: holding
82
+ * the lock only for the write leaves the decision based on a value another
83
+ * holder has already changed.
84
+ */
85
+ export function withFileLock(key, fn, options = {}) {
86
+ const lockDir = `${key}.lock`;
87
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
88
+ const now = options.now ?? Date.now;
89
+ mkdirSync(dirname(lockDir), { recursive: true, mode: 0o700 });
90
+ const deadline = now() + timeoutMs;
91
+ const token = mintToken();
92
+ for (;;) {
93
+ if (tryAcquire(lockDir, token))
94
+ break;
95
+ // A lock this old belongs to a process that died holding it. It is NOT
96
+ // taken over automatically.
97
+ //
98
+ // Automatic takeover went wrong twice here, and an earlier version of this
99
+ // comment claimed it could not be done at all. That was wrong: serialising
100
+ // the RIGHT TO BREAK with a second O_EXCL marker, and holding that marker
101
+ // until the main lock has been acquired, closes the window. Dropping the
102
+ // marker any earlier reopens it.
103
+ //
104
+ // It is not used, for a different and smaller reason: by measurement, that
105
+ // design cannot be told apart from the broken one by any test we can write
106
+ // without a hook inside the production path — the break-and-create is too
107
+ // fast to interleave from outside. Failing closed is the version whose
108
+ // behaviour a test can actually pin.
109
+ //
110
+ // So a dead lock stops the command and says what to delete. The section it
111
+ // guards is a few small file writes, so the window in which a crash can
112
+ // leave one behind is milliseconds wide; trading a rare manual step for a
113
+ // race in the mechanism that bounds an attacker's guesses is the wrong way
114
+ // round.
115
+ const age = heldFor(lockDir, now());
116
+ if (age !== null && age > STALE_MS)
117
+ throw new StaleLock(lockDir, age);
118
+ if (now() >= deadline)
119
+ throw new LockTimeout(lockDir);
120
+ sleepSync(RETRY_MS);
121
+ }
122
+ try {
123
+ return fn();
124
+ }
125
+ finally {
126
+ // Safe to remove unconditionally: nothing takes a lock away from its holder,
127
+ // so the directory here is the one this call created. An earlier draft
128
+ // re-checked the token, which only mattered while automatic takeover
129
+ // existed — a guard that cannot fire reads as protection and is not.
130
+ rmSync(lockDir, { recursive: true, force: true });
131
+ }
132
+ }
133
+ // The callers are synchronous CLI paths, so this blocks rather than yielding.
134
+ // Atomics.wait on a private buffer is the portable way to sleep without a busy
135
+ // loop that would burn a core while two invocations contend.
136
+ function sleepSync(ms) {
137
+ const shared = new Int32Array(new SharedArrayBuffer(4));
138
+ Atomics.wait(shared, 0, 0, ms);
139
+ }
140
+ // A temp name no other holder can collide with. A fixed `${path}.tmp` is shared
141
+ // by every process writing the same file, so two writers rename over each other
142
+ // and one update is lost — the failure mode being defended against here.
143
+ let counter = 0;
144
+ export function uniqueTempPath(path) {
145
+ counter += 1;
146
+ return `${path}.${process.pid}.${counter}.tmp`;
147
+ }
148
+ /**
149
+ * Read a file, distinguishing "not there" from "could not be read".
150
+ *
151
+ * Only ENOENT is absence. Every other error — a permission denied, a directory
152
+ * where the file should be, an I/O failure, a symlink loop — used to come back
153
+ * as null, which the ledger reads as an empty budget and the pending store
154
+ * reads as no reserved nonce. So the rule that a damaged record must never be
155
+ * mistaken for a missing one, which the parsing layer enforces carefully, was
156
+ * being broken one layer below it by the read itself.
157
+ */
158
+ export function readIfPresent(path) {
159
+ try {
160
+ return readFileSync(path, 'utf8');
161
+ }
162
+ catch (err) {
163
+ if (err.code === 'ENOENT')
164
+ return null;
165
+ throw err;
166
+ }
167
+ }