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,371 @@
|
|
|
1
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { createSasCommitment } from '@agmsg-cloud/sas-core';
|
|
4
|
+
import { CourierClient, CourierError } from '../api.js';
|
|
5
|
+
import { recordAuthenticatedDigest } from '../authenticated-digest.js';
|
|
6
|
+
import { CeremonyError, renderSasBlock, sasFromOpenedTranscript, waitForStatus } from '../ceremony.js';
|
|
7
|
+
import { generateDeviceIdentity, publicKeyOf } from '../oss.js';
|
|
8
|
+
import { originOf } from '../credentials.js';
|
|
9
|
+
import { deviceIdentityPath } from '../paths.js';
|
|
10
|
+
import { closeAttempt, consumeAttempt, readBudget, renderBudgetExhausted, renderBudgetWarning, requesterLedgerScope, } from '../ledger.js';
|
|
11
|
+
import { attachRequestId, clearRecord, listResumableRequesterRecords, reserveRequesterNonce, } from '../pending.js';
|
|
12
|
+
// (c, part 1) B asks to be added.
|
|
13
|
+
//
|
|
14
|
+
// The order below is the protocol, not a style choice:
|
|
15
|
+
//
|
|
16
|
+
// 1. commit to (device key, nonce) and PERSIST the nonce
|
|
17
|
+
// 2. post only the commitment digest
|
|
18
|
+
// 3. wait for the approver's commitment to be fixed
|
|
19
|
+
// 4. only then reveal the key and the nonce
|
|
20
|
+
// 5. wait for the approver to open, and derive the code locally
|
|
21
|
+
//
|
|
22
|
+
// Steps 3 and 4 are what stop the other side choosing its contribution after
|
|
23
|
+
// learning ours. Doing 4 before 3 would leave a request whose code the server
|
|
24
|
+
// could steer, and the human comparison would confirm nothing.
|
|
25
|
+
//
|
|
26
|
+
// The private key never leaves this machine; only the public recipient is sent,
|
|
27
|
+
// and not until step 4.
|
|
28
|
+
export async function cmdRequest(config, args, deps = {}) {
|
|
29
|
+
const out = deps.out ?? ((text) => void process.stdout.write(text));
|
|
30
|
+
const writeErr = deps.err ?? ((text) => void process.stderr.write(text));
|
|
31
|
+
const err = deps.err ?? ((text) => void writeErr(text));
|
|
32
|
+
const env = deps.env ?? process.env;
|
|
33
|
+
const nextStepsAreOurs = deps.nextStepsFromCaller !== true;
|
|
34
|
+
const client = new CourierClient(deps.fetchImpl ? { ...config, fetchImpl: deps.fetchImpl } : config);
|
|
35
|
+
const idPath = deviceIdentityPath(env);
|
|
36
|
+
mkdirSync(dirname(idPath), { recursive: true });
|
|
37
|
+
const pubkey = existsSync(idPath) ? await publicKeyOf(idPath) : await generateDeviceIdentity(idPath);
|
|
38
|
+
// Finish an interrupted ceremony rather than starting a second one. §4.1
|
|
39
|
+
// charges an attempt when the commitment is posted, so a crash loop that
|
|
40
|
+
// started over each time would spend the day's budget without ever completing
|
|
41
|
+
// a single comparison.
|
|
42
|
+
const resumable = listResumableRequesterRecords(config.baseUrl, env);
|
|
43
|
+
let record = resumable.find((r) => r.devicePubkey === pubkey);
|
|
44
|
+
let requestId = record?.requestId;
|
|
45
|
+
// §4.1 requires B to count durably too, scoped to its device identity and the
|
|
46
|
+
// server origin. Only a NEW ceremony costs an attempt: resuming one already
|
|
47
|
+
// paid for would charge the same guess twice and let a crash loop exhaust the
|
|
48
|
+
// budget without a single comparison.
|
|
49
|
+
const scope = requesterLedgerScope(config.baseUrl, pubkey);
|
|
50
|
+
// A record without an id may still have landed: the row can exist on the
|
|
51
|
+
// server while the response was lost. Ask, using the only handle available —
|
|
52
|
+
// the commitment. Dropping these was what left the ledger's open attempt
|
|
53
|
+
// blocking every retry with nothing able to clear it.
|
|
54
|
+
if (record && !requestId) {
|
|
55
|
+
// The list must SUCCEED before its emptiness means anything.
|
|
56
|
+
//
|
|
57
|
+
// Swallowing the error and treating it as "no live requests" turned a
|
|
58
|
+
// dropped connection into a verdict: the attempt was closed as failed and
|
|
59
|
+
// the nonce deleted, while the row on the server stayed live and became
|
|
60
|
+
// unopenable by anyone. The API failing to answer is not evidence that the
|
|
61
|
+
// row is absent.
|
|
62
|
+
let mine;
|
|
63
|
+
try {
|
|
64
|
+
mine = await client.listEnrollments();
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
writeErr('cannot tell whether the earlier request reached the server: ' +
|
|
68
|
+
`${err instanceof Error ? err.message : String(err)}\n` +
|
|
69
|
+
' Nothing has been changed — the attempt and its nonce are kept so a\n' +
|
|
70
|
+
' later run can finish it. Try again when the server is reachable.\n');
|
|
71
|
+
process.exitCode = 1;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const match = mine.filter((e) => e.commitment === record.commitmentHex);
|
|
75
|
+
if (match.length === 1) {
|
|
76
|
+
requestId = match[0].id;
|
|
77
|
+
attachRequestId({ serverOrigin: config.baseUrl, commitmentHex: record.commitmentHex, requestId }, env);
|
|
78
|
+
}
|
|
79
|
+
else if (match.length === 0) {
|
|
80
|
+
// Either it never arrived or it has already gone terminal. Both are over,
|
|
81
|
+
// and the attempt stays spent: this machine cannot tell whether the bytes
|
|
82
|
+
// left, and treating "I did not see a reply" as free is the retry an
|
|
83
|
+
// attacker would ask for.
|
|
84
|
+
closeAttempt(scope, record.commitmentHex, 'failed', 'no live request for this commitment', env);
|
|
85
|
+
clearRecord({ role: 'requester', serverOrigin: config.baseUrl, key: record.commitmentHex }, env);
|
|
86
|
+
record = undefined;
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
// One commitment, two live rows. Nothing here can choose safely.
|
|
90
|
+
writeErr('the server reports more than one live request for this commitment; ' +
|
|
91
|
+
'refusing to guess which one is yours\n');
|
|
92
|
+
process.exitCode = 1;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (!record || !requestId) {
|
|
97
|
+
// ASKED BEFORE THE ATTEMPT IS CHARGED, and that placement is the whole
|
|
98
|
+
// point. A device key that can never become a device on this account makes
|
|
99
|
+
// this ceremony unwinnable, and #194 is about such a path costing one of
|
|
100
|
+
// five. The server refuses it too — that check is the authority and stays
|
|
101
|
+
// — but it can only speak at the opening, by which time §4.1 has already
|
|
102
|
+
// spent the attempt.
|
|
103
|
+
//
|
|
104
|
+
// A REFUSAL BEFORE STARTING, NOT A REFUND AFTER. The difference matters:
|
|
105
|
+
// giving an attempt back because the server said the run did not count
|
|
106
|
+
// would let a hostile server hand out unlimited free ceremonies, which is
|
|
107
|
+
// exactly the budget an attacker wants while searching for a SAS
|
|
108
|
+
// collision. Declining to start costs the attacker nothing they cannot
|
|
109
|
+
// already do by refusing every request.
|
|
110
|
+
// The budget is READ before the lookup below, so a machine with nothing
|
|
111
|
+
// left still reaches the server for nothing. `consumeAttempt` remains the
|
|
112
|
+
// authority and re-checks under the lock — this only decides whether it is
|
|
113
|
+
// worth asking a question, and a sixth attempt must not reach the server
|
|
114
|
+
// at all, not even to read.
|
|
115
|
+
const before = readBudget(scope, env);
|
|
116
|
+
if (before.remaining === 0) {
|
|
117
|
+
writeErr(renderBudgetExhausted(before));
|
|
118
|
+
process.exitCode = 1;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const blocked = await keyAlreadySpokenFor(client, pubkey);
|
|
122
|
+
if (blocked) {
|
|
123
|
+
writeErr(blocked);
|
|
124
|
+
process.exitCode = 1;
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const { commitmentHex: plannedCommitment, openingNonce: plannedNonce } = createSasCommitment(pubkey);
|
|
128
|
+
const charged = consumeAttempt(scope, plannedCommitment, env);
|
|
129
|
+
if (!charged.ok) {
|
|
130
|
+
if (charged.reason === 'budget_exhausted') {
|
|
131
|
+
writeErr(renderBudgetExhausted(charged.budget));
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
writeErr('\n An enrollment attempt from this machine is still open. Run this\n' +
|
|
135
|
+
' command again to resume it rather than starting a second one.\n\n');
|
|
136
|
+
}
|
|
137
|
+
process.exitCode = 1;
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
out(renderBudgetWarning(charged.budget));
|
|
141
|
+
// Persisted BEFORE the digest is posted. A crash after the post but before
|
|
142
|
+
// this write would leave a request on the server that nothing can open.
|
|
143
|
+
record = reserveRequesterNonce({
|
|
144
|
+
serverOrigin: config.baseUrl,
|
|
145
|
+
commitmentHex: plannedCommitment,
|
|
146
|
+
nonceHex: Buffer.from(plannedNonce).toString('hex'),
|
|
147
|
+
devicePubkey: pubkey,
|
|
148
|
+
}, env);
|
|
149
|
+
const created = await client.createEnrollment({
|
|
150
|
+
commitment: record.commitmentHex,
|
|
151
|
+
label: args.label,
|
|
152
|
+
});
|
|
153
|
+
requestId = created.request_id;
|
|
154
|
+
attachRequestId({ serverOrigin: config.baseUrl, commitmentHex: record.commitmentHex, requestId }, env);
|
|
155
|
+
out(`enrollment requested: ${requestId} (expires ${created.expires_at})\n`);
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
out(`resuming enrollment ${requestId}\n`);
|
|
159
|
+
}
|
|
160
|
+
const done = () => clearRecord({ role: 'requester', serverOrigin: config.baseUrl, key: record.commitmentHex }, env);
|
|
161
|
+
try {
|
|
162
|
+
out('waiting for an approver to commit...\n');
|
|
163
|
+
await waitForStatus(client, requestId, 'both_committed', deps.waitOptions);
|
|
164
|
+
// Safe to open now: the approver's contribution is fixed and cannot change.
|
|
165
|
+
await client.submitOpening(requestId, {
|
|
166
|
+
device_pubkey: pubkey,
|
|
167
|
+
opening_nonce: record.nonceHex,
|
|
168
|
+
});
|
|
169
|
+
out('waiting for the approver to open...\n');
|
|
170
|
+
const opened = await waitForStatus(client, requestId, 'opened', deps.waitOptions);
|
|
171
|
+
// Derived here, from the two opened nonces. Nothing displayed below came
|
|
172
|
+
// from the server as a code.
|
|
173
|
+
const sas = sasFromOpenedTranscript(opened);
|
|
174
|
+
// The block says who decides, and says it once (#195). This printed a
|
|
175
|
+
// second copy immediately after it, so the screen read: who decides -> how
|
|
176
|
+
// to compare on a call you trust -> who decides. Repetition in the one
|
|
177
|
+
// screen where a single line has to be read is how the reader learns to
|
|
178
|
+
// skim it.
|
|
179
|
+
//
|
|
180
|
+
// The sentence was not deleted, it MOVED: what this copy carried and the
|
|
181
|
+
// block's did not — what a `no` means — is now the block's wording.
|
|
182
|
+
out(renderSasBlock(sas, 'requester'));
|
|
183
|
+
// The outcome is NOT known yet. Closing as succeeded here — which is what
|
|
184
|
+
// this did — recorded every rejected ceremony as a success, so §4.1's
|
|
185
|
+
// requirement that the requester counts failed and incomplete attempts, and
|
|
186
|
+
// warns from the second failure, did nothing on this side. Wait for the
|
|
187
|
+
// server to say which way it went.
|
|
188
|
+
const settled = await waitForStatus(client, requestId, 'consumed', deps.waitOptions);
|
|
189
|
+
// What the eight digits authenticated, kept for `fetch`.
|
|
190
|
+
//
|
|
191
|
+
// Written BEFORE the pending record is cleared, and the ordering is the
|
|
192
|
+
// point: this is the only moment where the digest and the evidence that a
|
|
193
|
+
// person compared the digits it is bound into are both in hand. Nobody
|
|
194
|
+
// compared this value — they compared eight decimal digits, and the digest
|
|
195
|
+
// is one of the inputs those digits were derived from. A crash between the
|
|
196
|
+
// two leaves a machine that has been through a ceremony and kept nothing
|
|
197
|
+
// from it, and `fetch` then refuses every bundle.
|
|
198
|
+
//
|
|
199
|
+
// The value comes from the transcript the SAS was derived over, not from a
|
|
200
|
+
// second read, so what the digits stood for and what is checked later
|
|
201
|
+
// cannot drift apart (spec 3.2.1).
|
|
202
|
+
const authenticated = opened.handoff_digest;
|
|
203
|
+
if (authenticated === null) {
|
|
204
|
+
throw new CeremonyError('server_transcript_disagrees', 'the opened transcript carries no handoff_digest');
|
|
205
|
+
}
|
|
206
|
+
recordAuthenticatedDigest({
|
|
207
|
+
serverOrigin: originOf(config.baseUrl),
|
|
208
|
+
requestId,
|
|
209
|
+
handoffDigest: authenticated,
|
|
210
|
+
secret: config.secret,
|
|
211
|
+
devicePubkey: pubkey,
|
|
212
|
+
}, env);
|
|
213
|
+
closeAttempt(scope, record.commitmentHex, 'succeeded', undefined, env);
|
|
214
|
+
// What HAPPENED is always said. What to do NEXT is said only by whoever
|
|
215
|
+
// owns the operator's next step — which is this command when someone typed
|
|
216
|
+
// it, and `sync` when `sync` is the one running it. `sync` goes straight on
|
|
217
|
+
// to fetch and pull, so telling the person to run fetch was telling them to
|
|
218
|
+
// do a thing that was already being done, in a sentence carrying a
|
|
219
|
+
// `<team>` placeholder they were expected to fill in themselves (found in
|
|
220
|
+
// the production walkthrough).
|
|
221
|
+
out(` Added.${nextStepsAreOurs ? ' Fetch the bundle with `agmsg-cloud fetch <team>`.' : ''} (${settled.status})\n`);
|
|
222
|
+
done();
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
if (err instanceof CeremonyError) {
|
|
226
|
+
// The record is cleared only when the ceremony is over on the SERVER too.
|
|
227
|
+
// A timeout leaves it, so the next run resumes instead of spending another
|
|
228
|
+
// attempt on a request that is still live.
|
|
229
|
+
if (err.reason === 'timed_out') {
|
|
230
|
+
// The ceremony may still be live; the next run resumes the same one and
|
|
231
|
+
// is not charged again.
|
|
232
|
+
writeErr(`enrollment did not complete: ${err.message}\n`);
|
|
233
|
+
writeErr(' Run the same command again to resume it; this costs no further attempt.\n');
|
|
234
|
+
process.exitCode = 1;
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
done();
|
|
238
|
+
closeAttempt(scope, record.commitmentHex, 'failed', err.reason, env);
|
|
239
|
+
writeErr(renderBudgetWarning(readBudget(scope, env)));
|
|
240
|
+
writeErr(`enrollment did not complete: ${err.message}\n`);
|
|
241
|
+
process.exitCode = 1;
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
// The two ways this machine's key cannot become a device on this account.
|
|
245
|
+
//
|
|
246
|
+
// They arrive at the OPENING — before any comparison — because that is the
|
|
247
|
+
// first moment the server has the key at all. They used to arrive as one
|
|
248
|
+
// `already_registered` at the very end, after an approver had been
|
|
249
|
+
// interrupted and had answered (#194), and it named neither case.
|
|
250
|
+
//
|
|
251
|
+
// A machine already on this account joining a second team is NOT here: that
|
|
252
|
+
// one succeeds now, which is what #194 was about.
|
|
253
|
+
if (err instanceof CourierError &&
|
|
254
|
+
(err.code === 'device_key_in_use' || err.code === 'device_key_changed')) {
|
|
255
|
+
done();
|
|
256
|
+
closeAttempt(scope, record.commitmentHex, 'failed', err.code, env);
|
|
257
|
+
writeErr(renderBudgetWarning(readBudget(scope, env)));
|
|
258
|
+
writeErr(await renderKeyRefusal(err.code, client, pubkey));
|
|
259
|
+
process.exitCode = 1;
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
throw err;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// WHAT IS TRUE AT THIS POINT, and no more.
|
|
266
|
+
//
|
|
267
|
+
// This used to say "no approver was asked", which is false: reaching the
|
|
268
|
+
// opening means the requester waited for `both_committed`, so an approver had
|
|
269
|
+
// already run `approve` and posted a commitment over the bundle digest. They
|
|
270
|
+
// were interrupted — what did NOT happen is the comparison and the upload
|
|
271
|
+
// (raised in review). The test that blessed the old wording started its
|
|
272
|
+
// scripted server at `both_committed` and so never ran the order it was
|
|
273
|
+
// asserting about.
|
|
274
|
+
const NOTHING_COMPARED = ' No digits were shown and no bundle was sent.\n';
|
|
275
|
+
/**
|
|
276
|
+
* Whether this machine's key is already spoken for by a DIFFERENT machine, or
|
|
277
|
+
* this machine already holds a different key — asked before an attempt is spent.
|
|
278
|
+
*
|
|
279
|
+
* Returns the message to print, or null to go ahead. Best effort in one
|
|
280
|
+
* direction only: a lookup that fails, or a server too old to say which row is
|
|
281
|
+
* this machine's, returns null and lets the ceremony run. The server's own
|
|
282
|
+
* refusal is the gate; this exists to keep an unwinnable run from costing one
|
|
283
|
+
* of five, and a check that cannot answer must not become a second refusal on
|
|
284
|
+
* its own.
|
|
285
|
+
*
|
|
286
|
+
* `thisMachine === undefined` is not `false`. An older server says nothing
|
|
287
|
+
* about ownership, and reading silence as "none of these are yours" would
|
|
288
|
+
* refuse every ordinary rejoin.
|
|
289
|
+
*/
|
|
290
|
+
async function keyAlreadySpokenFor(client, pubkey) {
|
|
291
|
+
let devices;
|
|
292
|
+
try {
|
|
293
|
+
devices = await client.listDevices();
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
if (!devices.some((d) => d.thisMachine !== undefined))
|
|
299
|
+
return null;
|
|
300
|
+
const mine = devices.find((d) => d.thisMachine === true);
|
|
301
|
+
const holdingMyKey = devices.find((d) => d.devicePubkey === pubkey);
|
|
302
|
+
// The ordinary second-team case: this machine is on the account and the key
|
|
303
|
+
// it presents is its own. It still runs the full ceremony — what that
|
|
304
|
+
// establishes is this team's bundle digest, not the registration.
|
|
305
|
+
if (holdingMyKey && holdingMyKey.thisMachine === true)
|
|
306
|
+
return null;
|
|
307
|
+
if (holdingMyKey) {
|
|
308
|
+
return (`not started: this machine's device key is already registered to a different machine on\n` +
|
|
309
|
+
` this account. It is listed as "${holdingMyKey.label}".\n\n` +
|
|
310
|
+
' Two things look like this, and the machine you are sitting at tells them apart:\n\n' +
|
|
311
|
+
' the same machine, signed in again under another name\n' +
|
|
312
|
+
' -> an owner removes the OTHER entry in the console (Machines -> Remove).\n\n' +
|
|
313
|
+
" a different machine, using a copy of that one's identity file\n" +
|
|
314
|
+
' -> this machine needs its own. Move ~/.agmsg-cloud/device.key aside and\n' +
|
|
315
|
+
' run this again; a new identity is generated when none is present.\n\n' +
|
|
316
|
+
' No enrollment was started, so this costs none of your attempts.\n');
|
|
317
|
+
}
|
|
318
|
+
if (mine) {
|
|
319
|
+
return ('not started: this machine is already listed on the account under a different device key.\n' +
|
|
320
|
+
' That happens when the key at ~/.agmsg-cloud/device.key was replaced or lost —\n' +
|
|
321
|
+
' the listed one cannot be recovered from here, and the account still points at it.\n\n' +
|
|
322
|
+
' To clear it, an owner removes this machine in the console (Machines -> Remove),\n' +
|
|
323
|
+
' and then this machine signs in again with `agmsg-cloud login`.\n\n' +
|
|
324
|
+
' No enrollment was started, so this costs none of your attempts.\n');
|
|
325
|
+
}
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Why this machine's device key was refused, and what actually clears it.
|
|
330
|
+
*
|
|
331
|
+
* Both remedies were followed to the end before being printed. Removing a
|
|
332
|
+
* machine in the console revokes its capability URL AND its device rows
|
|
333
|
+
* (`revokeCapabilityRows`) and releases the machine slot, and both unique
|
|
334
|
+
* indexes are partial on active rows — so the next sign-in registers cleanly.
|
|
335
|
+
* It is an OWNER action, which is why the wording does not tell a member to go
|
|
336
|
+
* and do it.
|
|
337
|
+
*/
|
|
338
|
+
async function renderKeyRefusal(code, client, pubkey) {
|
|
339
|
+
if (code === 'device_key_changed') {
|
|
340
|
+
return ('not added: this machine is already listed on the account under a different device key.\n' +
|
|
341
|
+
' That happens when the key at ~/.agmsg-cloud/device.key was replaced or lost —\n' +
|
|
342
|
+
' the listed one cannot be recovered from here, and the account still points at it.\n\n' +
|
|
343
|
+
' To clear it, an owner removes this machine in the console (Machines -> Remove),\n' +
|
|
344
|
+
' and then this machine signs in again with `agmsg-cloud login`.\n' +
|
|
345
|
+
NOTHING_COMPARED);
|
|
346
|
+
}
|
|
347
|
+
// Naming the machine that holds the key turns "something is wrong" into one
|
|
348
|
+
// decision. It discloses nothing new: `listDevices` already answers any
|
|
349
|
+
// capability of this account with every device's label and public key.
|
|
350
|
+
//
|
|
351
|
+
// Best effort — a failed lookup must not replace the reason with an error
|
|
352
|
+
// about the lookup.
|
|
353
|
+
let holder = '';
|
|
354
|
+
try {
|
|
355
|
+
const held = (await client.listDevices()).find((d) => d.devicePubkey === pubkey);
|
|
356
|
+
if (held)
|
|
357
|
+
holder = ` It is listed as "${held.label}".`;
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
holder = '';
|
|
361
|
+
}
|
|
362
|
+
return (`not added: this machine's device key is already registered to a different machine on\n` +
|
|
363
|
+
` this account.${holder}\n\n` +
|
|
364
|
+
' Two things look like this, and the machine you are sitting at tells them apart:\n\n' +
|
|
365
|
+
' the same machine, signed in again under another name\n' +
|
|
366
|
+
' -> an owner removes the OTHER entry in the console (Machines -> Remove).\n\n' +
|
|
367
|
+
' a different machine, using a copy of that one\'s identity file\n' +
|
|
368
|
+
' -> this machine needs its own. Move ~/.agmsg-cloud/device.key aside and\n' +
|
|
369
|
+
' run this again; a new identity is generated when none is present.\n\n' +
|
|
370
|
+
NOTHING_COMPARED);
|
|
371
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { hostname } from 'node:os';
|
|
3
|
+
import { CourierClient } from '../api.js';
|
|
4
|
+
import { originOf, readCredential } from '../credentials.js';
|
|
5
|
+
import { publicKeyOf } from '../oss.js';
|
|
6
|
+
import { deviceIdentityPath } from '../paths.js';
|
|
7
|
+
import { NEEDS, ensurePreflight, preflight } from '../preflight.js';
|
|
8
|
+
import { shellArg } from '../shell-arg.js';
|
|
9
|
+
import { cmdFetch } from './fetch.js';
|
|
10
|
+
import { cmdPull } from './pull.js';
|
|
11
|
+
import { cmdRequest } from './request.js';
|
|
12
|
+
// The name login settled for this machine, if there is a credential to read it
|
|
13
|
+
// from. Null rather than a throw: sync's own failure for a missing credential
|
|
14
|
+
// is better than one from a name lookup, and it says something more useful.
|
|
15
|
+
function credentialMachineName(config) {
|
|
16
|
+
const stored = readCredential(originOf(config.baseUrl));
|
|
17
|
+
return stored?.machineName ?? null;
|
|
18
|
+
}
|
|
19
|
+
export async function cmdSync(config, opts) {
|
|
20
|
+
// Checked here for everything the three steps need together, so a machine
|
|
21
|
+
// missing `remote.sh` is told before the ceremony rather than after it — the
|
|
22
|
+
// approver interrupted, the attempt spent, on a machine that could never
|
|
23
|
+
// have finished.
|
|
24
|
+
//
|
|
25
|
+
// Not the ONLY check: `fetch` and `pull` still run their own when called
|
|
26
|
+
// directly, and they still run them when called from here. That repetition
|
|
27
|
+
// is cheap and it is what keeps them correct on their own; what this adds is
|
|
28
|
+
// that the failure arrives first (raised in review).
|
|
29
|
+
ensurePreflight(preflight(config.scriptsDir, NEEDS.sync));
|
|
30
|
+
const d = opts.deps ?? {};
|
|
31
|
+
const out = d.out ?? ((text) => void process.stdout.write(text));
|
|
32
|
+
const request = d.request ?? cmdRequest;
|
|
33
|
+
const fetch = d.fetch ?? cmdFetch;
|
|
34
|
+
const pull = d.pull ?? cmdPull;
|
|
35
|
+
// The approver sees this, and they are looking for a machine they recognise.
|
|
36
|
+
// A hostname is what a person calls their laptop; a uuid is what they read
|
|
37
|
+
// aloud wrongly.
|
|
38
|
+
//
|
|
39
|
+
// Taken from the credential login settled, NOT from the hostname again. They
|
|
40
|
+
// were two independent defaults reading the same source, so a machine named
|
|
41
|
+
// at login still introduced itself to the approver as its hostname — which
|
|
42
|
+
// is how two machines came to be called mbp2024 on one approval screen. The
|
|
43
|
+
// operator has one machine; it has one name, chosen once.
|
|
44
|
+
//
|
|
45
|
+
// `--label` still overrides, for the run where someone wants to be someone
|
|
46
|
+
// else on purpose.
|
|
47
|
+
const label = opts.label ?? credentialMachineName(config) ?? hostname();
|
|
48
|
+
out(`\nJoining "${opts.team}" from this machine ("${label}").\n\n`);
|
|
49
|
+
// Said here, where it can still be acted on, because the approver cannot act
|
|
50
|
+
// on it at all: their screen shows names, and two rows reading the same one
|
|
51
|
+
// leave them guessing which request is this machine.
|
|
52
|
+
//
|
|
53
|
+
// A WARNING, NOT A REFUSAL, and the reason is who is inconvenienced by each.
|
|
54
|
+
// Refusing strands a join on a name the operator may not be able to change
|
|
55
|
+
// from here — the other machine might be one they no longer have, or a stale
|
|
56
|
+
// device only an approver can remove — and it refuses something that is
|
|
57
|
+
// probably fine, since the approver may well know which is which. Saying it
|
|
58
|
+
// costs nothing and puts the choice where the name can still be typed
|
|
59
|
+
// differently: re-run with --label.
|
|
60
|
+
//
|
|
61
|
+
// Best effort. A machine that cannot list devices is still allowed to ask to
|
|
62
|
+
// join; failing the join over a failed courtesy check would be the refusal
|
|
63
|
+
// this deliberately is not.
|
|
64
|
+
try {
|
|
65
|
+
const existing = await new CourierClient(config).listDevices();
|
|
66
|
+
// THIS MACHINE'S OWN ROW IS NOT A CLASH WITH ITSELF.
|
|
67
|
+
//
|
|
68
|
+
// The comparison was on label alone, so a machine already on the account —
|
|
69
|
+
// joining a second team — always matched itself and was warned that "this
|
|
70
|
+
// account already has a machine called <its own name>". That is the line
|
|
71
|
+
// read during the production walkthrough (#194), and acting on it means
|
|
72
|
+
// renaming a machine to avoid a collision with nothing.
|
|
73
|
+
//
|
|
74
|
+
// Identity is the device key, not the name. Absent before the first join,
|
|
75
|
+
// which is exactly when there is no own row to exclude.
|
|
76
|
+
const idPath = deviceIdentityPath();
|
|
77
|
+
const mine = existsSync(idPath) ? await publicKeyOf(idPath) : null;
|
|
78
|
+
const clash = existing.filter((d) => d.label === label && d.devicePubkey !== mine);
|
|
79
|
+
if (clash.length > 0) {
|
|
80
|
+
out(` NOTE: this account already has ${clash.length === 1 ? 'a machine' : `${clash.length} machines`} called "${label}".\n`);
|
|
81
|
+
out(' The approver sees names, so identical ones are indistinguishable on their screen.\n');
|
|
82
|
+
out(` To be told apart, run: agmsg-cloud sync ${shellArg(opts.team)} --label <a-different-name>\n\n`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Not fatal, and not reported: a failure here says nothing about whether
|
|
87
|
+
// this machine may join, and a scary line about an unrelated call would
|
|
88
|
+
// compete with the instructions below.
|
|
89
|
+
}
|
|
90
|
+
out(' On a machine that already has the team, run:\n\n');
|
|
91
|
+
// The real command, with the team quoted for a shell. A placeholder makes
|
|
92
|
+
// the reader do the substitution, and the whole point of this arc is to stop
|
|
93
|
+
// carrying values by hand between machines — an instruction that ends in
|
|
94
|
+
// `<team>` hands the work straight back (raised in review).
|
|
95
|
+
out(` agmsg-cloud approve ${shellArg(opts.team)}\n\n`);
|
|
96
|
+
out(' Compare the eight digits on both screens before answering there.\n\n');
|
|
97
|
+
// Everything the ceremony guarantees happens inside here: the commitment is
|
|
98
|
+
// pinned before anything opens, nothing is sealed or uploaded until the
|
|
99
|
+
// digits match, and this side refuses a transcript it cannot verify.
|
|
100
|
+
// This command owns what the operator is told to do next, because it is the
|
|
101
|
+
// one that knows there is nothing to do: fetch and pull follow immediately
|
|
102
|
+
// below. The decision is made HERE, once, rather than inferred inside each
|
|
103
|
+
// step (raised in review).
|
|
104
|
+
await request(config, { label }, { nextStepsFromCaller: true });
|
|
105
|
+
// PULL BEFORE FETCH, and the old order was the wrong way round.
|
|
106
|
+
//
|
|
107
|
+
// The comment that used to sit below said the messages "needed the key that
|
|
108
|
+
// arrived above". They do not. History arrives SEALED — that is what the
|
|
109
|
+
// age-v1 awaiting-unlock state is — and the key opens it rather than
|
|
110
|
+
// fetching it. The dependency runs the other way, and only pull satisfies
|
|
111
|
+
// it: `remote.sh unlock`, which fetch calls, refuses a team that is not on
|
|
112
|
+
// this machine, and pull is the only thing that writes one
|
|
113
|
+
// (_remote_write_pulled_team) or the age-v1 binding unlock insists on.
|
|
114
|
+
//
|
|
115
|
+
// Measured against a clean store rather than read: with no local team,
|
|
116
|
+
//
|
|
117
|
+
// remote.sh unlock <team> --authenticated-bundle-stdin
|
|
118
|
+
// -> agmsg: team not found: <team>
|
|
119
|
+
//
|
|
120
|
+
// On a real second machine the old order therefore failed AFTER the ceremony
|
|
121
|
+
// had run, an attempt had been spent and an approver had been interrupted.
|
|
122
|
+
// It stayed hidden because the second machine in testing pointed at the first
|
|
123
|
+
// machine's install, where the team was already present — so the second
|
|
124
|
+
// machine's path had never actually been walked.
|
|
125
|
+
await pull(config, opts.teamId === undefined
|
|
126
|
+
? { team: opts.team, nextStepsFromCaller: true }
|
|
127
|
+
: { team: opts.team, teamId: opts.teamId, nextStepsFromCaller: true });
|
|
128
|
+
// And now the key, which opens what arrived above. Only reachable once the
|
|
129
|
+
// server says the ceremony was approved — `request` waits for that, and
|
|
130
|
+
// throws otherwise. The bundle is checked against the snapshot those digits
|
|
131
|
+
// authenticated, by machine.
|
|
132
|
+
await fetch(config, { team: opts.team });
|
|
133
|
+
// The last thing said, because "what now" is the question the screen leaves
|
|
134
|
+
// otherwise — and here the answer is that there is no next command. Saying
|
|
135
|
+
// so is the point: someone who has just run five steps and watched a code
|
|
136
|
+
// comparison has every reason to expect a sixth.
|
|
137
|
+
out(`\n"${opts.team}" is on this machine, unlocked and syncing. Nothing further to run.\n`);
|
|
138
|
+
}
|