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,392 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { createInterface } from 'node:readline/promises';
6
+ import { createApproverCommitment, verifySasCommitment } from '@agmsg-cloud/sas-core';
7
+ import { CourierClient, CourierError } from '../api.js';
8
+ import { pickLiveRequest } from '../pick-request.js';
9
+ import { CeremonyError, renderSasBlock, sasFromOpenedTranscript, waitForStatus } from '../ceremony.js';
10
+ import { exportSnapshotDigest, keyHandoff, sealToRecipient, verifyHandoffDigest } from '../oss.js';
11
+ import { NEEDS, ensurePreflight, preflight } from '../preflight.js';
12
+ import { approverLedgerScope, closeAttempt, consumeAttempt, readBudget, renderBudgetExhausted, renderBudgetWarning, } from '../ledger.js';
13
+ import { approverRecordAgreesWithItself, clearRecord, loadApproverRecord, reserveApproverNonce, } from '../pending.js';
14
+ import { SAS_PINNED } from '../sas.js';
15
+ async function confirm(prompt) {
16
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
17
+ try {
18
+ const answer = (await rl.question(`${prompt} [y/N] `)).trim().toLowerCase();
19
+ return answer === 'y' || answer === 'yes';
20
+ }
21
+ finally {
22
+ rl.close();
23
+ }
24
+ }
25
+ // (b) A approves one enrollment.
26
+ //
27
+ // A commits to its own nonce BEFORE the requester reveals anything, so it cannot
28
+ // pick a value that steers the code once it has seen the requester's key. That
29
+ // ordering is the whole reason the comparison means something, and it is why
30
+ // this command is a sequence of waits rather than a single call.
31
+ //
32
+ /**
33
+ * Find the request this approval is for, when the operator named none.
34
+ *
35
+ * Returns null after explaining itself, rather than throwing: none pending and
36
+ * several pending are both ordinary situations, and an operator who typed a
37
+ * two-word command deserves to be told which one they are in.
38
+ */
39
+ async function resolveRequest(client, writeErr) {
40
+ // The claim IS the decision, and it is the server's: it settles the choice
41
+ // under a lock and records it, so the commitment that follows is bound to the
42
+ // request that was unambiguous when the choice was made. The listing below is
43
+ // only used to EXPLAIN a refusal — it never decides.
44
+ try {
45
+ return (await client.claimSoleLiveRequest()).requestId;
46
+ }
47
+ catch (err) {
48
+ if (!(err instanceof CourierError) || (err.code !== 'ambiguous' && err.code !== 'none')) {
49
+ throw err;
50
+ }
51
+ }
52
+ const picked = pickLiveRequest(await client.listEnrollments());
53
+ if (picked.kind === 'one') {
54
+ // The server refused and the list now shows one. That is not a
55
+ // contradiction to resolve by proceeding: the claim is the decision, and
56
+ // something changed between the two calls. Naming the id is what says
57
+ // which request the operator means.
58
+ writeErr('the request this would answer changed while it was being chosen.\n' +
59
+ 'Re-run naming the id: `agmsg-cloud approve <team> <request-id>`.\n');
60
+ return null;
61
+ }
62
+ if (picked.kind === 'none') {
63
+ writeErr('nothing is waiting for approval on this account.\n\n' +
64
+ 'The joining machine runs `agmsg-cloud sync <team>` first; this command\n' +
65
+ 'answers the request that makes.\n');
66
+ return null;
67
+ }
68
+ // Named, not counted. The operator is the only one who knows which machine
69
+ // they are on the phone with, and they cannot use a number to tell.
70
+ const lines = picked.requests
71
+ .map((r) => ` ${r.id} ${r.label} (started ${r.created_at})`)
72
+ .join('\n');
73
+ writeErr(`${picked.requests.length} machines are waiting for approval:\n\n${lines}\n\n` +
74
+ 'Which one is on the other end of your call cannot be decided from here.\n' +
75
+ 'Re-run with the id: `agmsg-cloud approve <team> <request-id>`.\n');
76
+ return null;
77
+ }
78
+ // Approval IS the bundle upload: sealing needs this machine's key material, so
79
+ // there is no server-side flag anyone could flip instead. The human comparison
80
+ // is the only thing standing between a substituted key and real key history.
81
+ export async function cmdApprove(config,
82
+ // `requestId` is optional: with no id, this finds the one live request and
83
+ // refuses if there is any doubt about which one that is (see resolveRequest).
84
+ args, deps = {}) {
85
+ // The ceremony ends in `key.sh handoff`; a missing install would surface
86
+ // only after both sides have read their codes aloud to each other.
87
+ ensurePreflight(preflight(config.scriptsDir, NEEDS.approve));
88
+ const out = deps.out ?? ((text) => void process.stdout.write(text));
89
+ const writeErr = deps.err ?? ((text) => void process.stderr.write(text));
90
+ const err = deps.err ?? ((text) => void writeErr(text));
91
+ const env = deps.env ?? process.env;
92
+ const ask = deps.ask ?? confirm;
93
+ const client = new CourierClient(deps.fetchImpl ? { ...config, fetchImpl: deps.fetchImpl } : config);
94
+ // Fail closed until the arc that pins this protocol is complete. Approval
95
+ // seals real key history to the requester's key on the strength of the code
96
+ // comparison, so it must not run while any part of that comparison is
97
+ // provisional.
98
+ if (!SAS_PINNED) {
99
+ writeErr('approval is disabled: the SAS ceremony is not fully pinned yet, so sealing ' +
100
+ 'key history now would rest on a comparison that is not finished.\n');
101
+ process.exitCode = 1;
102
+ return;
103
+ }
104
+ const scope = approverLedgerScope(config.baseUrl, config.secret);
105
+ // Checked before anything is read from the network. The budget is a local
106
+ // bound, and asking the server about a request this machine is not allowed to
107
+ // attempt both leaks the intent and invites a "just once more" retry loop
108
+ // against a server that is happy to allow it.
109
+ // Deciding which request this is costs nothing and can fail for reasons that
110
+ // are not the operator's — none pending, or two — and spending one of a small
111
+ // budget to discover that would punish someone for asking a question.
112
+ //
113
+ // The decision is the SERVER's, and it is taken here rather than at commitment
114
+ // time. §4.1 charges the send, not the reply: once the commitment bytes leave
115
+ // this machine the attempt is spent whatever comes back, so an ambiguity
116
+ // discovered by the commitment call could only be undone by this ledger
117
+ // trusting the server's account of its own behaviour — and the server is
118
+ // inside the threat model. A malicious one would answer every commitment with
119
+ // "ambiguous" and get unlimited attempts for free. So the claim happens
120
+ // BEFORE the charge, carries no commitment, and the commitment later only
121
+ // asks whether that claim still holds (raised in review).
122
+ const requestId = args.requestId ?? (await resolveRequest(client, writeErr));
123
+ if (requestId === null) {
124
+ process.exitCode = 1;
125
+ return;
126
+ }
127
+ // Keyed on the request, so retrying the SAME ceremony after a timeout resumes
128
+ // the attempt already paid for instead of buying a second one. The commitment
129
+ // that goes back out is the same commitment; that is one guess, not two.
130
+ // Established BEFORE any request goes out, so the budget can still refuse
131
+ // without contacting anyone. A reservation for this request means the
132
+ // commitment this run would send is the one already reserved — reused whole,
133
+ // byte for byte — so it is the guess the ledger already holds rather than a
134
+ // new one. A record that does not reproduce its own commitment is not
135
+ // evidence of anything and is treated as absent (raised in review).
136
+ const reserved = loadApproverRecord({ serverOrigin: config.baseUrl, requestId }, env);
137
+ const sameCommitment = reserved !== null && approverRecordAgreesWithItself(reserved);
138
+ const charged = consumeAttempt(scope, requestId, env, Date.now(), sameCommitment);
139
+ if (!charged.ok) {
140
+ if (charged.reason === 'budget_exhausted') {
141
+ writeErr(renderBudgetExhausted(charged.budget));
142
+ }
143
+ else {
144
+ writeErr('\n Another enrollment attempt is still open on this machine. Finish or\n' +
145
+ ' cancel it before starting a second one — one comparison at a time is\n' +
146
+ ' what keeps a mismatch attributable to a single ceremony.\n\n');
147
+ }
148
+ process.exitCode = 1;
149
+ return;
150
+ }
151
+ out(renderBudgetWarning(charged.budget));
152
+ const fail = (note) => {
153
+ closeAttempt(scope, requestId, 'failed', note, env);
154
+ writeErr(renderBudgetWarning(readBudget(scope, env)));
155
+ };
156
+ const start = await client.getEnrollment(requestId);
157
+ // `opened` is here because an approver who has SEEN the digits and not yet
158
+ // answered is the most ordinary way to arrive at this command a second time:
159
+ // the codes are on screen, the terminal dies, and the ceremony is unfinished.
160
+ //
161
+ // §4.1 already charges that run an attempt ("cancellation after A commits
162
+ // ... consumes an attempt exactly like a completed comparison"), so refusing
163
+ // to resume does not save anything — it forces a NEW enrollment, which
164
+ // consumes a second. Five interruptions blocked enrollment for 24 hours. The
165
+ // ledger was always built for this (consumeAttempt is keyed on the request
166
+ // id); only the selection was not.
167
+ const resuming = start.status === 'opened';
168
+ if (!resuming && start.status !== 'requester_committed' && start.status !== 'both_committed') {
169
+ fail(`request was ${start.status}`);
170
+ writeErr(`request ${requestId} is ${start.status}; nothing to approve\n`);
171
+ process.exitCode = 1;
172
+ return;
173
+ }
174
+ // The snapshot A will hand over, fixed BEFORE it commits (§3.2). It is the
175
+ // public key-state description, not the bundle: nothing carrying identity
176
+ // material is written until the human answers.
177
+ //
178
+ // Exported outside the reservation lock, because the lock is synchronous and
179
+ // this spawns a child. That is safe because of what happens below on a
180
+ // retry: an existing reservation is returned whole and THIS digest is
181
+ // discarded, so a second concurrent approve cannot swap in a snapshot of its
182
+ // own (raised in review).
183
+ const snapshotDir = mkdtempSync(join(tmpdir(), 'agmsg-approve-'));
184
+ let handoffDigestHex;
185
+ try {
186
+ handoffDigestHex = await exportSnapshotDigest(config.scriptsDir, args.team, join(snapshotDir, 'snapshot.json'));
187
+ }
188
+ finally {
189
+ // Removed on every path this process controls. A SIGKILL leaves a public
190
+ // snapshot in a directory only this user can enter, which the spec states
191
+ // rather than promises away.
192
+ rmSync(snapshotDir, { recursive: true, force: true });
193
+ }
194
+ // Reserved before the digest is posted, and REUSED on a retry — the nonce
195
+ // AND the digest together, since the commitment is over both. Minting a
196
+ // second nonce for a commitment the server already holds is
197
+ // indistinguishable, from the requester's side, from the substitution this
198
+ // ceremony detects; re-exporting a snapshot would do the same to the half
199
+ // the digest covers.
200
+ const record = reserveApproverNonce({ serverOrigin: config.baseUrl, requestId: requestId, handoffDigestHex }, (digestHex) => {
201
+ const made = createApproverCommitment(digestHex);
202
+ return {
203
+ commitmentHex: made.commitmentHex,
204
+ nonceHex: Buffer.from(made.approverNonce).toString('hex'),
205
+ };
206
+ }, env);
207
+ const done = () => clearRecord({ role: 'approver', serverOrigin: config.baseUrl, key: requestId }, env);
208
+ try {
209
+ // Same bytes on a retry, so this is idempotent server-side.
210
+ // The last argument says how this request was chosen. When the operator
211
+ // named no id, the server already settled which request this is — before
212
+ // the attempt was charged and before these bytes existed — so this asks it
213
+ // to check that claim still holds, not to decide again (raised in review).
214
+ // Whether the requester's opening matches the commitment it made. Shared so
215
+ // the two paths cannot drift, but CALLED at a different moment in each, and
216
+ // that difference is the security property: on the live path it must run
217
+ // BEFORE this machine reveals its own nonce. Folding both calls into one
218
+ // site after the reveal was a real regression — DRY moved a check across a
219
+ // boundary it exists to sit on.
220
+ const requesterOpeningVerifies = (t) => t.device_pubkey !== null &&
221
+ t.opening_nonce !== null &&
222
+ verifySasCommitment(t.commitment, t.device_pubkey, new Uint8Array(Buffer.from(t.opening_nonce, 'hex')));
223
+ const refuseOpening = async () => {
224
+ await client.abortEnrollment(requestId);
225
+ done();
226
+ fail('requester opening did not verify');
227
+ writeErr('the requester opening does not match its commitment; nothing was revealed ' +
228
+ 'and the request has been ended\n');
229
+ process.exitCode = 1;
230
+ };
231
+ let full;
232
+ if (resuming) {
233
+ // Nothing is sent. The commitment and the opening are already on the
234
+ // server — this run re-reads what is there and re-derives the same SAS
235
+ // from it, which is what §4(2) describes A doing in the first place. No
236
+ // state transition happens here; the only call that moves the request is
237
+ // the one after the human answers.
238
+ full = await client.getEnrollment(requestId);
239
+ // Resuming means doing the display again, NOT skipping the checks.
240
+ //
241
+ // This one has no counterpart above because the live path had just sent
242
+ // the commitment and knew it. Here the transcript arrives from a server
243
+ // that is inside the threat model, so what it calls "the approver's
244
+ // commitment" is checked against what this machine actually committed —
245
+ // the durable reservation. A relay that swapped it would otherwise get
246
+ // this machine to display a SAS derived from someone else's contribution.
247
+ if (full.approver_commitment !== record.commitmentHex) {
248
+ await client.abortEnrollment(requestId);
249
+ done();
250
+ fail('stored approver commitment is not this machine\'s');
251
+ writeErr('the request no longer carries the commitment this machine made; nothing was ' +
252
+ 'revealed and the request has been ended\n');
253
+ process.exitCode = 1;
254
+ return;
255
+ }
256
+ }
257
+ else {
258
+ await client.submitApproverCommitment(requestId, record.commitmentHex, args.requestId === undefined);
259
+ out(`request "${start.label}" — waiting for it to open...\n`);
260
+ const opened = await waitForStatus(client, requestId, 'requester_opened', deps.waitOptions);
261
+ // BEFORE revealing anything. The server relayed these values; checking
262
+ // them against the commitment fixed earlier is what makes the relay
263
+ // untrusted rather than trusted — and it is worth nothing if this
264
+ // machine's nonce is already out.
265
+ if (!requesterOpeningVerifies(opened)) {
266
+ await refuseOpening();
267
+ return;
268
+ }
269
+ full = await client.submitApproverOpening(requestId, record.nonceHex, record.handoffDigestHex);
270
+ }
271
+ // The resume path checks here instead, because by then both openings are
272
+ // already on the server and there is nothing left to withhold. What it can
273
+ // still refuse is to DISPLAY a SAS derived from an opening that does not
274
+ // verify, which is what this stops.
275
+ if (resuming && !requesterOpeningVerifies(full)) {
276
+ await refuseOpening();
277
+ return;
278
+ }
279
+ // The key the bundle is sealed to, taken from the transcript that was just
280
+ // verified. Non-null by that verification; narrowed here so the sealing
281
+ // below reads it from the checked value rather than from a variable that
282
+ // happened to survive a branch.
283
+ const devicePubkey = full.device_pubkey;
284
+ if (devicePubkey === null) {
285
+ await refuseOpening();
286
+ return;
287
+ }
288
+ const sas = sasFromOpenedTranscript(full);
289
+ out(renderSasBlock(sas, 'approver'));
290
+ const matched = await ask('Do the codes match exactly?');
291
+ if (!matched) {
292
+ // A mismatch is a result, not an error to swallow: reporting it is what
293
+ // makes the attempt count, and an attacker's best move is a retry that
294
+ // costs the user nothing.
295
+ await client.abortEnrollment(requestId);
296
+ done();
297
+ // A mismatch is the attack showing itself. It costs an attempt precisely
298
+ // so that an attacker cannot ask the user to "just try again" for free.
299
+ fail('codes did not match');
300
+ out('not approved. Nothing was sent, and this request is closed.\n');
301
+ return;
302
+ }
303
+ const scratch = mkdtempSync(join(tmpdir(), 'agmsg-cloud-'));
304
+ try {
305
+ const bundleFile = join(scratch, 'handoff.bundle');
306
+ await keyHandoff(config.scriptsDir, args.team, bundleFile);
307
+ // The bundle's own digest, measured for the comparison below — not for
308
+ // anyone to read. It used to be printed here and confirmed by voice,
309
+ // which is what this branch removed; what it is FOR is checking that the
310
+ // bundle just generated is the snapshot committed to before the digits
311
+ // were compared.
312
+ //
313
+ // The value comes from the same verification the joiner's `unlock`
314
+ // performs, never recomputed here: two implementations of one digest
315
+ // eventually disagree, and the disagreement looks like "we both read the
316
+ // same digits and it still refused".
317
+ const digest = await verifyHandoffDigest(config.scriptsDir, args.team, bundleFile, join(scratch, 'verify'));
318
+ // §4(5): the bundle is checked against what was COMMITTED to, before
319
+ // anything is sealed. If the key state moved between the commitment and
320
+ // this moment, the bundle B would receive is not the one the digits
321
+ // attested. B refuses it on arrival either way — that is what keeps this
322
+ // fail-closed against a dishonest approver — but stopping here is what
323
+ // stops an honest one from uploading a bundle it can already see is
324
+ // wrong and handing B a failure it cannot act on (raised in review).
325
+ if (digest !== record.handoffDigestHex) {
326
+ await client.abortEnrollment(requestId);
327
+ done();
328
+ fail('the key state changed after the commitment');
329
+ writeErr('not approved: this team\'s key state changed between the commitment and now,\n' +
330
+ 'so the bundle no longer matches the code that was compared.\n\n' +
331
+ 'Nothing was sealed or uploaded. This request is closed — re-running\n' +
332
+ '`agmsg-cloud approve` on it will not reopen it. The joining machine has to\n' +
333
+ 'run `agmsg-cloud request <label>` again, and this approval starts fresh\n' +
334
+ 'from a new snapshot.\n');
335
+ return;
336
+ }
337
+ // The 64-hex read-back is gone.
338
+ //
339
+ // It existed because nothing else connected what the two people compared
340
+ // to what the joining machine would accept: the joiner checked the
341
+ // bundle against a string its operator typed, so a person reading
342
+ // correctly WAS the check. That connection is now machine-made — the
343
+ // digest is bound into the eight digits, recorded by `request` from the
344
+ // transcript it derived them over, and `fetch` refuses any bundle that
345
+ // does not match it.
346
+ //
347
+ // So this asked a human to re-verify, by eye, something already verified
348
+ // by comparison of bytes. Two checks are not better than one when the
349
+ // weaker one trains people to skim: sixty-four characters read aloud is
350
+ // exactly the ritual someone starts approving without finishing.
351
+ //
352
+ // What is NOT gone is the check itself. §4(5) still compares the
353
+ // generated bundle's digest against the committed one above, and the
354
+ // joiner still recomputes on receipt and refuses a mismatch.
355
+ const sealed = await sealToRecipient(devicePubkey, readFileSync(bundleFile));
356
+ const { device_id } = await client.uploadBundle(requestId, {
357
+ operation_id: randomUUID(),
358
+ ciphertext: sealed.toString('base64'),
359
+ });
360
+ done();
361
+ // Closed, not refunded: §4.1 keeps a success counted in the window.
362
+ closeAttempt(scope, requestId, 'succeeded', undefined, env);
363
+ out(`approved; registered device ${device_id}\n`);
364
+ }
365
+ finally {
366
+ rmSync(scratch, { recursive: true, force: true });
367
+ }
368
+ }
369
+ catch (err) {
370
+ if (err instanceof CeremonyError) {
371
+ // A timeout keeps BOTH the pending record and the open ledger entry: the
372
+ // next run resends the same commitment, which is the same attempt. Closing
373
+ // it here was the bug — the record said "resume me" while the ledger said
374
+ // "that one is over", so resuming cost a second guess.
375
+ if (err.reason === 'timed_out') {
376
+ writeErr(`approval did not complete: ${err.message}\n`);
377
+ writeErr(' Run the same command again to resume it; this costs no further attempt.\n');
378
+ process.exitCode = 1;
379
+ return;
380
+ }
381
+ done();
382
+ fail(err.reason);
383
+ writeErr(`approval did not complete: ${err.message}\n`);
384
+ process.exitCode = 1;
385
+ return;
386
+ }
387
+ // Even an unexpected error closes the attempt. The commitment is already
388
+ // out; leaving the ledger open would block every later attempt instead.
389
+ fail('unexpected error');
390
+ throw err;
391
+ }
392
+ }