agmsg-cloud 0.1.0-rc.3 → 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 +3 -1
- package/dist/src/api.js +17 -0
- package/dist/src/ceremony.js +21 -4
- package/dist/src/commands/fetch.js +43 -1
- package/dist/src/commands/login.js +29 -10
- package/dist/src/commands/logout.js +74 -0
- package/dist/src/commands/request.js +164 -3
- package/dist/src/commands/sync.js +17 -6
- package/dist/src/commands/vault.js +209 -19
- package/dist/src/credentials.js +43 -1
- package/dist/src/index.js +38 -6
- package/dist/src/oss.js +89 -0
- package/dist/src/pending.js +33 -1
- package/dist/src/recovery-key.js +12 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,7 +23,9 @@ agmsg-cloud login sign this machine in
|
|
|
23
23
|
agmsg-cloud connect <team> put a team this machine runs onto the service
|
|
24
24
|
agmsg-cloud sync <team> (on a new machine) ask to join, and wait
|
|
25
25
|
agmsg-cloud approve <team> (on a machine that has the keys) answer that request
|
|
26
|
-
agmsg-cloud recovery setup
|
|
26
|
+
agmsg-cloud recovery setup create the account's recovery key and back up every
|
|
27
|
+
active team this machine's store reports
|
|
28
|
+
|
|
27
29
|
```
|
|
28
30
|
|
|
29
31
|
`sync` and `approve` are two halves of one ceremony: each screen shows eight
|
package/dist/src/api.js
CHANGED
|
@@ -21,6 +21,7 @@ const STATUSES = [
|
|
|
21
21
|
'consumed',
|
|
22
22
|
'expired',
|
|
23
23
|
'failed',
|
|
24
|
+
'refused',
|
|
24
25
|
];
|
|
25
26
|
// Ranges, not types.
|
|
26
27
|
//
|
|
@@ -365,6 +366,22 @@ export class CourierClient {
|
|
|
365
366
|
if (!isObject(d) || !isRecipient(d['devicePubkey']) || !isUuid(d['id'])) {
|
|
366
367
|
throw new CourierError(200, 'malformed_devices_response');
|
|
367
368
|
}
|
|
369
|
+
// `thisMachine` DECIDES A REFUSAL, so it is checked like one.
|
|
370
|
+
//
|
|
371
|
+
// Absent or boolean, nothing else. `"true"`, `null` and `0` are all
|
|
372
|
+
// truthy-or-falsy in some reading, and any of them would make the
|
|
373
|
+
// pre-flight below treat a row as "not this machine" while ALSO treating
|
|
374
|
+
// the response as coming from a server that answers the question — so an
|
|
375
|
+
// ordinary rejoin would be refused locally, without a single request
|
|
376
|
+
// reaching the server that could have corrected it.
|
|
377
|
+
//
|
|
378
|
+
// Refused here rather than coerced: this value gates a decision no other
|
|
379
|
+
// check re-derives, and the pre-flight's own catch turns a malformed
|
|
380
|
+
// response into "go ahead and run the ceremony", which is the safe
|
|
381
|
+
// direction (raised in review).
|
|
382
|
+
if (d['thisMachine'] !== undefined && typeof d['thisMachine'] !== 'boolean') {
|
|
383
|
+
throw new CourierError(200, 'malformed_devices_response');
|
|
384
|
+
}
|
|
368
385
|
}
|
|
369
386
|
return out['devices'];
|
|
370
387
|
}
|
package/dist/src/ceremony.js
CHANGED
|
@@ -30,7 +30,11 @@ export class CeremonyError extends Error {
|
|
|
30
30
|
const PROGRESSION = ['requester_committed', 'both_committed', 'requester_opened', 'opened', 'consumed'];
|
|
31
31
|
// Ends the ceremony without reaching the end. Not the same as 'consumed', which
|
|
32
32
|
// is the end.
|
|
33
|
-
|
|
33
|
+
// 'refused' belongs here even though it costs no attempt: a waiter's question
|
|
34
|
+
// is "can this still finish", and the answer is no. What it costs is a separate
|
|
35
|
+
// question, answered by the ledger and by the server's counters — a waiter that
|
|
36
|
+
// left it out would poll a dead request until it timed out.
|
|
37
|
+
const TERMINAL_FAILURE = ['expired', 'failed', 'refused'];
|
|
34
38
|
function rank(status) {
|
|
35
39
|
return PROGRESSION.indexOf(status);
|
|
36
40
|
}
|
|
@@ -137,9 +141,22 @@ export function renderSasBlock(sas, role) {
|
|
|
137
141
|
// Only the approver is asked anything. Telling the joining machine's
|
|
138
142
|
// operator to "answer no" pointed at a prompt that does not exist on their
|
|
139
143
|
// side, one line above the sentence explaining that the approver decides.
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
144
|
+
//
|
|
145
|
+
// SAID HERE AND NOWHERE ELSE. `request` printed its own version of the
|
|
146
|
+
// requester's sentence straight after this block, so the screen read: who
|
|
147
|
+
// decides -> how to compare remotely -> who decides (#195). The block is
|
|
148
|
+
// the one place that knows which side it is rendering for, so it is the
|
|
149
|
+
// one place that says it.
|
|
150
|
+
//
|
|
151
|
+
// The wording is the LONGER of the two that existed, because it carried
|
|
152
|
+
// something this one did not: what a `no` means. Deduplicating by keeping
|
|
153
|
+
// the shorter line would have deleted that with it.
|
|
154
|
+
...(role === 'approver'
|
|
155
|
+
? [' If they differ at all, answer no. That is what this check is for.']
|
|
156
|
+
: [
|
|
157
|
+
' The approver decides. If they confirm, this machine is added and can',
|
|
158
|
+
' fetch its bundle; if not, nothing is sent and this request is over.',
|
|
159
|
+
]),
|
|
143
160
|
'',
|
|
144
161
|
// Addressed to the people it applies to, and to nobody else.
|
|
145
162
|
//
|
|
@@ -4,7 +4,7 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { CourierClient } from '../api.js';
|
|
5
5
|
import { clearAuthenticatedDigest, readAuthenticatedDigests } from '../authenticated-digest.js';
|
|
6
6
|
import { originOf } from '../credentials.js';
|
|
7
|
-
import { decryptWithIdentity, publicKeyOf, unlockBundle, verifyHandoffDigest } from '../oss.js';
|
|
7
|
+
import { decryptWithIdentity, localTeamLookup, publicKeyOf, unlockBundle, verifyHandoffDigest, } from '../oss.js';
|
|
8
8
|
import { deviceIdentityPath } from '../paths.js';
|
|
9
9
|
import { NEEDS, ensurePreflight, preflight } from '../preflight.js';
|
|
10
10
|
// (c, part 2) B fetches the sealed bundles addressed to its device, opens each
|
|
@@ -23,9 +23,51 @@ env = process.env) {
|
|
|
23
23
|
// approval was recorded under, so a regenerated identity does not inherit
|
|
24
24
|
// the previous one's ceremonies (raised in review).
|
|
25
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
|
+
}
|
|
26
67
|
const client = new CourierClient(config);
|
|
27
68
|
const blobs = await client.fetchBlobs();
|
|
28
69
|
if (blobs.length === 0) {
|
|
70
|
+
// Now this says something: the team IS here, and nothing is waiting.
|
|
29
71
|
process.stdout.write('no pending bundles\n');
|
|
30
72
|
return;
|
|
31
73
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { DEFAULT_ENDPOINT, armEnterToOpen, verificationUrlIsSafe, } from '../browser.js';
|
|
2
2
|
import { isOrgAddress, originOf, readCredential, writeCredential } from '../credentials.js';
|
|
3
|
-
import { settleMachineName } from '../machine-name.js';
|
|
3
|
+
import { settleMachineName, validateMachineName } from '../machine-name.js';
|
|
4
4
|
// `login` — the device-authorization flow, from this machine's side.
|
|
5
5
|
//
|
|
6
6
|
// It is the one subcommand that runs with no credential, so it takes its
|
|
@@ -59,14 +59,11 @@ export async function cmdLogin(opts) {
|
|
|
59
59
|
const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
60
60
|
const now = opts.now ?? (() => Date.now());
|
|
61
61
|
const endpoint = (opts.endpoint ?? DEFAULT_ENDPOINT).replace(/\/+$/, '');
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
const machineName = await settleMachineName(opts.machineName, {
|
|
68
|
-
...(opts.nameDeps ?? {}),
|
|
69
|
-
});
|
|
62
|
+
// The flag, validated, before anything is asked. `settleMachineName` returns
|
|
63
|
+
// it verbatim when it is given, so nothing below needs the prompt to know
|
|
64
|
+
// what the caller wants — and a malformed value should be refused before the
|
|
65
|
+
// command starts talking to a server.
|
|
66
|
+
const asked = opts.machineName === undefined ? undefined : validateMachineName(opts.machineName);
|
|
70
67
|
// Said out loud on EVERY run, default or not. A default is convenient, and
|
|
71
68
|
// the thing it takes away is the moment the operator typed the destination —
|
|
72
69
|
// so the destination is printed instead. Nobody should have to guess which
|
|
@@ -98,8 +95,19 @@ export async function cmdLogin(opts) {
|
|
|
98
95
|
// are repaired by retrying the IDEMPOTENT activate with what is already
|
|
99
96
|
// stored, before opening a new grant — a fresh grant would instead collide
|
|
100
97
|
// with the machine name the stored credential is holding.
|
|
98
|
+
//
|
|
99
|
+
// Read BEFORE the machine name is asked for. The prompt used to run first, so
|
|
100
|
+
// a machine that was already signed in was asked to name itself and only then
|
|
101
|
+
// told the question had no bearing — and the asking is not a wasted
|
|
102
|
+
// keystroke, it is a claim. Two people read it as evidence that the stored
|
|
103
|
+
// credential had been deleted, and went looking for a file that was on disk
|
|
104
|
+
// the whole time (#193).
|
|
105
|
+
//
|
|
106
|
+
// rc.2 checked first. #169 moved the prompt to the top of the command to
|
|
107
|
+
// reuse the name on both screens; nothing in it was about ordering, so
|
|
108
|
+
// nothing looked at the ordering.
|
|
101
109
|
const stored = readCredential(originOf(endpoint), process.env);
|
|
102
|
-
if (stored && (
|
|
110
|
+
if (stored && (asked === undefined || stored.machineName === asked)) {
|
|
103
111
|
const res = await post('/v1/device/activate', {}, stored.secret);
|
|
104
112
|
if (res.ok) {
|
|
105
113
|
process.stdout.write(`Already signed in as machine "${stored.machineName}".\n`);
|
|
@@ -114,6 +122,17 @@ export async function cmdLogin(opts) {
|
|
|
114
122
|
throw new Error(`a credential for ${originOf(endpoint)} is stored but could not be activated (${res.status} ${code}) — not starting a new login on top of it`);
|
|
115
123
|
}
|
|
116
124
|
}
|
|
125
|
+
// Only now is the name needed: this run is opening a grant, so there really
|
|
126
|
+
// is a machine to name.
|
|
127
|
+
//
|
|
128
|
+
// Asked for, not assumed. The name is what an approver reads to tell one
|
|
129
|
+
// machine from another, and defaulting to the hostname without showing it is
|
|
130
|
+
// how two machines came to be called the same thing in production.
|
|
131
|
+
// --machine-name still wins outright; a terminal gets a pre-filled question;
|
|
132
|
+
// a headless run gets the hostname and is told so.
|
|
133
|
+
const machineName = await settleMachineName(opts.machineName, {
|
|
134
|
+
...(opts.nameDeps ?? {}),
|
|
135
|
+
});
|
|
117
136
|
const codeRes = await post('/v1/device/code', {
|
|
118
137
|
client_id: CLIENT_ID,
|
|
119
138
|
scope: 'login',
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { DEFAULT_ENDPOINT } from '../browser.js';
|
|
2
|
+
import { originOf, removeCredentials } from '../credentials.js';
|
|
3
|
+
import { clearRecordsForOrigin } from '../pending.js';
|
|
4
|
+
/**
|
|
5
|
+
* The endpoint `logout` was asked for, or a refusal.
|
|
6
|
+
*
|
|
7
|
+
* The argv this command accepts is CLOSED — `[]`, or exactly
|
|
8
|
+
* `--endpoint <url>` — rather than filtered for the one flag it reads.
|
|
9
|
+
*
|
|
10
|
+
* Because it is destructive AND defaulted. Reading only `--endpoint` and
|
|
11
|
+
* ignoring the rest means `logout --endpont https://self.test` finds no flag,
|
|
12
|
+
* falls back to the hosted service, and deletes the production credential
|
|
13
|
+
* while the person believed they had named their own stack. So does
|
|
14
|
+
* `logout https://self.test`. Neither is an exotic input: they are a typo and
|
|
15
|
+
* a reasonable guess at the syntax.
|
|
16
|
+
*
|
|
17
|
+
* A command that removes a secret must not act on an argv it did not
|
|
18
|
+
* understand.
|
|
19
|
+
*/
|
|
20
|
+
export function logoutEndpoint(argv) {
|
|
21
|
+
const refuse = () => {
|
|
22
|
+
throw new Error('usage: agmsg-cloud logout [--endpoint <url>]');
|
|
23
|
+
};
|
|
24
|
+
if (argv.length === 0)
|
|
25
|
+
return undefined;
|
|
26
|
+
if (argv.length !== 2 || argv[0] !== '--endpoint')
|
|
27
|
+
return refuse();
|
|
28
|
+
const value = argv[1];
|
|
29
|
+
// A missing value would otherwise swallow the next token; there is no next
|
|
30
|
+
// token here, but the same shape (`--endpoint --force`) is what the flag
|
|
31
|
+
// reader elsewhere in this file guards against.
|
|
32
|
+
if (value.length === 0 || value.startsWith('--'))
|
|
33
|
+
return refuse();
|
|
34
|
+
try {
|
|
35
|
+
// Parsed here rather than at the point of deletion: an unparseable address
|
|
36
|
+
// should be refused before anything is read or removed.
|
|
37
|
+
void new URL(value);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return refuse();
|
|
41
|
+
}
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
export function cmdLogout(opts = {}) {
|
|
45
|
+
const env = opts.env ?? process.env;
|
|
46
|
+
const out = opts.out ?? ((text) => void process.stdout.write(text));
|
|
47
|
+
const endpoint = (opts.endpoint ?? DEFAULT_ENDPOINT).replace(/\/+$/, '');
|
|
48
|
+
const origin = originOf(endpoint);
|
|
49
|
+
const removed = removeCredentials(origin, env);
|
|
50
|
+
const pending = clearRecordsForOrigin(origin, env);
|
|
51
|
+
if (removed.length === 0) {
|
|
52
|
+
// Not an error. "There was nothing to remove" is the state someone running
|
|
53
|
+
// this twice is in, and the state someone checking is in — and it is the
|
|
54
|
+
// same end state they asked for. Exiting non-zero would make a script that
|
|
55
|
+
// signs out defensively fail on the run where it worked.
|
|
56
|
+
out(`No credential stored for ${origin}.\n`);
|
|
57
|
+
if (pending > 0)
|
|
58
|
+
out(`Cleared ${pending} pending enrollment record(s).\n`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
// Named, not counted. The person signing out is entitled to know which
|
|
62
|
+
// identity just left this machine — one host can hold credentials for
|
|
63
|
+
// several orgs, and "signed out" without saying whose is how someone
|
|
64
|
+
// discovers later that the wrong one went.
|
|
65
|
+
for (const credential of removed) {
|
|
66
|
+
out(`Signed out machine "${credential.machineName}" from ${origin} (org ${credential.org}).\n`);
|
|
67
|
+
}
|
|
68
|
+
if (pending > 0)
|
|
69
|
+
out(`Cleared ${pending} pending enrollment record(s).\n`);
|
|
70
|
+
// Said because the omission is the surprising part: someone who ran this to
|
|
71
|
+
// clean up a machine should not have to wonder whether their history went
|
|
72
|
+
// with it.
|
|
73
|
+
out(`This machine's device key and local team keys were not touched.\n`);
|
|
74
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
2
2
|
import { dirname } from 'node:path';
|
|
3
3
|
import { createSasCommitment } from '@agmsg-cloud/sas-core';
|
|
4
|
-
import { CourierClient } from '../api.js';
|
|
4
|
+
import { CourierClient, CourierError } from '../api.js';
|
|
5
5
|
import { recordAuthenticatedDigest } from '../authenticated-digest.js';
|
|
6
6
|
import { CeremonyError, renderSasBlock, sasFromOpenedTranscript, waitForStatus } from '../ceremony.js';
|
|
7
7
|
import { generateDeviceIdentity, publicKeyOf } from '../oss.js';
|
|
@@ -94,6 +94,36 @@ export async function cmdRequest(config, args, deps = {}) {
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
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
|
+
}
|
|
97
127
|
const { commitmentHex: plannedCommitment, openingNonce: plannedNonce } = createSasCommitment(pubkey);
|
|
98
128
|
const charged = consumeAttempt(scope, plannedCommitment, env);
|
|
99
129
|
if (!charged.ok) {
|
|
@@ -141,9 +171,15 @@ export async function cmdRequest(config, args, deps = {}) {
|
|
|
141
171
|
// Derived here, from the two opened nonces. Nothing displayed below came
|
|
142
172
|
// from the server as a code.
|
|
143
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.
|
|
144
182
|
out(renderSasBlock(sas, 'requester'));
|
|
145
|
-
out(' The approver decides. If they confirm, this machine is added and can\n' +
|
|
146
|
-
' fetch its bundle; if not, nothing is sent and this request is over.\n\n');
|
|
147
183
|
// The outcome is NOT known yet. Closing as succeeded here — which is what
|
|
148
184
|
// this did — recorded every rejected ceremony as a success, so §4.1's
|
|
149
185
|
// requirement that the requester counts failed and incomplete attempts, and
|
|
@@ -205,6 +241,131 @@ export async function cmdRequest(config, args, deps = {}) {
|
|
|
205
241
|
process.exitCode = 1;
|
|
206
242
|
return;
|
|
207
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
|
+
}
|
|
208
262
|
throw err;
|
|
209
263
|
}
|
|
210
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
|
+
}
|
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
// Re-exported: the sync tests pinned the quoting rule through this module
|
|
3
|
-
// before it had a home of its own, and those cases are still the ones that
|
|
4
|
-
// prove the printed command survives a team name with a space in it.
|
|
5
|
-
export { shellArg };
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
6
2
|
import { hostname } from 'node:os';
|
|
7
3
|
import { CourierClient } from '../api.js';
|
|
8
4
|
import { originOf, readCredential } from '../credentials.js';
|
|
5
|
+
import { publicKeyOf } from '../oss.js';
|
|
6
|
+
import { deviceIdentityPath } from '../paths.js';
|
|
9
7
|
import { NEEDS, ensurePreflight, preflight } from '../preflight.js';
|
|
8
|
+
import { shellArg } from '../shell-arg.js';
|
|
10
9
|
import { cmdFetch } from './fetch.js';
|
|
11
10
|
import { cmdPull } from './pull.js';
|
|
12
11
|
import { cmdRequest } from './request.js';
|
|
@@ -64,7 +63,19 @@ export async function cmdSync(config, opts) {
|
|
|
64
63
|
// this deliberately is not.
|
|
65
64
|
try {
|
|
66
65
|
const existing = await new CourierClient(config).listDevices();
|
|
67
|
-
|
|
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);
|
|
68
79
|
if (clash.length > 0) {
|
|
69
80
|
out(` NOTE: this account already has ${clash.length === 1 ? 'a machine' : `${clash.length} machines`} called "${label}".\n`);
|
|
70
81
|
out(' The approver sees names, so identical ones are indistinguishable on their screen.\n');
|
|
@@ -2,7 +2,7 @@ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { CourierClient } from '../api.js';
|
|
5
|
-
import { keyHandoff, remoteBinding, unlockAuthenticatedBundle } from '../oss.js';
|
|
5
|
+
import { connectedTeams, keyHandoff, remoteBinding, unlockAuthenticatedBundle } from '../oss.js';
|
|
6
6
|
import { NEEDS, ensurePreflight, preflight } from '../preflight.js';
|
|
7
7
|
import { generateRecoveryKey, normalizeRecoveryKey, promptRecoveryKey, showRecoveryKey, } from '../recovery-key.js';
|
|
8
8
|
import { appendVaultVersionWithVdk, createVault, openVaultWithVdk, readAccountVault, vdkFromRecoveryKey, } from '../vault-protocol.js';
|
|
@@ -93,16 +93,87 @@ async function keepSlot(address, vdk) {
|
|
|
93
93
|
process.stdout.write(`note: this machine could not keep a key slot (${saved.reason}), so the recovery ` +
|
|
94
94
|
'key will be needed again next time.\n');
|
|
95
95
|
}
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
96
|
+
// Whether a team's material is the same material, judged on the key epochs the
|
|
97
|
+
// bundle declares rather than on its bytes.
|
|
98
|
+
//
|
|
99
|
+
// The ciphertext differs every run (nonce), and whether the bundle itself is
|
|
100
|
+
// byte-stable is not something this side has established — so neither can carry
|
|
101
|
+
// the comparison. `key_ids` are the epochs, they are already stored per entry,
|
|
102
|
+
// and they are exactly what "the key situation has not changed" means. Compared
|
|
103
|
+
// as a set: the order the exporter lists them in is not part of the fact.
|
|
104
|
+
/**
|
|
105
|
+
* The key that decides whether two records name the same team.
|
|
106
|
+
*
|
|
107
|
+
* One function, so both sides of every comparison are built the same way — a
|
|
108
|
+
* separator chosen twice is a separator that eventually differs. Framed rather
|
|
109
|
+
* than joined: `('a|b','c')` and `('a','b|c')` are different pairs and must not
|
|
110
|
+
* produce one key, which is the collision the request digest had to be fixed
|
|
111
|
+
* for. These two values are uuids today, so no pair can collide — the framing
|
|
112
|
+
* is here so that stays true if either ever stops being one.
|
|
113
|
+
*
|
|
114
|
+
* Written after the first version put a literal separator inside a template and
|
|
115
|
+
* a NUL character ended up in the source, which made the whole file binary to
|
|
116
|
+
* `grep`. Both sides still agreed, so nothing failed; the file simply stopped
|
|
117
|
+
* answering searches.
|
|
118
|
+
*/
|
|
119
|
+
function vaultTeamKey(serverInstanceId, teamId) {
|
|
120
|
+
return JSON.stringify([serverInstanceId, teamId]);
|
|
121
|
+
}
|
|
122
|
+
function sameEpochs(a, b) {
|
|
123
|
+
if (a.length !== b.length)
|
|
124
|
+
return false;
|
|
125
|
+
const left = [...a].sort();
|
|
126
|
+
const right = [...b].sort();
|
|
127
|
+
return left.every((v, i) => v === right[i]);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* `recovery setup` — put the keys of every team THIS MACHINE reports into the
|
|
131
|
+
* account's vault.
|
|
132
|
+
*
|
|
133
|
+
* NO ARGUMENT IS THE ORDINARY FORM. The vault holds one recovery key for the
|
|
134
|
+
* whole account, so "set up recovery" is an account-level act; naming a team
|
|
135
|
+
* made it something to repeat every time a team was added, which is the folding
|
|
136
|
+
* this design exists to do. A team may still be named, for adding one on its
|
|
137
|
+
* own.
|
|
138
|
+
*
|
|
139
|
+
* NOT "every team on the account", and the difference is load-bearing until
|
|
140
|
+
* fujibee/agmsg#650 lands. The set comes from `remote.sh status --json`, which
|
|
141
|
+
* drops a team it could not read and exits 0 — so an active team that has never
|
|
142
|
+
* been backed up can be missed with nothing said. Every sentence describing this
|
|
143
|
+
* command, here and in `--help` and in the README, stops at what the machine
|
|
144
|
+
* reported for exactly that reason. Widening them is the change that closes
|
|
145
|
+
* #182, and it comes after #650, not before.
|
|
146
|
+
*
|
|
147
|
+
* IDEMPOTENT. A run where no team's epochs have moved writes nothing and leaves
|
|
148
|
+
* the revision where it was. `setup` that appended an identical version every
|
|
149
|
+
* time was a name promising one thing and a behaviour doing another — and the
|
|
150
|
+
* cost is not storage, it is that a version history exists to show when
|
|
151
|
+
* something changed, and identical versions make it unreadable.
|
|
152
|
+
*/
|
|
99
153
|
export async function cmdVaultPut(config, args) {
|
|
100
154
|
// Same check connect gets. Without it a machine with no agmsg install runs
|
|
101
155
|
// straight into `bash exited 127: …/remote.sh: No such file or directory` —
|
|
102
156
|
// the same absence connect reports as a named checklist.
|
|
103
157
|
ensurePreflight(preflight(config.scriptsDir, NEEDS.vaultPut));
|
|
104
158
|
const client = new CourierClient(config);
|
|
105
|
-
|
|
159
|
+
// Two questions, and they are not the same one.
|
|
160
|
+
//
|
|
161
|
+
// observed every team whose status this machine could READ, whatever it
|
|
162
|
+
// said. This is what makes "the vault holds a team that did not
|
|
163
|
+
// appear" mean something: without it, a team disconnected here
|
|
164
|
+
// is indistinguishable from one whose status could not be read.
|
|
165
|
+
// targets those with an ACTIVE binding. Only these may be filed — a
|
|
166
|
+
// disconnected team's ids are a leftover, and a vault entry
|
|
167
|
+
// under one records a backup against a remote this machine is no
|
|
168
|
+
// longer bound to.
|
|
169
|
+
const observed = args.team === undefined ? await connectedTeams(config.scriptsDir) : [];
|
|
170
|
+
const targets = args.team === undefined
|
|
171
|
+
? observed.filter((t) => t.state === 'active')
|
|
172
|
+
: [{ team: args.team, ...(await remoteBinding(config.scriptsDir, args.team)) }];
|
|
173
|
+
if (targets.length === 0) {
|
|
174
|
+
throw new Error('no team on this machine is connected to the hosted service, so there are no keys to back up.\n' +
|
|
175
|
+
'Run `agmsg-cloud connect <team>` first, or name a team if one is connected under a different name.');
|
|
176
|
+
}
|
|
106
177
|
// The account's vault, not this team's: one vault, one recovery key. A second
|
|
107
178
|
// team finds the vault that already exists and is added to it.
|
|
108
179
|
const { identity, version } = await readAccountVault(client);
|
|
@@ -125,7 +196,7 @@ export async function cmdVaultPut(config, args) {
|
|
|
125
196
|
// X" was true when a team had its own vault; saying it now would teach
|
|
126
197
|
// someone to expect a different key per team, which is the belief the
|
|
127
198
|
// account vault exists to remove.
|
|
128
|
-
const obtained = await vdkForVault(identity, version, args.team);
|
|
199
|
+
const obtained = await vdkForVault(identity, version, args.team ?? targets[0].team);
|
|
129
200
|
vdk = obtained.vdk;
|
|
130
201
|
askedForTheKey = obtained.usedRecoveryKey;
|
|
131
202
|
// Open before writing. The container has to be read to add a team to it,
|
|
@@ -165,17 +236,127 @@ export async function cmdVaultPut(config, args) {
|
|
|
165
236
|
await showRecoveryKey(minted, args.team);
|
|
166
237
|
container = emptyContainer();
|
|
167
238
|
}
|
|
239
|
+
// THE ENUMERATION IS NOT FAIL-CLOSED, AND THIS DOES NOT PRETEND TO FIX THAT.
|
|
240
|
+
//
|
|
241
|
+
// `remote.sh status --json` with no team DROPS a team it could not read and
|
|
242
|
+
// still exits 0, and the single-team form cannot tell "never connected" from
|
|
243
|
+
// "could not be read" either — same message, same exit code. Measured, and
|
|
244
|
+
// filed as fujibee/agmsg#650.
|
|
245
|
+
//
|
|
246
|
+
// The first version of this REFUSED when the vault held a team the run did
|
|
247
|
+
// not see. That was wrong, and the reason is worth keeping: a team
|
|
248
|
+
// deliberately disconnected here disappears from the enumeration too, and the
|
|
249
|
+
// vault keeps its entry forever by design — so the refusal fired every run,
|
|
250
|
+
// for the one reason that is not a problem, with no way out. Refusing on a
|
|
251
|
+
// distinction the system cannot draw treats "unknown" as "wrong".
|
|
252
|
+
//
|
|
253
|
+
// So it reports instead. What was not seen is put on the screen by name and
|
|
254
|
+
// the judgement goes back to the operator, who is the only party that knows
|
|
255
|
+
// whether they disconnected it. That leaves a real gap — nobody who does not
|
|
256
|
+
// read the output is protected — and the gap is smaller than a command that
|
|
257
|
+
// cannot be run.
|
|
258
|
+
//
|
|
259
|
+
// When #650 lands this can become a refusal again, because by then a team
|
|
260
|
+
// that could not be read will say so.
|
|
261
|
+
const seenThisRun = new Set(observed.map((t) => vaultTeamKey(t.serverInstanceId, t.teamId)));
|
|
262
|
+
const unseen = args.team === undefined
|
|
263
|
+
? container.teams.filter((e) => !seenThisRun.has(vaultTeamKey(e.server_instance_id, e.team_id)))
|
|
264
|
+
: [];
|
|
265
|
+
// Seen, and deliberately not filed. Kept apart from `unseen` because they are
|
|
266
|
+
// different facts: this machine HEARD about these, and the ones above it did
|
|
267
|
+
// not hear about at all.
|
|
268
|
+
const disconnected = observed.filter((t) => t.state === 'disconnected');
|
|
269
|
+
// WHAT THIS RUN COVERED, BY NAME. Written once, so both exits below say the
|
|
270
|
+
// same thing — a sentence written twice is one that eventually disagrees with
|
|
271
|
+
// itself, and these two paths differ in everything else.
|
|
272
|
+
//
|
|
273
|
+
// NO COUNT WITHOUT ITS MEMBERS. What the operator saw on the production
|
|
274
|
+
// walkthrough was `stored revision 3 — team 'rc3walk' backed up; 2 teams`,
|
|
275
|
+
// and the "2 teams" never said which two. A count reads as "the account is
|
|
276
|
+
// backed up", which is wider than what was checked: the set came from what
|
|
277
|
+
// the local store reported, and that store can come back short without
|
|
278
|
+
// saying so (agmsg#650).
|
|
279
|
+
//
|
|
280
|
+
// The first version of this got it wrong in its own way — it printed the
|
|
281
|
+
// heading "Covered" and then listed only the DISCONNECTED teams, because the
|
|
282
|
+
// backed-up ones are not known until the loop below has run. Hence the
|
|
283
|
+
// parameters: the groups are passed in rather than closed over, so the
|
|
284
|
+
// heading cannot outrun what is under it.
|
|
285
|
+
const coverage = (written, unchanged) => {
|
|
286
|
+
const group = (title, members) => members.length === 0 ? [] : [`\n ${title}\n`, ...members.map((m) => ` ${m}\n`)];
|
|
287
|
+
// The first two hold for either form — a run that names one team still has
|
|
288
|
+
// to say what it did with it. The rest are about the ENUMERATION, so they
|
|
289
|
+
// only mean anything when the enumeration is what chose the set.
|
|
290
|
+
const enumerated = args.team === undefined;
|
|
291
|
+
const lines = [
|
|
292
|
+
...group('Backed up in this revision:', written),
|
|
293
|
+
...group('Already current, nothing to write:', unchanged),
|
|
294
|
+
...(!enumerated
|
|
295
|
+
? []
|
|
296
|
+
: group('Disconnected on this machine, left as they are:', disconnected.map((t) => t.team))),
|
|
297
|
+
// Ids, because a team that never reported has no local name here to
|
|
298
|
+
// print — the same absence that put it on this list. Said plainly rather
|
|
299
|
+
// than dressed up as an instruction: this machine does not know whether
|
|
300
|
+
// these were disconnected on purpose, and the operator does.
|
|
301
|
+
...(!enumerated
|
|
302
|
+
? []
|
|
303
|
+
: group('In the vault, but not reported by this machine on this run:', unseen.map((e) => `${e.team_id} (server ${e.server_instance_id})`))),
|
|
304
|
+
];
|
|
305
|
+
if (unseen.length > 0) {
|
|
306
|
+
lines.push('\n Those entries are untouched. If you disconnected them here, nothing is\n', ' wrong. If you did not, this machine could not read their state and they\n', ' were not re-checked — that is agmsg#650, and it cannot be told apart\n', ' from here.\n');
|
|
307
|
+
}
|
|
308
|
+
// Said only by the form that DERIVED its set. A run given a team covered
|
|
309
|
+
// what it was told to; claiming it covered what the store reported would
|
|
310
|
+
// be a statement about an enumeration that never ran.
|
|
311
|
+
if (enumerated) {
|
|
312
|
+
lines.push("\n That is every team this machine's store reported. A team connected only\n", ' on another machine is backed up by running this there.\n');
|
|
313
|
+
}
|
|
314
|
+
return lines.join('');
|
|
315
|
+
};
|
|
168
316
|
const scratch = mkdtempSync(join(tmpdir(), 'agmsg-cloud-'));
|
|
169
317
|
try {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
318
|
+
// One pass over every target. A team whose declared epochs already match
|
|
319
|
+
// what the vault holds is left exactly as it is — not re-sealed under a
|
|
320
|
+
// fresh nonce, which would be a new version saying nothing new.
|
|
321
|
+
let next = container;
|
|
322
|
+
const written = [];
|
|
323
|
+
const unchanged = [];
|
|
324
|
+
for (const [i, target] of targets.entries()) {
|
|
325
|
+
const bundleFile = join(scratch, `handoff-${i}.bundle`);
|
|
326
|
+
await keyHandoff(config.scriptsDir, target.team, bundleFile);
|
|
327
|
+
const bundle = readFileSync(bundleFile);
|
|
328
|
+
const keyIds = bundleKeyIds(bundle);
|
|
329
|
+
const already = next.teams.find((e) => e.server_instance_id === target.serverInstanceId && e.team_id === target.teamId);
|
|
330
|
+
if (already && sameEpochs(already.key_ids, keyIds)) {
|
|
331
|
+
unchanged.push(target.team);
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
next = upsertTeam(next, {
|
|
335
|
+
server_instance_id: target.serverInstanceId,
|
|
336
|
+
team_id: target.teamId,
|
|
337
|
+
key_ids: keyIds,
|
|
338
|
+
bundle: bundle.toString('base64'),
|
|
339
|
+
});
|
|
340
|
+
written.push(target.team);
|
|
341
|
+
}
|
|
342
|
+
// Nothing moved, and the vault already exists: there is no version to
|
|
343
|
+
// write. Saying "stored revision N+1" here would be true of the server and
|
|
344
|
+
// false about the account — the point of a revision is that something is
|
|
345
|
+
// different in it.
|
|
346
|
+
if (written.length === 0 && version && vdk) {
|
|
347
|
+
// The headline carries no bare count: what "up to date" covers is the
|
|
348
|
+
// list below it, and a number on its own is the thing this was reported
|
|
349
|
+
// for.
|
|
350
|
+
process.stdout.write(`already up to date — nothing has new keys. Revision stays at ${version.revision}.\n`);
|
|
351
|
+
process.stdout.write(coverage(written, unchanged));
|
|
352
|
+
// A run that had to type the key still earns a slot: the key is in hand,
|
|
353
|
+
// and the next run should not ask again just because this one had nothing
|
|
354
|
+
// to store.
|
|
355
|
+
if (askedForTheKey) {
|
|
356
|
+
await keepSlot(slotAddress(identity, version.vault_id, version.recovery_generation), vdk);
|
|
357
|
+
}
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
179
360
|
const content = serializeContainer(next);
|
|
180
361
|
// The address this machine's slot is kept at, decided by which write
|
|
181
362
|
// happened: an append addresses the vault that already exists, a create
|
|
@@ -196,10 +377,19 @@ export async function cmdVaultPut(config, args) {
|
|
|
196
377
|
vdk = created.vdk;
|
|
197
378
|
address = slotAddress(identity, created.vaultId, created.recoveryGeneration);
|
|
198
379
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
380
|
+
// `; 2 teams in this account's vault` used to end this line. It is the
|
|
381
|
+
// count the walkthrough operator read and could not act on — it never said
|
|
382
|
+
// WHICH two, and a bare number here reads as a statement about the account
|
|
383
|
+
// rather than about what this run saw. The groups below say both.
|
|
384
|
+
process.stdout.write(`stored revision ${revision}${version ? '' : ' (vault created)'}\n`);
|
|
385
|
+
// WHAT WAS COVERED IS WHAT THIS MACHINE REPORTED, and that is what the line
|
|
386
|
+
// says. It used to end at the count above, which reads as "the account is
|
|
387
|
+
// backed up" — a claim wider than the thing that was checked, since the
|
|
388
|
+
// enumeration this ran on can come back short without saying so
|
|
389
|
+
// (agmsg#650). Teams already in the vault are covered by the refusal
|
|
390
|
+
// higher up; a team that has never been in it cannot be seen from here at
|
|
391
|
+
// all, so the sentence stops at the machine rather than the account.
|
|
392
|
+
process.stdout.write(coverage(written, unchanged));
|
|
203
393
|
// After the write, and only when this run had to reach the key. A run the
|
|
204
394
|
// slot already answered has a working slot at this address; saving again
|
|
205
395
|
// would mint a fresh KEK, replace it, and touch the keychain on every
|
package/dist/src/credentials.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
|
-
import { closeSync, constants, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, chmodSync, unlinkSync, writeFileSync, writeSync, } from 'node:fs';
|
|
2
|
+
import { closeSync, constants, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, chmodSync, unlinkSync, writeFileSync, writeSync, } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
/**
|
|
@@ -297,6 +297,48 @@ function writeLocked(path, dir, credential) {
|
|
|
297
297
|
active: key,
|
|
298
298
|
credentials: { ...file.credentials, [key]: credential },
|
|
299
299
|
};
|
|
300
|
+
commit(path, dir, next);
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Remove every credential this origin holds, and say which they were.
|
|
304
|
+
*
|
|
305
|
+
* Same lock and same durable write as a login, for the same reason: two
|
|
306
|
+
* processes that read before locking both write a stale base, and the loser's
|
|
307
|
+
* entry comes back from the dead. Removal has to be as careful as writing —
|
|
308
|
+
* a sign-out that half-worked leaves a secret on disk that its owner believes
|
|
309
|
+
* is gone.
|
|
310
|
+
*
|
|
311
|
+
* Returns what it removed rather than a count. The caller names the machine on
|
|
312
|
+
* screen, and the person signing out is entitled to know which identity just
|
|
313
|
+
* left this machine.
|
|
314
|
+
*/
|
|
315
|
+
export function removeCredentials(origin, env = process.env) {
|
|
316
|
+
const path = credentialsPath(env);
|
|
317
|
+
if (!existsSync(path))
|
|
318
|
+
return [];
|
|
319
|
+
const dir = dirname(path);
|
|
320
|
+
const release = acquireLock(path);
|
|
321
|
+
try {
|
|
322
|
+
const file = readFile(path);
|
|
323
|
+
const doomed = Object.entries(file.credentials).filter(([key]) => key.startsWith(`${origin} `));
|
|
324
|
+
if (doomed.length === 0)
|
|
325
|
+
return [];
|
|
326
|
+
const kept = Object.fromEntries(Object.entries(file.credentials).filter(([key]) => !key.startsWith(`${origin} `)));
|
|
327
|
+
// `active` is a KEY, and the key it names may be one of the ones going. A
|
|
328
|
+
// stale pointer would make `readCredential(null)` answer with a credential
|
|
329
|
+
// that is no longer in the file — null, not the first survivor: which org
|
|
330
|
+
// becomes current is the person's to say, and guessing it here would sign
|
|
331
|
+
// them into an account they did not choose.
|
|
332
|
+
const active = file.active && file.active in kept ? file.active : null;
|
|
333
|
+
commit(path, dir, { version: 1, active, credentials: kept });
|
|
334
|
+
return doomed.map(([, credential]) => credential);
|
|
335
|
+
}
|
|
336
|
+
finally {
|
|
337
|
+
release();
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/** The atomic, durable write both paths share. Caller holds the lock. */
|
|
341
|
+
function commit(path, dir, next) {
|
|
300
342
|
// Unpredictable temp name: a name an attacker can guess is a name they can
|
|
301
343
|
// pre-create as a symlink pointing somewhere else.
|
|
302
344
|
const tmp = `${path}.${randomBytes(8).toString('hex')}.tmp`;
|
package/dist/src/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { cmdConnect, cmdConnectPreflight } from './commands/connect.js';
|
|
|
5
5
|
import { cmdFetch } from './commands/fetch.js';
|
|
6
6
|
import { cmdSync } from './commands/sync.js';
|
|
7
7
|
import { cmdLogin } from './commands/login.js';
|
|
8
|
+
import { cmdLogout, logoutEndpoint } from './commands/logout.js';
|
|
8
9
|
import { cmdPull } from './commands/pull.js';
|
|
9
10
|
import { cmdRequest } from './commands/request.js';
|
|
10
11
|
import { cmdVaultPut, cmdVaultRestore } from './commands/vault.js';
|
|
@@ -16,6 +17,9 @@ const USAGE = `agmsg-cloud — hosted agmsg from this machine
|
|
|
16
17
|
[--endpoint <url>] a self-hosted or development stack (default: the hosted service)
|
|
17
18
|
[--machine-name <n>] the name this machine is registered under (default: hostname)
|
|
18
19
|
|
|
20
|
+
logout remove this machine's stored credential for the service
|
|
21
|
+
[--endpoint <url>] the same address login used (default: the hosted service)
|
|
22
|
+
|
|
19
23
|
connect <team> put a team this machine already runs onto the hosted service
|
|
20
24
|
connect --preflight check what connect needs, without connecting anything
|
|
21
25
|
|
|
@@ -28,15 +32,21 @@ const USAGE = `agmsg-cloud — hosted agmsg from this machine
|
|
|
28
32
|
it is matched against the snapshot your code authenticated
|
|
29
33
|
approve <team> [request-id] (on a key-holding machine) approve the waiting request after the codes match
|
|
30
34
|
watch [--interval-ms <n>] (on a key-holding machine) print pending requests as they arrive
|
|
31
|
-
recovery setup
|
|
35
|
+
recovery setup [team] create this account's recovery key and back up every active
|
|
36
|
+
team this machine's store reports; name one to back up
|
|
37
|
+
only that team
|
|
32
38
|
recovery restore <team> (on any approved machine) open the backup and unlock <team>
|
|
33
39
|
version the version of this CLI (also --version, -v)
|
|
34
40
|
the OSS scripts it drives are reported by
|
|
35
41
|
\`connect --preflight\`, which is a separate answer
|
|
36
42
|
|
|
37
43
|
The recovery key is generated by \`recovery setup\` on the first run, shown once,
|
|
38
|
-
and never stored anywhere.
|
|
39
|
-
|
|
44
|
+
and never stored anywhere. There is ONE for the account, not one per team — which
|
|
45
|
+
is why it is not something to repeat each time a team is added. What one run
|
|
46
|
+
covers is every active team THIS MACHINE's store reports, and it names them when
|
|
47
|
+
it finishes; a team connected only on another machine is backed up by running it
|
|
48
|
+
there. It is the only thing that opens the backup — if it is lost, so is the
|
|
49
|
+
backup. After the first run, a machine with an OS secure store
|
|
40
50
|
keeps a key slot and does not ask for it again; a machine without one asks every
|
|
41
51
|
time, and says so. It can only be typed at a terminal: there is no flag and no
|
|
42
52
|
environment variable for it.
|
|
@@ -101,6 +111,16 @@ async function main(argv) {
|
|
|
101
111
|
...(machineName === undefined ? {} : { machineName }),
|
|
102
112
|
});
|
|
103
113
|
}
|
|
114
|
+
case 'logout': {
|
|
115
|
+
// Local only, and its own subcommand rather than a flag on `login`: the
|
|
116
|
+
// two do opposite things, and a flag that inverts a command is how
|
|
117
|
+
// someone signs out while meaning to sign in.
|
|
118
|
+
// Closed argv, not a filtered read: this removes a secret and defaults
|
|
119
|
+
// to the hosted service, so an argument it does not understand must not
|
|
120
|
+
// become "the default one".
|
|
121
|
+
const endpoint = logoutEndpoint(rest);
|
|
122
|
+
return cmdLogout(endpoint === undefined ? {} : { endpoint });
|
|
123
|
+
}
|
|
104
124
|
case 'connect': {
|
|
105
125
|
// The dry run resolves no credential: its job is to be usable before
|
|
106
126
|
// anything is set up.
|
|
@@ -191,14 +211,26 @@ async function main(argv) {
|
|
|
191
211
|
case 'recovery': {
|
|
192
212
|
const [sub, team] = rest;
|
|
193
213
|
if (sub !== 'setup' && sub !== 'restore') {
|
|
194
|
-
|
|
214
|
+
// The two take different arguments, so one line covering both said the
|
|
215
|
+
// wrong thing about each: `setup` does not need a team and `restore`
|
|
216
|
+
// does. A usage message is read as the contract (raised in review).
|
|
217
|
+
throw new Error('usage: agmsg-cloud recovery setup [team]\n' +
|
|
218
|
+
' agmsg-cloud recovery restore <team>');
|
|
195
219
|
}
|
|
220
|
+
// `setup` takes no team in its ordinary form: the vault is the ACCOUNT's,
|
|
221
|
+
// so setting it up covers every connected team, and naming one made it a
|
|
222
|
+
// thing to repeat each time a team was added. A team may still be given,
|
|
223
|
+
// to add that one on its own. `restore` still requires one — it unlocks a
|
|
224
|
+
// specific team on this machine.
|
|
225
|
+
//
|
|
196
226
|
// The excuse rides inside the slot, so it cannot apply to anything else.
|
|
197
|
-
if (!team) {
|
|
227
|
+
if (sub === 'restore' && !team) {
|
|
198
228
|
throw new Error(`usage: agmsg-cloud recovery ${ /* printed-commands: a subcommand name this file chooses, narrowed to 'setup' | 'restore' two lines up — not a value anyone supplies, and quoting it would print `recovery 'setup'` */sub} <team>`);
|
|
199
229
|
}
|
|
200
230
|
const config = loadConfig();
|
|
201
|
-
return sub === 'setup'
|
|
231
|
+
return sub === 'setup'
|
|
232
|
+
? cmdVaultPut(config, team === undefined ? {} : { team })
|
|
233
|
+
: cmdVaultRestore(config, { team: team });
|
|
202
234
|
}
|
|
203
235
|
default:
|
|
204
236
|
throw new Error(`unknown command: ${cmd}\n\n${USAGE}`);
|
package/dist/src/oss.js
CHANGED
|
@@ -28,6 +28,52 @@ function run(cmd, args, input) {
|
|
|
28
28
|
child.stdin.end();
|
|
29
29
|
});
|
|
30
30
|
}
|
|
31
|
+
/** A finished run, whatever its exit code. For callers where non-zero is an ANSWER. */
|
|
32
|
+
function runAllowingFailure(cmd, args) {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
const child = spawnOssPiped(cmd, args);
|
|
35
|
+
const out = [];
|
|
36
|
+
const err = [];
|
|
37
|
+
child.stdout.on('data', (d) => out.push(d));
|
|
38
|
+
child.stderr.on('data', (d) => err.push(d));
|
|
39
|
+
child.on('error', reject);
|
|
40
|
+
child.on('close', (code) => resolve({
|
|
41
|
+
code: code ?? -1,
|
|
42
|
+
stdout: Buffer.concat(out).toString(),
|
|
43
|
+
stderr: Buffer.concat(err).toString().trim(),
|
|
44
|
+
}));
|
|
45
|
+
child.stdin.end();
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Whether this machine has a team by this name — the question `unlock` asks.
|
|
50
|
+
*
|
|
51
|
+
* NOT `remoteBinding`, and the difference was measured rather than assumed.
|
|
52
|
+
* `remoteBinding` additionally requires the binding to be ACTIVE, and against
|
|
53
|
+
* the real scripts:
|
|
54
|
+
*
|
|
55
|
+
* team status <team> --json unlock <team>
|
|
56
|
+
* connected exit 0, state active past the team check
|
|
57
|
+
* DISCONNECTED exit 0, state disconnected past the team check
|
|
58
|
+
* never existed exit 1 "agmsg: team not found"
|
|
59
|
+
*
|
|
60
|
+
* So a disconnected team is one `unlock` accepts and `remoteBinding` refuses.
|
|
61
|
+
* A caller that used the stricter check to decide whether a name is known would
|
|
62
|
+
* turn a working command into a refusal — which is the shape of defect this arc
|
|
63
|
+
* has already paid for twice. The exit code is the answer; the state is not.
|
|
64
|
+
*
|
|
65
|
+
* A non-zero exit is reported WITH what the store said, so a broken script does
|
|
66
|
+
* not get presented to the operator as a mistyped name.
|
|
67
|
+
*/
|
|
68
|
+
export async function localTeamLookup(scriptsDir, team) {
|
|
69
|
+
const r = await runAllowingFailure('bash', [
|
|
70
|
+
join(scriptsDir, 'remote.sh'),
|
|
71
|
+
'status',
|
|
72
|
+
team,
|
|
73
|
+
'--json',
|
|
74
|
+
]);
|
|
75
|
+
return r.code === 0 ? { known: true, said: '' } : { known: false, said: r.stderr };
|
|
76
|
+
}
|
|
31
77
|
// Generate a device age identity at `identityPath`; returns its public recipient.
|
|
32
78
|
export async function generateDeviceIdentity(identityPath) {
|
|
33
79
|
await run('age-keygen', ['-o', identityPath]);
|
|
@@ -126,6 +172,49 @@ export async function remoteBinding(scriptsDir, team) {
|
|
|
126
172
|
}
|
|
127
173
|
return { teamId: status.remote_team_id, serverInstanceId: status.server_instance_id };
|
|
128
174
|
}
|
|
175
|
+
export async function connectedTeams(scriptsDir) {
|
|
176
|
+
const out = await run('bash', [join(scriptsDir, 'remote.sh'), 'status', '--json']);
|
|
177
|
+
const teams = [];
|
|
178
|
+
for (const line of out.toString().split('\n')) {
|
|
179
|
+
const text = line.trim();
|
|
180
|
+
if (text === '')
|
|
181
|
+
continue;
|
|
182
|
+
let status;
|
|
183
|
+
try {
|
|
184
|
+
status = JSON.parse(text);
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
// One unreadable line must not decide the whole answer either way:
|
|
188
|
+
// skipping it silently would file a backup that quietly omits a team, so
|
|
189
|
+
// it is refused instead. A status output this client cannot parse is not
|
|
190
|
+
// a shorter list of teams.
|
|
191
|
+
// Worded so it does not read as a command to paste: it quotes DATA, and
|
|
192
|
+
// the checker is right that an interpolation inside something
|
|
193
|
+
// command-shaped is worth refusing on sight.
|
|
194
|
+
throw new Error(`the team status this machine printed contains a line this version cannot read: ${JSON.stringify(text.slice(0, 80))}`);
|
|
195
|
+
}
|
|
196
|
+
// A state this version does not know is not quietly dropped and not quietly
|
|
197
|
+
// treated as filable: an unknown word here is the same shape as a line that
|
|
198
|
+
// did not parse, and it gets the same refusal.
|
|
199
|
+
if (status.state !== 'active' && status.state !== 'disconnected') {
|
|
200
|
+
throw new Error(`the team status this machine printed describes a state this version cannot read: ${JSON.stringify(String(status.state).slice(0, 40))}`);
|
|
201
|
+
}
|
|
202
|
+
if (typeof status.local_team !== 'string' ||
|
|
203
|
+
typeof status.remote_team_id !== 'string' ||
|
|
204
|
+
!status.remote_team_id ||
|
|
205
|
+
typeof status.server_instance_id !== 'string' ||
|
|
206
|
+
!status.server_instance_id) {
|
|
207
|
+
throw new Error(`remote.sh status --json described a team without the ids a vault entry needs`);
|
|
208
|
+
}
|
|
209
|
+
teams.push({
|
|
210
|
+
team: status.local_team,
|
|
211
|
+
teamId: status.remote_team_id,
|
|
212
|
+
serverInstanceId: status.server_instance_id,
|
|
213
|
+
state: status.state,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
return teams;
|
|
217
|
+
}
|
|
129
218
|
export async function remoteTeamId(scriptsDir, team) {
|
|
130
219
|
const out = await run('bash', [join(scriptsDir, 'remote.sh'), 'status', team, '--json']);
|
|
131
220
|
const text = out.toString().trim();
|
package/dist/src/pending.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { chmodSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { deriveApproverCommitment, isCanonicalAgeRecipient } from '@agmsg-cloud/sas-core';
|
|
@@ -296,3 +296,35 @@ export function listResumableRequesterRecords(serverOrigin, env = process.env) {
|
|
|
296
296
|
export function clearRecord(input, env = process.env) {
|
|
297
297
|
rmSync(recordPath(pendingDir(env), input.role, input.serverOrigin, input.key), { force: true });
|
|
298
298
|
}
|
|
299
|
+
/**
|
|
300
|
+
* Drop every pending record for one server, and say how many.
|
|
301
|
+
*
|
|
302
|
+
* For sign-out: a ceremony half-started against a service this machine is
|
|
303
|
+
* leaving has nothing left to complete, and its nonce is the opening of a
|
|
304
|
+
* commitment nobody will ever ask about again.
|
|
305
|
+
*
|
|
306
|
+
* Selected by the origin TAG that is already part of every filename, so this
|
|
307
|
+
* cannot drift from the naming above the way a second list of origins would.
|
|
308
|
+
* Records for other servers are untouched: signing out of one deployment is
|
|
309
|
+
* not signing out of the others.
|
|
310
|
+
*/
|
|
311
|
+
export function clearRecordsForOrigin(serverOrigin, env = process.env) {
|
|
312
|
+
const dir = pendingDir(env);
|
|
313
|
+
if (!existsSync(dir))
|
|
314
|
+
return 0;
|
|
315
|
+
const tag = originTag(serverOrigin);
|
|
316
|
+
let removed = 0;
|
|
317
|
+
for (const name of readdirSync(dir)) {
|
|
318
|
+
if (!name.endsWith('.json'))
|
|
319
|
+
continue;
|
|
320
|
+
// `<role>-<tag>-<key>.json`. Matching the tag as its own dash-delimited
|
|
321
|
+
// field, not as a substring: a key that happened to contain the tag would
|
|
322
|
+
// otherwise take another server's record with it.
|
|
323
|
+
const parts = name.slice(0, -'.json'.length).split('-');
|
|
324
|
+
if (parts[1] !== tag)
|
|
325
|
+
continue;
|
|
326
|
+
rmSync(join(dir, name), { force: true });
|
|
327
|
+
removed += 1;
|
|
328
|
+
}
|
|
329
|
+
return removed;
|
|
330
|
+
}
|
package/dist/src/recovery-key.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomInt } from 'node:crypto';
|
|
2
|
-
import { shellArg } from './
|
|
2
|
+
import { shellArg } from './shell-arg.js';
|
|
3
3
|
// The recovery key (K7) is the only thing that opens the vault. It is generated
|
|
4
4
|
// here, shown once, and never stored — not by us and not by the server. Losing it
|
|
5
5
|
// means the vault is unopenable, which is the property that makes the vault worth
|
|
@@ -289,12 +289,22 @@ export function promptRecoveryKey(prompt) {
|
|
|
289
289
|
// a politer form: what is printed here is meant to be pasted, so it has to be
|
|
290
290
|
// the command that runs, for the team this failure happened on, including
|
|
291
291
|
// when that name is not one shell word.
|
|
292
|
+
// The command to re-run. The quoting sits ON the interpolating line, which is
|
|
293
|
+
// what the printed-command checker reads — a ternary hid it, and so did binding
|
|
294
|
+
// the result to a variable first. Both were still quoted; neither was visible.
|
|
295
|
+
// The checker is right to demand the stricter form: what it can see is what
|
|
296
|
+
// survives the next edit.
|
|
297
|
+
function setupCommand(team) {
|
|
298
|
+
if (team === undefined)
|
|
299
|
+
return 'agmsg-cloud recovery setup';
|
|
300
|
+
return `agmsg-cloud recovery setup ${shellArg(team)}`;
|
|
301
|
+
}
|
|
292
302
|
export class NeedsTerminalError extends Error {
|
|
293
303
|
constructor(team) {
|
|
294
304
|
super('this needs a terminal, and stdin is not one.\n\n' +
|
|
295
305
|
'Nothing was created and no recovery key exists yet. Run this yourself,\n' +
|
|
296
306
|
'in your own terminal:\n\n' +
|
|
297
|
-
`
|
|
307
|
+
` ${setupCommand(team)}\n\n` +
|
|
298
308
|
'It is refused here on purpose: the recovery key is shown once and stored\n' +
|
|
299
309
|
'nowhere, so writing it into a redirected or captured stream would be this\n' +
|
|
300
310
|
'tool breaking that promise itself.');
|
package/package.json
CHANGED