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,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
|
+
}
|