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,273 @@
1
+ import { spawnOssInherit } from '../oss-env.js';
2
+ import { existsSync, mkdirSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { CourierClient, CourierError } from '../api.js';
5
+ import { originOf, readCredential } from '../credentials.js';
6
+ import { generateDeviceIdentity, publicKeyOf, remoteTeamId } from '../oss.js';
7
+ import { deviceIdentityPath } from '../paths.js';
8
+ import { NEEDS, ensurePreflight, formatPreflight, preflight } from '../preflight.js';
9
+ import { shellArg } from '../shell-arg.js';
10
+ function runInherit(command, args) {
11
+ return new Promise((resolve, reject) => {
12
+ // stdio inherited: `remote.sh connect` prints its own progress, and
13
+ // swallowing it would leave the operator watching nothing during the
14
+ // slowest step. It may also prompt.
15
+ const child = spawnOssInherit(command, args);
16
+ child.on('error', reject);
17
+ child.on('close', (code) => resolve(code ?? 1));
18
+ });
19
+ }
20
+ export async function cmdConnect(config, opts) {
21
+ // Checked BEFORE anything is sent. A missing prerequisite discovered midway
22
+ // costs the operator an approval they cannot get back.
23
+ ensurePreflight(preflight(config.scriptsDir, NEEDS.connect));
24
+ // The capability URL, not the control-plane endpoint. They are different
25
+ // hosts in production — the control plane mints the URL, the sync gateway
26
+ // answers it — and sending a team to the wrong one is not a redirect, it is
27
+ // a request the gateway never sees.
28
+ const credential = readCredential(originOf(config.baseUrl));
29
+ if (!credential) {
30
+ throw new Error(`no stored credential for ${originOf(config.baseUrl)} — run \`agmsg-cloud login\` on this machine first`);
31
+ }
32
+ process.stdout.write(`\nConnecting "${opts.team}" as machine "${credential.machineName}".\n`);
33
+ const run = opts.runner ?? runInherit;
34
+ const code = await run('bash', [
35
+ join(config.scriptsDir, 'remote.sh'),
36
+ 'connect',
37
+ '--endpoint',
38
+ credential.capabilityUrl,
39
+ '--e2ee',
40
+ opts.team,
41
+ ]);
42
+ if (code !== 0) {
43
+ // A non-zero exit used to end the command here, and that is the whole of
44
+ // issue #146: the OSS step registers the team upstream and THEN something
45
+ // fails — a 502 from the gateway is enough — so this returns before the
46
+ // machine is registered. Running connect again cannot repair it, because
47
+ // the OSS step now refuses with a uniqueness conflict ("a team_id registers
48
+ // once ... not a transient error to retry") and exits non-zero again. The
49
+ // one place that can register this machine sits behind a gate that a retry
50
+ // can never pass, and the team becomes one nobody else can ever join.
51
+ //
52
+ // Nothing needs to be rolled back and no delete endpoint is needed. The
53
+ // server holds no half-finished state: the team's claim row is written by
54
+ // the edge guard when the OSS step syncs, which is exactly the state it
55
+ // should be in. What is missing is the device registration, and everything
56
+ // it needs — the team's remote id, this machine's key, its name — is a fact
57
+ // this machine already has or can recompute.
58
+ //
59
+ // So a failed OSS step is not a reason to skip the registration. It is a
60
+ // reason to ask whether the registration is still wanted, and the OSS
61
+ // side's own status answers that: an active binding means the team IS
62
+ // registered upstream, whatever this exit code says.
63
+ let bound = false;
64
+ try {
65
+ await (opts.teamIdOf ?? remoteTeamId)(config.scriptsDir, opts.team);
66
+ bound = true;
67
+ }
68
+ catch {
69
+ // Not bound: the OSS step failed before it registered anything, so there
70
+ // is nothing for this machine to be the first device OF.
71
+ }
72
+ if (!bound) {
73
+ // The OSS script has already said what went wrong on this terminal;
74
+ // repeating a guess on top of it would compete with the real message.
75
+ throw new Error(`connect failed (remote.sh exited ${code})`);
76
+ }
77
+ // States only what has happened. The registration below re-reads the
78
+ // team's status, generates or reads a device key, and calls the server —
79
+ // any of which can fail, and a control plane that is down fails all three.
80
+ // An earlier version of this line said the run "finishes registering this
81
+ // machine" and that "there is nothing else to do", printed here, before any
82
+ // of it had been attempted: on a failure the terminal would have claimed a
83
+ // completed act while has_device stayed false — the exact state this whole
84
+ // change is about. Success is claimed once, after the call returns.
85
+ process.stdout.write(`\nThe step above failed (remote.sh exited ${code}), but "${opts.team}" is registered\n` +
86
+ 'on the remote, so this run now tries to register this machine.\n');
87
+ }
88
+ // Register this machine as the team's first device.
89
+ //
90
+ // Approving anyone requires the approver to BE a registered device, and the
91
+ // only caller the server accepts for the bootstrap is the capability that
92
+ // claimed the team. This is the one place where that is guaranteed to be
93
+ // true, which is why it lives here rather than in a command of its own: a
94
+ // separate command could be run later, from a machine whose capability never
95
+ // claimed anything, and there would be no way to tell.
96
+ //
97
+ // Without this the first machine can never approve a second one, so a team
98
+ // that connects successfully is still a team nobody else can ever join.
99
+ await registerThisMachine(config, opts, credential.machineName);
100
+ // Reached on both paths, and it is the same sentence on purpose: what it
101
+ // claims — this machine can approve others — is true either way, and it is
102
+ // the claim the failure above used to make false.
103
+ //
104
+ // AFTER the call, and only after it. This is the one place either path says
105
+ // the registration happened.
106
+ process.stdout.write(`"${opts.team}" is on the hosted service, and this machine can approve others.\n`);
107
+ // The routes the OSS scripts stopped naming once this CLI took the job
108
+ // (AGMSG_OPERATOR_GUIDANCE=caller). Taking it on is an obligation: those
109
+ // scripts no longer say what to do next, so if this said nothing, nobody
110
+ // would.
111
+ //
112
+ // It says what to DO and why, and asserts nothing about what is already
113
+ // true. The first version of this said "this machine now holds the only
114
+ // copy", which is false on a re-connect: `bootstrapDevice` is idempotent, so
115
+ // a team that already has a recovery vault and a second machine reaches this
116
+ // line too (raised in review).
117
+ //
118
+ // And it cannot be fixed by looking: the vault is per ACCOUNT, so its
119
+ // existence does not say whether THIS team is in it, and finding out means
120
+ // opening it — which needs the recovery key nobody has typed here. A
121
+ // sentence about where the only copy is would be a guess whichever way it
122
+ // went. The condition is named instead, and it is true in every case.
123
+ process.stdout.write(`\nBack these keys up: \`agmsg-cloud recovery setup ${shellArg(opts.team)}\`.\n` +
124
+ 'Until a team is in your recovery vault, the only copies of its keys are on\n' +
125
+ 'the machines that hold them; once it is there, the recovery key opens it again.\n' +
126
+ `\nTo add a second machine, run \`agmsg-cloud sync ${shellArg(opts.team)}\` there and answer here.\n` +
127
+ // The subject is the operator, and it has to be. Something IS carried
128
+ // between the machines after the yes — the sealed bundle, which is what
129
+ // this feature is for. What nobody carries is a value, by hand, from one
130
+ // screen to the other, and that is the property the ceremony buys
131
+ // (raised in review).
132
+ 'You do not carry any value between the machines yourself: both screens show\n' +
133
+ 'eight digits and you check they are the same.\n');
134
+ if (code !== 0) {
135
+ // Now that it is true, the operator can be told the failure needs nothing
136
+ // further — but only about the case where that is so. The error itself is
137
+ // still theirs to read; this does not summarise it.
138
+ process.stdout.write('Read the error above: if it was a uniqueness conflict, the team was already connected\n' +
139
+ 'and there is nothing else to do.\n');
140
+ }
141
+ }
142
+ async function registerThisMachine(config, opts, machineName) {
143
+ // The team's server-side id comes from the OSS side's own status output —
144
+ // the local name and the remote id are unrelated namespaces, and `connect`
145
+ // is the only thing that ever links them.
146
+ const teamId = await (opts.teamIdOf ?? remoteTeamId)(config.scriptsDir, opts.team);
147
+ // The device key lives under AGMSG_CLOUD_HOME, like every other per-machine
148
+ // secret, so a second machine driven from the same host with its own HOME
149
+ // gets its own identity rather than sharing this one.
150
+ const idPath = deviceIdentityPath();
151
+ mkdirSync(dirname(idPath), { recursive: true, mode: 0o700 });
152
+ const pubkey = existsSync(idPath) ? await publicKeyOf(idPath) : await generateDeviceIdentity(idPath);
153
+ const client = new CourierClient(config);
154
+ try {
155
+ await client.bootstrapDevice({
156
+ team_id: teamId,
157
+ device_pubkey: pubkey,
158
+ label: machineName,
159
+ });
160
+ }
161
+ catch (err) {
162
+ // The server distinguishes these because a client cannot: the device list
163
+ // is the org's recipient set and carries no capability, so seeing this key
164
+ // in it proves nothing about who registered it.
165
+ if (err instanceof CourierError && err.code === 'device_key_mismatch') {
166
+ throw new Error(`this machine is already registered under a different device key than the one in ${deviceIdentityPath()}.\n\n` +
167
+ 'That happens when the key file is replaced or restored from elsewhere. Messages sent to this\n' +
168
+ 'machine are addressed to the registered key, so it cannot read them with the key it has.\n' +
169
+ 'Ask an approver to remove this machine, then run connect again to register the current key.');
170
+ }
171
+ if (err instanceof CourierError && err.code === 'device_key_in_use') {
172
+ throw new Error(`the device key in ${deviceIdentityPath()} is already registered by another machine.\n\n` +
173
+ 'Each machine needs its own key; this one looks copied from somewhere else. Move that file\n' +
174
+ 'aside and run connect again to generate a fresh identity for this machine.');
175
+ }
176
+ // Written out because this change routes more traffic here: a connect whose
177
+ // OSS step failed now reaches the registration instead of stopping before
178
+ // it, so a bare `courier request failed: 403 forbidden` would become the
179
+ // thing a person sees while recovering.
180
+ //
181
+ // What a 403 here means, measured rather than guessed. The route's
182
+ // `requireCapability` already refuses a capability that is revoked or not
183
+ // yet activated with a 401, so by the time the handler runs the caller IS
184
+ // live and the only refusal left is `not_claimant`. (`capability_not_live`
185
+ // survives inside the transaction for the race where it dies mid-command —
186
+ // and that lands in the same place, because a replacement credential is a
187
+ // new capability and therefore not the claimant either.)
188
+ //
189
+ // RE-RUNNING CONNECT IS NOT AN EXIT, and an earlier version of this message
190
+ // offered it. `claimOrVerifyTeam` records the claiming capability with
191
+ // ON CONFLICT DO NOTHING and nothing anywhere reassigns it, so a capability
192
+ // that is not the claimant never becomes one. Connect from here returns to
193
+ // this same 403 forever.
194
+ if (err instanceof CourierError && err.status === 403) {
195
+ throw new Error(await forbiddenAdvice(client, opts.team, pubkey));
196
+ }
197
+ throw err;
198
+ }
199
+ }
200
+ /**
201
+ * What to tell someone the server refused to register.
202
+ *
203
+ * The exit is the approved enrollment path — `sync` — and whether that path is
204
+ * open depends on a fact this command can look up rather than assume: does the
205
+ * account have a machine that could approve? Enrollment needs an approver, and
206
+ * an approver is a registered device.
207
+ *
208
+ * The case with no approver is a real one and it has no way out today. That is
209
+ * written plainly instead of offering a command that will wait forever, because
210
+ * a person who is told there is no recovery stops spending time looking for one.
211
+ *
212
+ * WHY NOT BUILD A WAY BACK. Reassigning a team's claim to a live capability
213
+ * would fix it, and it is a change nobody should make inside a bug fix: it
214
+ * decides who may take over a team whose first machine is gone, and the obvious
215
+ * answer — any live capability in the org — turns a revoked machine into "any
216
+ * machine may become device-0 for that team", which is part of what revoking
217
+ * was for. Named here so the next person who finds this message unhelpful
218
+ * reaches the same question rather than the same guess.
219
+ */
220
+ async function forbiddenAdvice(client, team, ownPubkey) {
221
+ const head = `the server would not register this machine for '${team}'.\n\n` +
222
+ 'It is not the machine that first connected this team, and only that one can register\n' +
223
+ 'without an approver. That is recorded once and never moves, so running connect again\n' +
224
+ 'from here will always end exactly here.\n\n';
225
+ // OTHER machines, not machines. The list is the org's recipient set and
226
+ // includes this capability's own device — and approveEnrollment refuses an
227
+ // approver whose capability is the requester's, so a machine cannot approve
228
+ // its own request. Counting the list would offer `sync` to an account whose
229
+ // only device is this one, which is the same dead end as the empty case
230
+ // wearing a different number (raised in review).
231
+ //
232
+ // Matched on the device key rather than a capability id, because the list
233
+ // deliberately carries no capability: it is the recipient set, and the server
234
+ // keeps it that way so a key appearing in it proves nothing about who
235
+ // registered it.
236
+ let approvers = null;
237
+ try {
238
+ const devices = await client.listDevices();
239
+ approvers = devices.filter((d) => d.devicePubkey !== ownPubkey).length;
240
+ }
241
+ catch {
242
+ // Could not ask — most likely this machine's credential just stopped being
243
+ // live. Said as the uncertainty it is.
244
+ approvers = null;
245
+ }
246
+ if (approvers === null) {
247
+ return (head +
248
+ 'This machine could not read the account\'s machine list, so its credential may have\n' +
249
+ 'expired mid-command. Sign in again, then join the team from here:\n' +
250
+ ' agmsg-cloud login\n' +
251
+ ` agmsg-cloud sync ${shellArg(team)}`);
252
+ }
253
+ if (approvers === 0) {
254
+ return (head +
255
+ 'There is also no OTHER machine on this account that could approve a request — and a\n' +
256
+ 'machine cannot approve its own — so joining is not open either. THERE IS NO WAY TO\n' +
257
+ 'RECOVER THIS TEAM FROM THIS ACCOUNT TODAY: the machine that claimed it can no longer\n' +
258
+ 'register, and enrollment needs an approver that does not exist. Reported rather than\n' +
259
+ 'dressed up as a command, so you do not spend the evening on one that cannot work.');
260
+ }
261
+ return (head +
262
+ 'Join it from here instead — this account has another machine that can approve the\n' +
263
+ 'request (a machine cannot approve its own):\n' +
264
+ ` agmsg-cloud sync ${shellArg(team)}`);
265
+ }
266
+ // Takes a scripts directory, not a CliConfig: this runs before a credential
267
+ // exists, which is the point of a dry run.
268
+ export function cmdConnectPreflight(scriptsDir) {
269
+ const checks = preflight(scriptsDir, NEEDS.connect);
270
+ process.stdout.write(`\nChecking what connect needs:\n\n${formatPreflight(checks)}`);
271
+ if (!checks.ok)
272
+ throw new Error('prerequisites are missing');
273
+ }
@@ -0,0 +1,249 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { CourierClient } from '../api.js';
5
+ import { clearAuthenticatedDigest, readAuthenticatedDigests } from '../authenticated-digest.js';
6
+ import { originOf } from '../credentials.js';
7
+ import { decryptWithIdentity, localTeamLookup, publicKeyOf, unlockBundle, verifyHandoffDigest, } from '../oss.js';
8
+ import { deviceIdentityPath } from '../paths.js';
9
+ import { NEEDS, ensurePreflight, preflight } from '../preflight.js';
10
+ // (c, part 2) B fetches the sealed bundles addressed to its device, opens each
11
+ // with its device identity, and feeds the recovered handoff bundle to
12
+ // `remote.sh unlock --bundle` for the given team, then acks it (server deletes).
13
+ export async function cmdFetch(config, args,
14
+ // Injected by the tests, so a run can be pointed at its own cloud home
15
+ // without touching the operator's.
16
+ env = process.env) {
17
+ // This ends in `remote.sh unlock`, so a machine without the scripts fails at
18
+ // the last step — after the bundle has been fetched and decrypted. Checked
19
+ // first, the same way connect checks.
20
+ ensurePreflight(preflight(config.scriptsDir, NEEDS.fetch));
21
+ const idPath = deviceIdentityPath();
22
+ // The device key this machine will decrypt with. Part of the scope an
23
+ // approval was recorded under, so a regenerated identity does not inherit
24
+ // the previous one's ceremonies (raised in review).
25
+ const devicePubkey = await publicKeyOf(idPath);
26
+ // ASKED BEFORE THE QUEUE IS REPORTED ON (#145).
27
+ //
28
+ // `fetchBlobs` is scoped to the DEVICE, not to the team, and the team name is
29
+ // not read until `unlock` at the far end — so an empty queue and a mistyped
30
+ // name produced the same sentence. Measured on one machine with one
31
+ // credential:
32
+ //
33
+ // fetch walkfresh1 -> no pending bundles (real team, empty queue)
34
+ // fetch zzz-no-such-team -> no pending bundles (never existed)
35
+ //
36
+ // "no pending bundles" was true of the second and useless: someone waits for
37
+ // a bundle that will never arrive under that name. It is the same argument
38
+ // the `--confirm-digest` branch below already makes out loud — a thing
39
+ // silently ignored is how someone keeps believing they are the check.
40
+ //
41
+ // The queue is unchanged and so is every gate after it. What changes is that
42
+ // a name this machine does not have is refused BY NAME instead of answered.
43
+ // STOPS AT WHAT WAS OBSERVED, which is that the lookup did not succeed.
44
+ //
45
+ // It said "this machine has no team called X" — an assertion about absence,
46
+ // from a non-zero exit that has more than one cause: an unknown name, a
47
+ // config that could not be read, a store lock, the script failing to start.
48
+ // This repository has already MEASURED that the first two are
49
+ // indistinguishable from here — a corrupt team and an unconnected one give
50
+ // the same exit code and the same sentence (agmsg#650, found while reviewing
51
+ // #192). Naming a cause that was ruled indistinguishable, and then printing
52
+ // the store's own words underneath, does not undo the first line: the first
53
+ // line is what gets believed.
54
+ //
55
+ // So the two readings are given, and the store's words carry whichever it is
56
+ // (raised in review).
57
+ const lookup = await localTeamLookup(config.scriptsDir, args.team);
58
+ if (!lookup.known) {
59
+ throw new Error(`this machine could not confirm a team called ${JSON.stringify(args.team)}, so there is\n` +
60
+ ' nothing here it can unlock.\n' +
61
+ (lookup.said ? `\n The store said: ${lookup.said}\n` : '') +
62
+ '\n That reads two ways and this machine cannot tell them apart: there may be no\n' +
63
+ ' team by that name here, or its state could not be read.\n' +
64
+ '\n If the name is right, `agmsg-cloud pull` is what puts a team on a machine, and\n' +
65
+ ' `agmsg-cloud sync` does that and this in one go.');
66
+ }
67
+ const client = new CourierClient(config);
68
+ const blobs = await client.fetchBlobs();
69
+ if (blobs.length === 0) {
70
+ // Now this says something: the team IS here, and nothing is waiting.
71
+ process.stdout.write('no pending bundles\n');
72
+ return;
73
+ }
74
+ // The authenticated digest IDENTIFIES the bundle, it does not merely check
75
+ // it. An earlier version refused outright whenever more than one was queued,
76
+ // which was a dead end rather than a gate: the same queue returns the same
77
+ // blobs next time, so two honest approvals could wedge a device forever
78
+ // while the error told the operator to do something no command could do.
79
+ //
80
+ // So every queued blob is opened in scratch and asked for its digest, and the
81
+ // one matching what this machine's ceremony authenticated is the one that
82
+ // runs. The rest are left untouched — still queued, still fetchable once
83
+ // their own approval is the recorded one.
84
+ const scratch = mkdtempSync(join(tmpdir(), 'agmsg-cloud-'));
85
+ try {
86
+ // What the ceremony authenticated on this machine — the digest the SAS was
87
+ // derived over, saved by `request` when the codes matched. Nobody reads it
88
+ // aloud; that step existed only because this comparison did not.
89
+ //
90
+ // This is the value the bundle is checked against. It used to be a 64-hex
91
+ // string the operator typed, connected to the ceremony only by someone
92
+ // reading it correctly; binding the digest into the SAS moved that check
93
+ // from the person to the machine, and this is where it lands (spec
94
+ // 3.2.1).
95
+ const authenticated = readAuthenticatedDigests(originOf(config.baseUrl), config.secret, devicePubkey, env);
96
+ if (authenticated.length === 0) {
97
+ // Refused. "No record" is not "the machine checked and agreed", and
98
+ // treating it as such would leave every machine one missing file away
99
+ // from accepting a bundle nothing vouched for.
100
+ throw new Error('this machine has no digest from an approved enrollment.\n\n' +
101
+ 'A bundle is only accepted when it matches the snapshot the eight-digit\n' +
102
+ 'code authenticated, and no such approval is recorded here. Run\n' +
103
+ '`agmsg-cloud request <label>` and have it approved again.');
104
+ }
105
+ if (authenticated.length > 1) {
106
+ // Never a silent choice. Checking against the wrong ceremony's digest is
107
+ // not a weaker check — it is no check, in the shape of one.
108
+ const ids = authenticated.map((a) => ` ${a.requestId} (${a.recordedAt})`).join('\n');
109
+ throw new Error(`${authenticated.length} approved enrollments are recorded for this service:\n\n${ids}\n\n` +
110
+ 'Which one this bundle belongs to cannot be decided from here.');
111
+ }
112
+ const consumed = authenticated[0];
113
+ const expected = consumed.handoffDigest;
114
+ const matches = [];
115
+ // Two different things, and collapsing them is the defect this splits.
116
+ //
117
+ // unreadable the blob itself did not open. That IS a fact about the
118
+ // blob: it is not addressed to this key, or it is damaged.
119
+ // unchecked the comparison never ran. That is a fact about this
120
+ // machine — a shell-out that failed, a registry lock held
121
+ // by another process — and says NOTHING about the bundle.
122
+ //
123
+ // Counted apart because the sentence at the end differs. "Check the digest
124
+ // with the approver" is a claim that a comparison happened and disagreed.
125
+ // Said after a lock timeout, it sends the operator to their approver over
126
+ // an infrastructure failure, wearing the face of the one security check
127
+ // this ceremony exists to perform.
128
+ let unreadable = 0;
129
+ let comparedAndDiffered = 0;
130
+ const unchecked = [];
131
+ for (const [i, blob] of blobs.entries()) {
132
+ // A fixed local name from the loop index, never the server-supplied id: a
133
+ // compromised server could otherwise return '../../x' and steer the
134
+ // decrypted secret bundle outside scratch.
135
+ const bundleFile = join(scratch, `bundle-${i}.bundle`);
136
+ let digest;
137
+ try {
138
+ const bundle = await decryptWithIdentity(idPath, Buffer.from(blob.ciphertext, 'base64'));
139
+ writeFileSync(bundleFile, bundle, { mode: 0o600 });
140
+ try {
141
+ digest = await verifyHandoffDigest(config.scriptsDir, args.team, bundleFile, join(scratch, `verify-${i}`));
142
+ }
143
+ catch (err) {
144
+ // The verifier shells out, so this catches a bundle it refused AND a
145
+ // run that never happened — a lock it could not take, a missing
146
+ // script, a killed process. Those cannot be told apart from here:
147
+ // the exit code is non-zero for both.
148
+ //
149
+ // So it is recorded as NO USABLE RESULT — not as "the run did not
150
+ // happen", which is one of the two and cannot be told from the other.
151
+ // What is known is only that no comparison this machine can stand
152
+ // behind came out of it; whether the bundle matches is undetermined.
153
+ unchecked.push(err instanceof Error ? err.message : String(err));
154
+ continue;
155
+ }
156
+ }
157
+ catch {
158
+ // One unopenable blob must not hide a good one behind it — that would
159
+ // be the same dead end in a different shape. It is counted and
160
+ // reported, never silently dropped.
161
+ unreadable += 1;
162
+ continue;
163
+ }
164
+ // The recorded value is the only one there is now. It came from the
165
+ // transcript the SAS was derived over, so this comparison is the one the
166
+ // eight digits stood for.
167
+ if (digest === expected)
168
+ matches.push({ blob, file: bundleFile });
169
+ else
170
+ comparedAndDiffered += 1;
171
+ }
172
+ if (matches.length === 0) {
173
+ // "None matched" is only a MISMATCH when every bundle was compared.
174
+ //
175
+ // A blob that did not open was never digest-checked, and a verifier that
176
+ // returned nothing usable produced no comparison either. If the bundle
177
+ // the approver sealed is among those, then nothing here disagrees with
178
+ // the eight digits — the evidence is simply missing. Saying "check with
179
+ // the approver" in that state sends someone to re-read a code over a
180
+ // comparison that never reached their bundle (raised in review).
181
+ if (unreadable > 0 || unchecked.length > 0) {
182
+ const parts = [];
183
+ if (comparedAndDiffered > 0) {
184
+ parts.push(` ${comparedAndDiffered} were compared and did not match`);
185
+ }
186
+ if (unreadable > 0) {
187
+ parts.push(` ${unreadable} could not be opened on this machine`);
188
+ }
189
+ if (unchecked.length > 0) {
190
+ parts.push(` ${unchecked.length} produced no usable result from the check\n` +
191
+ ` the first reason was: ${unchecked[0]}`);
192
+ }
193
+ throw new Error(`could not establish whether any of the ${blobs.length} queued bundle(s) is the one ` +
194
+ `you approved.\n\n${parts.join('\n')}\n\n` +
195
+ 'A bundle that was not compared may be the one you approved, so this is NOT\n' +
196
+ 'established as a digest mismatch. Fix the reasons above and run this again\n' +
197
+ 'before asking the approver to re-read the code.');
198
+ }
199
+ throw new Error(`none of the ${blobs.length} queued bundle(s) has the digest you were given. ` +
200
+ `All ${blobs.length} were opened and compared, and none matched. Check the digest\n` +
201
+ 'with the approver — this is the mismatch the confirmation exists to catch.');
202
+ }
203
+ // A match is positive evidence about THAT bundle: its digest equals the one
204
+ // the eight digits stood for. A bundle whose check never ran is an absence
205
+ // of evidence about a different bundle, and absence of evidence about one
206
+ // does not weaken the proof about another — so this proceeds. It says so,
207
+ // because bundles left queued are otherwise a silent oddity.
208
+ if (unchecked.length > 0) {
209
+ process.stdout.write(`note: ${unchecked.length} other queued bundle(s) could not be verified on this machine ` +
210
+ `(${unchecked[0]}). The one taken below WAS checked against your digest, so this is ` +
211
+ 'not a mismatch; the others stay queued.\n');
212
+ }
213
+ // Several matches is NOT a refusal. The digest binds the latest canonical
214
+ // snapshot that `unlock` will adopt — not the courier blob that carried it —
215
+ // and nothing anywhere distinguishes one blob from another: the ceremony
216
+ // authenticates a snapshot, not a delivery. Bundles that completed
217
+ // verification under the same digest are
218
+ // therefore equivalent in exactly what this ceremony confirms, so one is
219
+ // taken and the rest are left queued. Refusing here would have been a
220
+ // permanent stop that no new digest could clear.
221
+ //
222
+ // The choice is made from the VERIFIED matching set, never from the raw
223
+ // queue: picking an unverified blob by position would not be identification.
224
+ const { blob, file } = matches[0];
225
+ await unlockBundle(config.scriptsDir, args.team, file, expected);
226
+ await client.ackBlob(blob.id);
227
+ // Consumed only now, and the order is load-bearing.
228
+ //
229
+ // Clearing before the ack would throw away the expected digest while the
230
+ // blob is still queued, so the retry after a failed ack would arrive with
231
+ // nothing to compare against — and with no record, `fetch` refuses. The
232
+ // bundle is sitting there, correct and approved, and this machine can no
233
+ // longer take it. That is the failure, not a weaker check.
234
+ //
235
+ // Kept until the ceremony is finished on the server, then removed, because
236
+ // a record that outlives its enrollment turns every later fetch into "two
237
+ // approvals are recorded" and refuses just as permanently (raised in review).
238
+ clearAuthenticatedDigest(originOf(config.baseUrl), consumed.requestId, config.secret, devicePubkey, env);
239
+ const others = blobs.length - 1;
240
+ const duplicates = matches.length - 1;
241
+ process.stdout.write(`unlocked and acked the confirmed bundle` +
242
+ (others > 0 ? `; ${others} other bundle(s) left queued` : '') +
243
+ (duplicates > 0 ? ` (${duplicates} of them carry the same digest)` : '') +
244
+ '\n');
245
+ }
246
+ finally {
247
+ rmSync(scratch, { recursive: true, force: true });
248
+ }
249
+ }