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,49 @@
1
+ import { spawn } from 'node:child_process';
2
+ // The environment every OSS script inherits from this CLI.
3
+ //
4
+ // One place, because the thing it says is a property of "this process runs
5
+ // agmsg as a step of something bigger", not of any particular command. Setting
6
+ // it at each spawn site would mean three copies of one decision, and the one
7
+ // that got missed would be the one printing the wrong route to an operator.
8
+ //
9
+ // `AGMSG_OPERATOR_GUIDANCE=caller` tells the OSS scripts that whoever invoked
10
+ // them owns the "what to do next" lines. It does not silence errors, warnings,
11
+ // progress or prompts, and it does not silence facts — only the closing
12
+ // guidance, which for a plain install names routes this product does not have:
13
+ // no server-side recovery (there is one here), `key.sh show --reveal-secret`
14
+ // (people using this never meet key.sh), and carrying a snapshot out of band
15
+ // (there is a ceremony here that exists so nobody has to).
16
+ //
17
+ // Taking that on is an obligation, not a mute button: OSS no longer says those
18
+ // things, so this side has to.
19
+ export const OSS_GUIDANCE_ENV = 'AGMSG_OPERATOR_GUIDANCE';
20
+ export function ossChildEnv(env = process.env) {
21
+ return { ...env, [OSS_GUIDANCE_ENV]: 'caller' };
22
+ }
23
+ /**
24
+ * Start an OSS script. The only way this CLI does.
25
+ *
26
+ * A wrapper rather than a rule to remember, because "every spawn site must
27
+ * also pass the environment" is the kind of rule that holds until someone adds
28
+ * a fourth site. There is nowhere here to forget it: the environment is not a
29
+ * parameter, and a caller that wanted to skip it would have to stop using this
30
+ * function, which is visible in a way a missing option is not.
31
+ *
32
+ * stdio stays the caller's choice — `connect` and `pull` inherit it so the
33
+ * operator sees progress and can answer prompts; the wrappers in oss.ts pipe
34
+ * it because they parse what comes back.
35
+ */
36
+ /**
37
+ * Start an OSS script whose output the operator watches, and may answer.
38
+ *
39
+ * Two functions rather than one with a mode, because the pipe variant's
40
+ * streams are non-null and the inherited one's are not — a single signature
41
+ * loses that and hands every caller a null check it does not need.
42
+ */
43
+ export function spawnOssInherit(command, args) {
44
+ return spawn(command, [...args], { stdio: 'inherit', env: ossChildEnv() });
45
+ }
46
+ /** Start an OSS script whose output this CLI reads. */
47
+ export function spawnOssPiped(command, args) {
48
+ return spawn(command, [...args], { stdio: ['pipe', 'pipe', 'pipe'], env: ossChildEnv() });
49
+ }
@@ -0,0 +1,289 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { closeSync, constants, openSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { spawnOssPiped } from './oss-env.js';
5
+ // Thin wrappers over the OSS tooling. The proprietary courier never touches key
6
+ // material: sealing/opening the bundle and the handoff/unlock steps all run
7
+ // through `age` and the OSS `key.sh` / `remote.sh` on the operator's machine.
8
+ // This module only shells out to them.
9
+ // Run a command, piping `input` to stdin and collecting stdout as a Buffer.
10
+ // Rejects with the exit code and captured stderr (never the input) on failure.
11
+ function run(cmd, args, input) {
12
+ return new Promise((resolve, reject) => {
13
+ const child = spawnOssPiped(cmd, args);
14
+ const out = [];
15
+ const err = [];
16
+ child.stdout.on('data', (d) => out.push(d));
17
+ child.stderr.on('data', (d) => err.push(d));
18
+ child.on('error', reject);
19
+ child.on('close', (code) => {
20
+ if (code === 0)
21
+ resolve(Buffer.concat(out));
22
+ else
23
+ reject(new Error(`${cmd} exited ${code}: ${Buffer.concat(err).toString().trim()}`));
24
+ });
25
+ if (input !== undefined)
26
+ child.stdin.end(input);
27
+ else
28
+ child.stdin.end();
29
+ });
30
+ }
31
+ /** A finished run, whatever its exit code. For callers where non-zero is an ANSWER. */
32
+ function runAllowingFailure(cmd, args) {
33
+ return new Promise((resolve, reject) => {
34
+ const child = spawnOssPiped(cmd, args);
35
+ const out = [];
36
+ const err = [];
37
+ child.stdout.on('data', (d) => out.push(d));
38
+ child.stderr.on('data', (d) => err.push(d));
39
+ child.on('error', reject);
40
+ child.on('close', (code) => resolve({
41
+ code: code ?? -1,
42
+ stdout: Buffer.concat(out).toString(),
43
+ stderr: Buffer.concat(err).toString().trim(),
44
+ }));
45
+ child.stdin.end();
46
+ });
47
+ }
48
+ /**
49
+ * Whether this machine has a team by this name — the question `unlock` asks.
50
+ *
51
+ * NOT `remoteBinding`, and the difference was measured rather than assumed.
52
+ * `remoteBinding` additionally requires the binding to be ACTIVE, and against
53
+ * the real scripts:
54
+ *
55
+ * team status <team> --json unlock <team>
56
+ * connected exit 0, state active past the team check
57
+ * DISCONNECTED exit 0, state disconnected past the team check
58
+ * never existed exit 1 "agmsg: team not found"
59
+ *
60
+ * So a disconnected team is one `unlock` accepts and `remoteBinding` refuses.
61
+ * A caller that used the stricter check to decide whether a name is known would
62
+ * turn a working command into a refusal — which is the shape of defect this arc
63
+ * has already paid for twice. The exit code is the answer; the state is not.
64
+ *
65
+ * A non-zero exit is reported WITH what the store said, so a broken script does
66
+ * not get presented to the operator as a mistyped name.
67
+ */
68
+ export async function localTeamLookup(scriptsDir, team) {
69
+ const r = await runAllowingFailure('bash', [
70
+ join(scriptsDir, 'remote.sh'),
71
+ 'status',
72
+ team,
73
+ '--json',
74
+ ]);
75
+ return r.code === 0 ? { known: true, said: '' } : { known: false, said: r.stderr };
76
+ }
77
+ // Generate a device age identity at `identityPath`; returns its public recipient.
78
+ export async function generateDeviceIdentity(identityPath) {
79
+ await run('age-keygen', ['-o', identityPath]);
80
+ const pub = (await run('age-keygen', ['-y', identityPath])).toString().trim();
81
+ return pub;
82
+ }
83
+ // The public recipient of an existing device identity file.
84
+ export async function publicKeyOf(identityPath) {
85
+ return (await run('age-keygen', ['-y', identityPath])).toString().trim();
86
+ }
87
+ // Seal plaintext to a device's public recipient (binary age ciphertext).
88
+ export function sealToRecipient(recipient, plaintext) {
89
+ return run('age', ['-r', recipient, '-e'], plaintext);
90
+ }
91
+ // Open a sealed blob with the local device identity.
92
+ export function decryptWithIdentity(identityPath, ciphertext) {
93
+ return run('age', ['-d', '-i', identityPath], ciphertext);
94
+ }
95
+ // OSS `key.sh handoff <team> --out <file>` — export the one secret handoff bundle
96
+ // (confirmed snapshot chain + every epoch identity) for `team`.
97
+ export async function keyHandoff(scriptsDir, team, outFile) {
98
+ await run('bash', [join(scriptsDir, 'key.sh'), 'handoff', team, '--out', outFile]);
99
+ }
100
+ // OSS `remote-sync.sh verify-age-handoff` — the digest the joiner's `unlock`
101
+ // will compare the human-carried value against.
102
+ //
103
+ // It is NOT recomputed here. `unlock` compares `snapshot_sha256`, which is
104
+ // SHA-256 over the RFC 8785 canonical JSON of the bundle's latest snapshot — not
105
+ // over the bundle file. A second implementation of that in TypeScript would
106
+ // agree until the day it did not, and the disagreement would surface as two
107
+ // people reading the same digits aloud while the gate refuses them. So the
108
+ // approver asks the same code the joiner will ask.
109
+ //
110
+ // The verification writes the bundle's epoch identities into `outDir`. That is
111
+ // not new exposure: the bundle handed to it already contains those identities
112
+ // and is already on disk beside it — but the directory holds private key
113
+ // material and must be treated exactly like the bundle.
114
+ export async function verifyHandoffDigest(scriptsDir, team, bundleFile, outDir) {
115
+ const out = await run('bash', [
116
+ join(scriptsDir, 'remote-sync.sh'),
117
+ 'verify-age-handoff',
118
+ '--team',
119
+ team,
120
+ '--bundle',
121
+ bundleFile,
122
+ '--out-dir',
123
+ outDir,
124
+ ]);
125
+ const text = out.toString().trim();
126
+ const lines = text.split('\n').filter((l) => l.trim() !== '');
127
+ // The contract is ONE line of JSON on stdout (the shell wrapper writes nothing
128
+ // there; every human-facing line goes to stderr). Taking the last line would
129
+ // quietly absorb anything that started appearing before it, which is how an
130
+ // ABI change becomes a silent behaviour change instead of a loud failure.
131
+ if (lines.length !== 1) {
132
+ throw new Error(`verify-age-handoff wrote ${lines.length} lines to stdout; the contract is exactly one JSON result`);
133
+ }
134
+ let parsed;
135
+ try {
136
+ parsed = JSON.parse(lines[0]);
137
+ }
138
+ catch {
139
+ throw new Error('verify-age-handoff did not produce a JSON result');
140
+ }
141
+ const result = parsed;
142
+ // Shape-checked rather than trusted: a digest read out of an unexpected
143
+ // response would be confirmed by a human and then never match.
144
+ if (result.type !== 'age_handoff_verified' || typeof result.snapshot_sha256 !== 'string') {
145
+ throw new Error('verify-age-handoff returned an unrecognised result');
146
+ }
147
+ if (!/^[a-f0-9]{64}$/.test(result.snapshot_sha256)) {
148
+ throw new Error('verify-age-handoff returned a digest that is not a SHA-256 hex string');
149
+ }
150
+ return result.snapshot_sha256;
151
+ }
152
+ // The full binding, for callers that have to record WHICH remote a team's
153
+ // material came from. `remoteTeamId` stays for callers that only address the
154
+ // team, so neither has to know about the other's fields.
155
+ export async function remoteBinding(scriptsDir, team) {
156
+ const out = await run('bash', [join(scriptsDir, 'remote.sh'), 'status', team, '--json']);
157
+ const text = out.toString().trim();
158
+ if (!text)
159
+ throw new Error(`team '${team}' has never been connected to a remote`);
160
+ const status = JSON.parse(text);
161
+ if (status.state !== 'active') {
162
+ throw new Error(`team '${team}' is not connected (state: ${String(status.state)})`);
163
+ }
164
+ if (typeof status.remote_team_id !== 'string' || !status.remote_team_id) {
165
+ throw new Error(`team '${team}' has no remote team id recorded`);
166
+ }
167
+ if (typeof status.server_instance_id !== 'string' || !status.server_instance_id) {
168
+ // Refuse rather than file the entry under an empty instance: the binding is
169
+ // what tells a restore that this bundle belongs to the remote this machine
170
+ // is talking to.
171
+ throw new Error(`team '${team}' has no server instance id recorded`);
172
+ }
173
+ return { teamId: status.remote_team_id, serverInstanceId: status.server_instance_id };
174
+ }
175
+ export async function connectedTeams(scriptsDir) {
176
+ const out = await run('bash', [join(scriptsDir, 'remote.sh'), 'status', '--json']);
177
+ const teams = [];
178
+ for (const line of out.toString().split('\n')) {
179
+ const text = line.trim();
180
+ if (text === '')
181
+ continue;
182
+ let status;
183
+ try {
184
+ status = JSON.parse(text);
185
+ }
186
+ catch {
187
+ // One unreadable line must not decide the whole answer either way:
188
+ // skipping it silently would file a backup that quietly omits a team, so
189
+ // it is refused instead. A status output this client cannot parse is not
190
+ // a shorter list of teams.
191
+ // Worded so it does not read as a command to paste: it quotes DATA, and
192
+ // the checker is right that an interpolation inside something
193
+ // command-shaped is worth refusing on sight.
194
+ throw new Error(`the team status this machine printed contains a line this version cannot read: ${JSON.stringify(text.slice(0, 80))}`);
195
+ }
196
+ // A state this version does not know is not quietly dropped and not quietly
197
+ // treated as filable: an unknown word here is the same shape as a line that
198
+ // did not parse, and it gets the same refusal.
199
+ if (status.state !== 'active' && status.state !== 'disconnected') {
200
+ throw new Error(`the team status this machine printed describes a state this version cannot read: ${JSON.stringify(String(status.state).slice(0, 40))}`);
201
+ }
202
+ if (typeof status.local_team !== 'string' ||
203
+ typeof status.remote_team_id !== 'string' ||
204
+ !status.remote_team_id ||
205
+ typeof status.server_instance_id !== 'string' ||
206
+ !status.server_instance_id) {
207
+ throw new Error(`remote.sh status --json described a team without the ids a vault entry needs`);
208
+ }
209
+ teams.push({
210
+ team: status.local_team,
211
+ teamId: status.remote_team_id,
212
+ serverInstanceId: status.server_instance_id,
213
+ state: status.state,
214
+ });
215
+ }
216
+ return teams;
217
+ }
218
+ export async function remoteTeamId(scriptsDir, team) {
219
+ const out = await run('bash', [join(scriptsDir, 'remote.sh'), 'status', team, '--json']);
220
+ const text = out.toString().trim();
221
+ if (!text)
222
+ throw new Error(`team '${team}' has never been connected to a remote`);
223
+ const status = JSON.parse(text);
224
+ if (status.state !== 'active') {
225
+ // A disconnected binding's remote_team_id is a leftover. Writing a vault
226
+ // version under it would file the backup against a team this machine is no
227
+ // longer bound to.
228
+ throw new Error(`team '${team}' is not connected (state: ${String(status.state)})`);
229
+ }
230
+ if (typeof status.remote_team_id !== 'string' || !status.remote_team_id) {
231
+ throw new Error(`team '${team}' has no remote team id recorded`);
232
+ }
233
+ return status.remote_team_id;
234
+ }
235
+ // OSS `remote.sh unlock <team> --authenticated-bundle-stdin`.
236
+ //
237
+ // The disaster-restore ingress. We pass the exact Buffer the AEAD produced,
238
+ // straight down the pipe — no temporary file, no path. A path would reopen the
239
+ // window this mode exists to close: what we authenticated and what gets imported
240
+ // must be the same bytes, not the same filename. It also means the decrypted
241
+ // bundle never lands on disk in the clear.
242
+ //
243
+ // remote.sh performs no authentication here; it trusts that the caller did. That
244
+ // obligation is ours: only call this with a buffer that came out of
245
+ // unwrapWithVdk, whose AAD bound the vault, team, and profile we expected. The
246
+ // courier path must keep using unlockBundle with an out-of-band digest, because
247
+ // age's recipient encryption does not authenticate the sender.
248
+ export async function unlockAuthenticatedBundle(scriptsDir, team, bundle) {
249
+ await run('bash', [join(scriptsDir, 'remote.sh'), 'unlock', team, '--authenticated-bundle-stdin'], bundle);
250
+ }
251
+ // OSS `remote.sh unlock <team> --bundle <file> [--confirm-digest <digest>]`.
252
+ export async function unlockBundle(scriptsDir, team, bundleFile, confirmDigest) {
253
+ const args = [join(scriptsDir, 'remote.sh'), 'unlock', team, '--bundle', bundleFile];
254
+ if (confirmDigest)
255
+ args.push('--confirm-digest', confirmDigest);
256
+ await run('bash', args);
257
+ }
258
+ // The canonical age snapshot, exported locally, and its digest (§3.2, §3.2.1).
259
+ //
260
+ // This is what A commits to BEFORE either side opens. It is the public
261
+ // key-state description — no identity material — so what it leaves behind if
262
+ // this process is killed is a snapshot in a file the user owns, not a secret.
263
+ // The bundle that does carry identities is generated after the human answers.
264
+ //
265
+ // The digest is taken from the FILE, not from the script's stderr line. The
266
+ // spec defines it as the SHA-256 of the canonical JSON, and the file is that
267
+ // JSON; the stderr line is a human-facing surface whose wording can change
268
+ // without anything failing. Reading the definition rather than the message
269
+ // also means A and B compute the same value from the same rule.
270
+ export async function exportSnapshotDigest(scriptsDir, team, outPath) {
271
+ await run('bash', [
272
+ join(scriptsDir, 'remote-sync.sh'),
273
+ 'export-age-snapshot',
274
+ '--team',
275
+ team,
276
+ '--out',
277
+ outPath,
278
+ ]);
279
+ // O_NOFOLLOW: the path is one we just asked another program to create, and
280
+ // reading a snapshot through a symlink someone else planted would hash a
281
+ // file this machine never wrote.
282
+ const fd = openSync(outPath, constants.O_RDONLY | constants.O_NOFOLLOW);
283
+ try {
284
+ return createHash('sha256').update(readFileSync(fd)).digest('hex');
285
+ }
286
+ finally {
287
+ closeSync(fd);
288
+ }
289
+ }
@@ -0,0 +1,8 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ // Where this machine keeps its device identity (the age private key that blobs
4
+ // are sealed to). It never leaves the machine; 0600, outside any repo.
5
+ export function deviceIdentityPath(env = process.env) {
6
+ const base = env.AGMSG_CLOUD_HOME ?? join(homedir(), '.agmsg-cloud');
7
+ return join(base, 'device.key');
8
+ }
@@ -0,0 +1,330 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { chmodSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { deriveApproverCommitment, isCanonicalAgeRecipient } from '@agmsg-cloud/sas-core';
6
+ import { readIfPresent, uniqueTempPath, withFileLock } from './filelock.js';
7
+ // Same scope as the §4.1 ledger: the canonical server origin. A record from one
8
+ // deployment must never satisfy another, so the origin is part of the key rather
9
+ // than a field a reader could forget to check.
10
+ function originTag(serverOrigin) {
11
+ return createHash('sha256').update(serverOrigin).digest('hex').slice(0, 16);
12
+ }
13
+ export function pendingDir(env = process.env) {
14
+ const base = env['AGMSG_CLOUD_HOME'] ?? join(homedir(), '.agmsg-cloud');
15
+ return join(base, 'pending');
16
+ }
17
+ function recordPath(dir, role, origin, key) {
18
+ return join(dir, `${role}-${originTag(origin)}-${key}.json`);
19
+ }
20
+ // 0700 on the directory as well as 0600 on the file: a world-readable directory
21
+ // leaks which ceremonies are in flight and when, even if the nonces themselves
22
+ // stay unreadable.
23
+ function ensureDir(dir) {
24
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
25
+ chmodSync(dir, 0o700);
26
+ }
27
+ // Written to a temporary name and renamed, so a reader never sees a half-written
28
+ // record, and created 0600 from the start rather than chmod'ed afterwards — a
29
+ // window where the nonce is world-readable is still a window.
30
+ function writeRecord(path, record) {
31
+ // Per-process temp name: a shared one is renamed over by every writer, so two
32
+ // processes reserving at once lose one of the two records — and a lost record
33
+ // is a nonce nobody can open with.
34
+ const tmp = uniqueTempPath(path);
35
+ writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
36
+ chmodSync(tmp, 0o600);
37
+ renameSync(tmp, path);
38
+ }
39
+ const HEX64 = /^[0-9a-f]{64}$/;
40
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
41
+ function validTimestamp(value) {
42
+ if (typeof value !== 'string')
43
+ return false;
44
+ const ms = Date.parse(value);
45
+ return Number.isFinite(ms) && new Date(ms).toISOString() === value;
46
+ }
47
+ /**
48
+ * Read a record, checking every field rather than only that the file is JSON.
49
+ *
50
+ * An earlier version accepted anything that parsed, so `{}` came back as an
51
+ * existing record: minting was suppressed and undefined values went on to be
52
+ * used as a nonce. "Corrupt" has to mean "does not satisfy the shape", or the
53
+ * refusal protects only against a truncated write.
54
+ *
55
+ * The expected role, origin and key are passed in and compared against the
56
+ * contents, so a record cannot be moved or renamed into another ceremony's slot.
57
+ */
58
+ function readRecord(path, expected) {
59
+ const raw = readIfPresent(path);
60
+ if (raw === null)
61
+ return null;
62
+ const fail = () => {
63
+ throw new Error(`pending record is unreadable: ${path}`);
64
+ };
65
+ let parsed;
66
+ try {
67
+ parsed = JSON.parse(raw);
68
+ }
69
+ catch {
70
+ return fail();
71
+ }
72
+ if (typeof parsed !== 'object' || parsed === null)
73
+ return fail();
74
+ const r = parsed;
75
+ if (r['role'] !== expected.role)
76
+ return fail();
77
+ if (r['serverOrigin'] !== expected.serverOrigin)
78
+ return fail();
79
+ if (typeof r['commitmentHex'] !== 'string' || !HEX64.test(r['commitmentHex']))
80
+ return fail();
81
+ if (typeof r['nonceHex'] !== 'string' || !HEX64.test(r['nonceHex']))
82
+ return fail();
83
+ if (!validTimestamp(r['createdAt']))
84
+ return fail();
85
+ if (expected.role === 'requester') {
86
+ // The commitment IS the file key for a requester, so a mismatch means this
87
+ // record belongs to a different ceremony.
88
+ if (r['commitmentHex'] !== expected.key)
89
+ return fail();
90
+ if (typeof r['devicePubkey'] !== 'string' || !isCanonicalAgeRecipient(r['devicePubkey'])) {
91
+ return fail();
92
+ }
93
+ if (r['requestId'] !== undefined && (typeof r['requestId'] !== 'string' || !UUID.test(r['requestId']))) {
94
+ return fail();
95
+ }
96
+ }
97
+ else {
98
+ if (typeof r['requestId'] !== 'string' || r['requestId'] !== expected.key)
99
+ return fail();
100
+ if (r['devicePubkey'] !== undefined)
101
+ return fail();
102
+ // Required, and required STRICTLY. A record written before the digest was
103
+ // part of the commitment cannot be repaired by attaching today's snapshot:
104
+ // the stored commitment was computed without one, so the pair would not
105
+ // reproduce it, and the server would refuse the opening after the human
106
+ // had already compared digits. Fail here and let a fresh attempt start
107
+ // (raised in review).
108
+ if (typeof r['handoffDigestHex'] !== 'string' || !HEX64.test(r['handoffDigestHex'])) {
109
+ return fail();
110
+ }
111
+ }
112
+ // A requester never has one. Allowing it would let a file written for one
113
+ // role be read as the other's.
114
+ if (expected.role === 'requester' && r['handoffDigestHex'] !== undefined)
115
+ return fail();
116
+ return r;
117
+ }
118
+ export function fileMode(path) {
119
+ return statSync(path).mode & 0o777;
120
+ }
121
+ /**
122
+ * Reserve the requester's opening nonce for a commitment.
123
+ *
124
+ * Called BEFORE the commitment is posted. If a record already exists for this
125
+ * commitment, it is returned unchanged — the same commitment must always open to
126
+ * the same nonce.
127
+ */
128
+ export function reserveRequesterNonce(input, env = process.env) {
129
+ const dir = pendingDir(env);
130
+ ensureDir(dir);
131
+ const path = recordPath(dir, 'requester', input.serverOrigin, input.commitmentHex);
132
+ const expected = { role: 'requester', serverOrigin: input.serverOrigin, key: input.commitmentHex };
133
+ // Read and write under one lock. Split apart, two processes both see nothing
134
+ // and both write — and the loser's nonce is the one the server is holding a
135
+ // commitment for.
136
+ return withFileLock(path, () => {
137
+ const existing = readRecord(path, expected);
138
+ if (existing)
139
+ return existing;
140
+ const record = {
141
+ role: 'requester',
142
+ commitmentHex: input.commitmentHex,
143
+ nonceHex: input.nonceHex,
144
+ devicePubkey: input.devicePubkey,
145
+ serverOrigin: input.serverOrigin,
146
+ createdAt: new Date().toISOString(),
147
+ };
148
+ writeRecord(path, record);
149
+ return record;
150
+ });
151
+ }
152
+ /**
153
+ * Reserve the approver's nonce for one enrollment request.
154
+ *
155
+ * The mint callback runs ONLY when no record exists. After a crash this returns
156
+ * the nonce already committed to, because replacing a fixed commitment is
157
+ * indistinguishable from a substitution attack.
158
+ */
159
+ /**
160
+ * Reserve the approver's (nonce, digest, commitment) — as one thing.
161
+ *
162
+ * `handoffDigestHex` is exported OUTSIDE this lock, because exporting a
163
+ * snapshot spawns a child process and the lock is synchronous. That is safe
164
+ * for one reason, and it is worth stating: a caller that loses the race
165
+ * DISCARDS its own digest. It returns the stored record whole, so the value
166
+ * that ends up committed to, opened with, and compared by a human is the one
167
+ * on disk — never a second snapshot taken concurrently (raised in review).
168
+ *
169
+ * The trio is written in a single record, and read back through the validator
170
+ * that requires all three, so a partially written or pre-digest record fails
171
+ * rather than being completed from the current state.
172
+ */
173
+ /**
174
+ * Does this approver record reproduce its own commitment from its own nonce and
175
+ * digest?
176
+ *
177
+ * The three values are only meaningful together, and a record that fails this
178
+ * cannot be used for anything: it would post a commitment the server never saw,
179
+ * or open one it cannot satisfy. Which of the three is wrong is unknowable from
180
+ * here, so there is nothing to repair.
181
+ *
182
+ * Shared, because two callers now ask it for different reasons and they must
183
+ * not drift: reserving refuses to continue from a record like this, and the
184
+ * budget refuses to treat one as evidence that a run is re-sending a commitment
185
+ * it has already been charged for (raised in review).
186
+ */
187
+ export function approverRecordAgreesWithItself(record) {
188
+ if (record.role !== 'approver')
189
+ return false;
190
+ if (typeof record.handoffDigestHex !== 'string')
191
+ return false;
192
+ return (deriveApproverCommitment(new Uint8Array(Buffer.from(record.nonceHex, 'hex')), record.handoffDigestHex) === record.commitmentHex);
193
+ }
194
+ export function reserveApproverNonce(input, mint, env = process.env) {
195
+ if (!HEX64.test(input.handoffDigestHex)) {
196
+ throw new Error('handoff digest must be 64 lowercase hexadecimal characters');
197
+ }
198
+ const dir = pendingDir(env);
199
+ ensureDir(dir);
200
+ const path = recordPath(dir, 'approver', input.serverOrigin, input.requestId);
201
+ const expected = { role: 'approver', serverOrigin: input.serverOrigin, key: input.requestId };
202
+ // The mint callback runs INSIDE the lock, so two processes cannot each mint a
203
+ // nonce for one request. Two different nonces for one fixed commitment is,
204
+ // from the requester's side, indistinguishable from a substitution.
205
+ return withFileLock(path, () => {
206
+ const existing = readRecord(path, expected);
207
+ if (existing) {
208
+ // The three values are only meaningful together. A record whose stored
209
+ // commitment does not reproduce from its own nonce and digest is one
210
+ // this cannot use — it would post a commitment the server never saw, or
211
+ // open one it cannot satisfy — and there is nothing to repair, because
212
+ // which of the three is wrong is unknowable from here.
213
+ if (!approverRecordAgreesWithItself(existing)) {
214
+ throw new Error(`pending approver record does not agree with itself: ${path}\n\n` +
215
+ 'Remove it and start a fresh approval; its commitment cannot be reproduced from the nonce and digest stored beside it.');
216
+ }
217
+ return existing;
218
+ }
219
+ const minted = mint(input.handoffDigestHex);
220
+ const record = {
221
+ role: 'approver',
222
+ commitmentHex: minted.commitmentHex,
223
+ nonceHex: minted.nonceHex,
224
+ handoffDigestHex: input.handoffDigestHex,
225
+ requestId: input.requestId,
226
+ serverOrigin: input.serverOrigin,
227
+ createdAt: new Date().toISOString(),
228
+ };
229
+ writeRecord(path, record);
230
+ return record;
231
+ });
232
+ }
233
+ // Record the id the server assigned, once the commitment is accepted. Only ever
234
+ // adds the id; the nonce and commitment are never touched.
235
+ export function attachRequestId(input, env = process.env) {
236
+ const path = recordPath(pendingDir(env), 'requester', input.serverOrigin, input.commitmentHex);
237
+ const expected = { role: 'requester', serverOrigin: input.serverOrigin, key: input.commitmentHex };
238
+ withFileLock(path, () => {
239
+ const existing = readRecord(path, expected);
240
+ if (!existing)
241
+ return;
242
+ writeRecord(path, { ...existing, requestId: input.requestId });
243
+ });
244
+ }
245
+ export function loadRequesterRecord(input, env = process.env) {
246
+ return readRecord(recordPath(pendingDir(env), 'requester', input.serverOrigin, input.commitmentHex), { role: 'requester', serverOrigin: input.serverOrigin, key: input.commitmentHex });
247
+ }
248
+ export function loadApproverRecord(input, env = process.env) {
249
+ return readRecord(recordPath(pendingDir(env), 'approver', input.serverOrigin, input.requestId), {
250
+ role: 'approver',
251
+ serverOrigin: input.serverOrigin,
252
+ key: input.requestId,
253
+ });
254
+ }
255
+ /**
256
+ * The requester's unfinished ceremonies against one server, oldest first.
257
+ *
258
+ * Resuming matters for more than convenience. §4.1 charges an attempt when the
259
+ * commitment is posted, so starting over instead of finishing an existing
260
+ * request spends a second attempt for one enrollment — five crashes would
261
+ * exhaust the day's budget without a single completed comparison.
262
+ */
263
+ export function listResumableRequesterRecords(serverOrigin, env = process.env) {
264
+ const dir = pendingDir(env);
265
+ const prefix = `requester-${originTag(serverOrigin)}-`;
266
+ let names;
267
+ try {
268
+ names = readdirSync(dir);
269
+ }
270
+ catch {
271
+ return [];
272
+ }
273
+ const out = [];
274
+ for (const name of names) {
275
+ if (!name.startsWith(prefix) || !name.endsWith('.json'))
276
+ continue;
277
+ const key = name.slice(prefix.length, -'.json'.length);
278
+ const record = readRecord(join(dir, name), {
279
+ role: 'requester',
280
+ serverOrigin,
281
+ key,
282
+ });
283
+ // Records WITHOUT an id are returned too. One of them may still have landed
284
+ // on the server — the response can be lost after the row exists — and
285
+ // dropping it here left the caller starting over while the ledger's open
286
+ // attempt refused every retry. Deciding which case it is needs the server,
287
+ // so that decision belongs to the caller.
288
+ if (record)
289
+ out.push(record);
290
+ }
291
+ return out.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
292
+ }
293
+ // Called once a ceremony reaches a terminal state. Not called on a mere failure
294
+ // to reach the server: a record whose commitment may have landed must survive,
295
+ // or the next run would mint a replacement for a commitment the server holds.
296
+ export function clearRecord(input, env = process.env) {
297
+ rmSync(recordPath(pendingDir(env), input.role, input.serverOrigin, input.key), { force: true });
298
+ }
299
+ /**
300
+ * Drop every pending record for one server, and say how many.
301
+ *
302
+ * For sign-out: a ceremony half-started against a service this machine is
303
+ * leaving has nothing left to complete, and its nonce is the opening of a
304
+ * commitment nobody will ever ask about again.
305
+ *
306
+ * Selected by the origin TAG that is already part of every filename, so this
307
+ * cannot drift from the naming above the way a second list of origins would.
308
+ * Records for other servers are untouched: signing out of one deployment is
309
+ * not signing out of the others.
310
+ */
311
+ export function clearRecordsForOrigin(serverOrigin, env = process.env) {
312
+ const dir = pendingDir(env);
313
+ if (!existsSync(dir))
314
+ return 0;
315
+ const tag = originTag(serverOrigin);
316
+ let removed = 0;
317
+ for (const name of readdirSync(dir)) {
318
+ if (!name.endsWith('.json'))
319
+ continue;
320
+ // `<role>-<tag>-<key>.json`. Matching the tag as its own dash-delimited
321
+ // field, not as a substring: a key that happened to contain the tag would
322
+ // otherwise take another server's record with it.
323
+ const parts = name.slice(0, -'.json'.length).split('-');
324
+ if (parts[1] !== tag)
325
+ continue;
326
+ rmSync(join(dir, name), { force: true });
327
+ removed += 1;
328
+ }
329
+ return removed;
330
+ }