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.
- package/README.md +39 -2
- package/dist/src/api.js +517 -0
- package/dist/src/authenticated-digest.js +234 -0
- package/dist/src/browser.js +241 -0
- package/dist/src/ceremony.js +181 -0
- package/dist/src/commands/approve.js +392 -0
- package/dist/src/commands/connect.js +273 -0
- package/dist/src/commands/fetch.js +249 -0
- package/dist/src/commands/login.js +334 -0
- package/dist/src/commands/logout.js +74 -0
- package/dist/src/commands/pull.js +80 -0
- package/dist/src/commands/request.js +371 -0
- package/dist/src/commands/sync.js +138 -0
- package/dist/src/commands/vault.js +478 -0
- package/dist/src/commands/watch.js +47 -0
- package/dist/src/config.js +34 -0
- package/dist/src/credentials.js +374 -0
- package/dist/src/device-slot.js +148 -0
- package/dist/src/filelock.js +167 -0
- package/dist/src/index.js +242 -0
- package/dist/src/ledger.js +296 -0
- package/dist/src/machine-name.js +90 -0
- package/dist/src/oss-env.js +49 -0
- package/dist/src/oss.js +289 -0
- package/dist/src/paths.js +8 -0
- package/dist/src/pending.js +330 -0
- package/dist/src/pick-request.js +56 -0
- package/dist/src/preflight.js +257 -0
- package/dist/src/recovery-key.js +386 -0
- package/dist/src/sas.js +18 -0
- package/dist/src/secure-store.js +176 -0
- package/dist/src/shell-arg.js +18 -0
- package/dist/src/slot-advice.js +74 -0
- package/dist/src/vault-container.js +115 -0
- package/dist/src/vault-crypto.js +190 -0
- package/dist/src/vault-protocol.js +358 -0
- package/dist/src/version.js +57 -0
- package/node_modules/@agmsg-cloud/sas-core/dist/src/bech32.d.ts +17 -0
- package/node_modules/@agmsg-cloud/sas-core/dist/src/bech32.js +103 -0
- package/node_modules/@agmsg-cloud/sas-core/dist/src/index.d.ts +17 -0
- package/node_modules/@agmsg-cloud/sas-core/dist/src/index.js +147 -0
- package/node_modules/@agmsg-cloud/sas-core/package.json +30 -0
- package/package.json +50 -7
- 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
|
+
}
|