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,386 @@
|
|
|
1
|
+
import { randomInt } from 'node:crypto';
|
|
2
|
+
import { shellArg } from './shell-arg.js';
|
|
3
|
+
// The recovery key (K7) is the only thing that opens the vault. It is generated
|
|
4
|
+
// here, shown once, and never stored — not by us and not by the server. Losing it
|
|
5
|
+
// means the vault is unopenable, which is the property that makes the vault worth
|
|
6
|
+
// having; the cost is that the display and the read-back below are the entire
|
|
7
|
+
// safety net for the user.
|
|
8
|
+
// Crockford-ish: no I/L/O/U, so a transcribed key cannot be ruined by 1-vs-l or
|
|
9
|
+
// 0-vs-O. 32 symbols = 5 bits each.
|
|
10
|
+
const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
11
|
+
// The shape the design pins (recovery-vault-v1.md, recovery-key-v1): 24 symbols
|
|
12
|
+
// of secret — 120 bits — plus one check symbol, shown as five groups of five
|
|
13
|
+
// behind an AGMSG- prefix.
|
|
14
|
+
//
|
|
15
|
+
// The prefix is not decoration. It is how someone finds this string again on a
|
|
16
|
+
// piece of paper months later and knows what it opens; a bare block of base32
|
|
17
|
+
// is indistinguishable from every other token they have written down.
|
|
18
|
+
const PREFIX = 'AGMSG-';
|
|
19
|
+
const PREFIX_SYMBOLS = 'AGMSG'.length;
|
|
20
|
+
const SECRET_SYMBOLS = 24;
|
|
21
|
+
const TOTAL_SYMBOLS = SECRET_SYMBOLS + 1;
|
|
22
|
+
const GROUP = 5;
|
|
23
|
+
// The check symbol, 5 bits, over the 24 secret symbols.
|
|
24
|
+
//
|
|
25
|
+
// The design delegates "check-digit bit selection and domain" to an OSS pin
|
|
26
|
+
// that does not exist yet (searched: no recovery-key spec in the vendored
|
|
27
|
+
// tree). So this is the cloud side choosing first, and the choice is written
|
|
28
|
+
// down here rather than left in the arithmetic:
|
|
29
|
+
//
|
|
30
|
+
// sum of (value * (position + 1)) over the 24 symbols, mod 32
|
|
31
|
+
//
|
|
32
|
+
// The weights are ODD, which is the whole reason the scheme works mod 32.
|
|
33
|
+
// An even weight shares a factor with 32, so a substitution can move the sum
|
|
34
|
+
// by a multiple of 32 and leave the check symbol unchanged: with weight 2, a
|
|
35
|
+
// value difference of 16 is invisible. Odd weights are invertible mod 32, so
|
|
36
|
+
// weight*delta is zero only when delta is, and EVERY single-symbol
|
|
37
|
+
// substitution changes the result.
|
|
38
|
+
//
|
|
39
|
+
// Adjacent transposition is the weaker case, not the stronger one: swapping
|
|
40
|
+
// neighbours moves the sum by 2*(a-b) mod 32, which vanishes when the two
|
|
41
|
+
// symbols differ by exactly 16. So: all single substitutions, and all
|
|
42
|
+
// transpositions except that one class.
|
|
43
|
+
//
|
|
44
|
+
// A prime modulus (Crockford's own check uses 37) would catch both outright,
|
|
45
|
+
// and is what a future pin should probably use. It cannot be spelled in five
|
|
46
|
+
// bits, which is what the design asked for. Changing this later invalidates
|
|
47
|
+
// keys people have already written down.
|
|
48
|
+
function checkSymbol(secret) {
|
|
49
|
+
let sum = 0;
|
|
50
|
+
for (let i = 0; i < secret.length; i++) {
|
|
51
|
+
sum += ALPHABET.indexOf(secret[i]) * (2 * i + 1);
|
|
52
|
+
}
|
|
53
|
+
return ALPHABET[sum % ALPHABET.length];
|
|
54
|
+
}
|
|
55
|
+
/** The canonical display form: AGMSG-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX. */
|
|
56
|
+
export function formatRecoveryKey(symbols) {
|
|
57
|
+
return PREFIX + (symbols.match(new RegExp(`.{1,${GROUP}}`, 'g')) ?? []).join('-');
|
|
58
|
+
}
|
|
59
|
+
export function generateRecoveryKey() {
|
|
60
|
+
let secret = '';
|
|
61
|
+
for (let i = 0; i < SECRET_SYMBOLS; i++) {
|
|
62
|
+
// randomInt is rejection-sampled: `randomBytes[i] % 32` would be unbiased at
|
|
63
|
+
// 32 but silently biased the moment someone edits the alphabet's length.
|
|
64
|
+
secret += ALPHABET[randomInt(ALPHABET.length)];
|
|
65
|
+
}
|
|
66
|
+
return formatRecoveryKey(secret + checkSymbol(secret));
|
|
67
|
+
}
|
|
68
|
+
// Accept what a human retypes: any case, any grouping, and the four excluded
|
|
69
|
+
// letters mapped to the digits they get mistaken for. Everything else is an
|
|
70
|
+
// error rather than a silent drop, so a wrong key fails as a wrong key and not
|
|
71
|
+
// as a mangled one.
|
|
72
|
+
export function normalizeRecoveryKey(input) {
|
|
73
|
+
let cleaned = input
|
|
74
|
+
.trim()
|
|
75
|
+
.toUpperCase()
|
|
76
|
+
.replace(/[\s-]/g, '');
|
|
77
|
+
// The prefix is a label, not key material, and it is stripped by LENGTH
|
|
78
|
+
// rather than by the letters. Every character of AGMSG is also a legal
|
|
79
|
+
// symbol, so a perfectly good key can begin with those five: stripping on
|
|
80
|
+
// sight turns that person's 25 symbols into 20 and rejects the key they
|
|
81
|
+
// hold. One in 32^5 keys starts that way — rare enough never to be noticed
|
|
82
|
+
// in testing, and permanent for whoever draws it.
|
|
83
|
+
//
|
|
84
|
+
// So: PREFIX + body is 30 and gets the prefix removed; a bare body is 25 and
|
|
85
|
+
// is kept whole. Anything else falls through to the length check below,
|
|
86
|
+
// which says what was wrong.
|
|
87
|
+
if (cleaned.length === PREFIX_SYMBOLS + TOTAL_SYMBOLS && cleaned.startsWith('AGMSG')) {
|
|
88
|
+
cleaned = cleaned.slice(PREFIX_SYMBOLS);
|
|
89
|
+
}
|
|
90
|
+
cleaned = cleaned
|
|
91
|
+
.replace(/[IL]/g, '1')
|
|
92
|
+
.replace(/O/g, '0')
|
|
93
|
+
.replace(/U/g, 'V');
|
|
94
|
+
for (const ch of cleaned) {
|
|
95
|
+
if (!ALPHABET.includes(ch))
|
|
96
|
+
throw new Error(`recovery key contains an unusable character: ${ch}`);
|
|
97
|
+
}
|
|
98
|
+
if (cleaned.length !== TOTAL_SYMBOLS) {
|
|
99
|
+
throw new Error(`recovery key must be ${TOTAL_SYMBOLS} symbols, got ${cleaned.length}`);
|
|
100
|
+
}
|
|
101
|
+
// The check symbol, verified here rather than at the vault. Without it, a
|
|
102
|
+
// mistyped key is indistinguishable from a wrong one until the AEAD refuses
|
|
103
|
+
// it — which is at restore time, the moment there is no way back. Naming the
|
|
104
|
+
// typo at the prompt is the whole reason the design could withdraw the
|
|
105
|
+
// type-it-back-to-confirm step.
|
|
106
|
+
const secret = cleaned.slice(0, SECRET_SYMBOLS);
|
|
107
|
+
if (cleaned[SECRET_SYMBOLS] !== checkSymbol(secret)) {
|
|
108
|
+
throw new Error('that recovery key has a typo in it: the check symbol does not match. ' +
|
|
109
|
+
'Nothing was sent. Compare it with what you wrote down and try again.');
|
|
110
|
+
}
|
|
111
|
+
return cleaned;
|
|
112
|
+
}
|
|
113
|
+
// Read a secret from the terminal without ever writing it to an output stream.
|
|
114
|
+
//
|
|
115
|
+
// The obvious implementation — let readline echo, then erase the line — does not
|
|
116
|
+
// work, and looked like it did. readline writes the character to stdout first;
|
|
117
|
+
// the erase only repaints the screen. Anything recording that stream (a PTY
|
|
118
|
+
// transcript, `script`, a terminal logger, CI capture) already has the key bytes.
|
|
119
|
+
// "It disappears from the display" is not "it was never emitted".
|
|
120
|
+
//
|
|
121
|
+
// So: raw mode, read the key events ourselves, and echo nothing. Deliberately
|
|
122
|
+
// narrow otherwise — there is no argv flag and no environment variable for the
|
|
123
|
+
// recovery key, because both would put it in shell history and in every process
|
|
124
|
+
// listing on the machine.
|
|
125
|
+
//
|
|
126
|
+
// Raw mode is process-EXTERNAL state: it belongs to the terminal and outlives us.
|
|
127
|
+
// So the restore is bound to every way out, not to the successful one. A `finally`
|
|
128
|
+
// covers returning and throwing; it does not cover being killed, and it does not
|
|
129
|
+
// cover a stream that ends while we wait. Leaving a shell with no echo and no line
|
|
130
|
+
// editing is its own kind of damage — and having written restore code is not
|
|
131
|
+
// evidence that it runs.
|
|
132
|
+
const PROMPT_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
133
|
+
const CTRL_C = '\x03';
|
|
134
|
+
const DEL = '\x7f';
|
|
135
|
+
// What stands in for a typed symbol. A fixed character, so the width of the
|
|
136
|
+
// row is the number of symbols entered and nothing about their values.
|
|
137
|
+
//
|
|
138
|
+
// ASCII on purpose. A bullet renders as two bytes that `cat -v` shows as
|
|
139
|
+
// M-^@M-^" and that a non-UTF-8 terminal shows as mojibake — and this row is
|
|
140
|
+
// the only feedback someone gets while typing a key they cannot see.
|
|
141
|
+
const MASK = '*';
|
|
142
|
+
// One raw-mode session at the terminal, with every way out restored.
|
|
143
|
+
//
|
|
144
|
+
// Extracted because a second input path (the save acknowledgement) reimplemented
|
|
145
|
+
// it and lost half: no signal handling, no end/close/error, and on those paths
|
|
146
|
+
// the promise never settled and the shell was left in raw mode. A third input
|
|
147
|
+
// path would have lost a different half. There is one of these now, and both
|
|
148
|
+
// callers get whatever it knows.
|
|
149
|
+
//
|
|
150
|
+
// `onChunk` decides when the session is over: call `done()` to finish, or
|
|
151
|
+
// `stop(err)` to fail. Everything else — listeners, signal handlers, raw mode,
|
|
152
|
+
// pause — is unwound exactly once, on every exit including the ones nobody
|
|
153
|
+
// writes tests for.
|
|
154
|
+
function withRawStdin(prompt, onChunk) {
|
|
155
|
+
const stdin = process.stdin;
|
|
156
|
+
if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
|
|
157
|
+
return Promise.reject(new Error('this can only be answered at a terminal (stdin is not a TTY)'));
|
|
158
|
+
}
|
|
159
|
+
const wasRaw = stdin.isRaw === true;
|
|
160
|
+
return new Promise((resolve, reject) => {
|
|
161
|
+
let settled = false;
|
|
162
|
+
// Idempotent, and the only place that undoes anything. The signal handlers
|
|
163
|
+
// go too: registered per session, they would otherwise accumulate on the
|
|
164
|
+
// process across successive prompts.
|
|
165
|
+
const restore = () => {
|
|
166
|
+
if (settled)
|
|
167
|
+
return;
|
|
168
|
+
settled = true;
|
|
169
|
+
stdin.off('data', onData);
|
|
170
|
+
stdin.off('end', onEnd);
|
|
171
|
+
stdin.off('close', onEnd);
|
|
172
|
+
stdin.off('error', onError);
|
|
173
|
+
for (const [sig, handler] of signalHandlers)
|
|
174
|
+
process.off(sig, handler);
|
|
175
|
+
try {
|
|
176
|
+
stdin.setRawMode(wasRaw);
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
// The stream can already be gone on close/error; nothing left to restore.
|
|
180
|
+
}
|
|
181
|
+
stdin.pause();
|
|
182
|
+
};
|
|
183
|
+
const done = (value) => {
|
|
184
|
+
restore();
|
|
185
|
+
resolve(value);
|
|
186
|
+
};
|
|
187
|
+
const stop = (err) => {
|
|
188
|
+
restore();
|
|
189
|
+
reject(err);
|
|
190
|
+
};
|
|
191
|
+
const onData = (chunk) => onChunk(chunk, done, stop);
|
|
192
|
+
const onEnd = () => stop(new Error('input ended before the terminal answered'));
|
|
193
|
+
const onError = (err) => stop(err instanceof Error ? err : new Error(`stdin failed: ${String(err)}`));
|
|
194
|
+
// Restore first, then let the signal do what it was sent to do. Swallowing
|
|
195
|
+
// it would turn "stop this process" into "this process ignores you".
|
|
196
|
+
const signalHandlers = PROMPT_SIGNALS.map((sig) => [
|
|
197
|
+
sig,
|
|
198
|
+
() => {
|
|
199
|
+
stop(new Error(`interrupted by ${sig}`));
|
|
200
|
+
process.kill(process.pid, sig);
|
|
201
|
+
},
|
|
202
|
+
]);
|
|
203
|
+
try {
|
|
204
|
+
stdin.setRawMode(true);
|
|
205
|
+
stdin.resume();
|
|
206
|
+
stdin.setEncoding('utf8');
|
|
207
|
+
stdin.on('data', onData);
|
|
208
|
+
stdin.on('end', onEnd);
|
|
209
|
+
stdin.on('close', onEnd);
|
|
210
|
+
stdin.on('error', onError);
|
|
211
|
+
for (const [sig, handler] of signalHandlers)
|
|
212
|
+
process.on(sig, handler);
|
|
213
|
+
process.stdout.write(prompt);
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
// setRawMode/resume/setEncoding can throw. They sit inside the guarded
|
|
217
|
+
// region precisely so that failing halfway still unwinds what was done.
|
|
218
|
+
stop(err instanceof Error ? err : new Error(String(err)));
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
export function promptRecoveryKey(prompt) {
|
|
223
|
+
let entered = '';
|
|
224
|
+
return withRawStdin(prompt, (chunk, done, stop) => {
|
|
225
|
+
for (const ch of chunk) {
|
|
226
|
+
switch (ch) {
|
|
227
|
+
case '\r':
|
|
228
|
+
case '\n':
|
|
229
|
+
process.stdout.write('\n');
|
|
230
|
+
done(entered);
|
|
231
|
+
return;
|
|
232
|
+
case CTRL_C: // In raw mode this arrives as a byte, not as a signal.
|
|
233
|
+
process.stdout.write('\n');
|
|
234
|
+
stop(new Error('cancelled'));
|
|
235
|
+
return;
|
|
236
|
+
case DEL: // Backspace.
|
|
237
|
+
case '\b':
|
|
238
|
+
// Erase one mask character too, or the row stops matching what is in
|
|
239
|
+
// the buffer: someone correcting a typo would watch the line keep
|
|
240
|
+
// growing and have no idea how far back they had reached.
|
|
241
|
+
if (entered.length > 0)
|
|
242
|
+
process.stdout.write('\b \b');
|
|
243
|
+
entered = entered.slice(0, -1);
|
|
244
|
+
break;
|
|
245
|
+
default:
|
|
246
|
+
// Ignore other control characters rather than storing them: they are
|
|
247
|
+
// never part of a recovery key and would fail normalization later
|
|
248
|
+
// with a confusing message.
|
|
249
|
+
if (ch >= ' ') {
|
|
250
|
+
entered += ch;
|
|
251
|
+
// One mask character per accepted symbol — not the symbol itself,
|
|
252
|
+
// which is the property this prompt exists for. Typing 25
|
|
253
|
+
// characters into a line that never moves is indistinguishable
|
|
254
|
+
// from a hung terminal, and the reflex it teaches is to paste.
|
|
255
|
+
process.stdout.write(MASK);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
// Show the key once, before anything is stored.
|
|
262
|
+
//
|
|
263
|
+
// There is no type-it-back step: the design withdrew it (recovery-vault-v1.md,
|
|
264
|
+
// recovery-key-v1, "no re-entry confirmation") once the key carried a check
|
|
265
|
+
// symbol. Retyping 25 characters with no error detection is what taught people
|
|
266
|
+
// to paste, and paste is the one thing this key must never touch. The check
|
|
267
|
+
// symbol names a typo at the prompt instead.
|
|
268
|
+
//
|
|
269
|
+
// There IS an acknowledgement keypress, and the ordering is what makes it
|
|
270
|
+
// mean something: display -> save ack -> vault create. Cancelling at the ack
|
|
271
|
+
// leaves no vault, because nothing has been created yet.
|
|
272
|
+
//
|
|
273
|
+
// Showing after the upload instead would break both halves. It creates a
|
|
274
|
+
// failure this order does not have — a crash between storing the vault and
|
|
275
|
+
// reaching the screen leaves a vault encrypted under a key nobody has ever
|
|
276
|
+
// seen — and it makes the ack a lie, since the thing it offers to cancel
|
|
277
|
+
// already exists. A key on paper for a vault that was never created is fixed
|
|
278
|
+
// by discarding the paper; neither of those is.
|
|
279
|
+
//
|
|
280
|
+
// Showing last becomes safe when the device-local slot exists, because the key
|
|
281
|
+
// can then be re-derived from material this machine holds. It does not exist
|
|
282
|
+
// yet, so the caller shows first and says so if the backup then fails.
|
|
283
|
+
//
|
|
284
|
+
// The command it prints is the REAL one, built from the team this run was
|
|
285
|
+
// given and quoted through `shellArg` (raised in review). It used to read
|
|
286
|
+
// `agmsg-cloud recovery setup` with no argument, which the dispatch refuses —
|
|
287
|
+
// so the one line telling someone what to do next was a line that could only
|
|
288
|
+
// produce a usage error. A `<team>` placeholder would have the same defect in
|
|
289
|
+
// a politer form: what is printed here is meant to be pasted, so it has to be
|
|
290
|
+
// the command that runs, for the team this failure happened on, including
|
|
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
|
+
}
|
|
302
|
+
export class NeedsTerminalError extends Error {
|
|
303
|
+
constructor(team) {
|
|
304
|
+
super('this needs a terminal, and stdin is not one.\n\n' +
|
|
305
|
+
'Nothing was created and no recovery key exists yet. Run this yourself,\n' +
|
|
306
|
+
'in your own terminal:\n\n' +
|
|
307
|
+
` ${setupCommand(team)}\n\n` +
|
|
308
|
+
'It is refused here on purpose: the recovery key is shown once and stored\n' +
|
|
309
|
+
'nowhere, so writing it into a redirected or captured stream would be this\n' +
|
|
310
|
+
'tool breaking that promise itself.');
|
|
311
|
+
this.name = 'NeedsTerminalError';
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* The words shown with the key, as a value rather than as a side effect.
|
|
316
|
+
*
|
|
317
|
+
* Split out because the words ARE the artifact here, and they could not be
|
|
318
|
+
* checked: this screen claimed the machine would be asked for the key again on
|
|
319
|
+
* the next backup, the closing line of the same command claimed it would not,
|
|
320
|
+
* and nothing failed. The most important screen in the product contradicted
|
|
321
|
+
* itself and no test could see it.
|
|
322
|
+
*/
|
|
323
|
+
export function recoveryKeyNotice(key) {
|
|
324
|
+
return ('\n' +
|
|
325
|
+
'RECOVERY KEY - save this now. This is the only time it is shown.\n' +
|
|
326
|
+
'Your vault is created right after this, using this key.\n' +
|
|
327
|
+
'\n' +
|
|
328
|
+
` ${key}\n` +
|
|
329
|
+
'\n' +
|
|
330
|
+
// Says nothing about whether this machine will ask again, deliberately.
|
|
331
|
+
//
|
|
332
|
+
// It cannot: this runs BEFORE the vault exists and before a device key
|
|
333
|
+
// slot is attempted, and whether one can be kept depends on the machine —
|
|
334
|
+
// there is no secure store on Windows or Linux, and a locked keychain
|
|
335
|
+
// refuses on macOS. The command reports what actually happened once it
|
|
336
|
+
// knows, in one place (`keepSlot`). A screen that has to be read once and
|
|
337
|
+
// acted on immediately is the last place to put a guess about the future.
|
|
338
|
+
'Put it in your password manager, or print it and keep it where you keep\n' +
|
|
339
|
+
'a passport. It is the only thing that opens your personal recovery\n' +
|
|
340
|
+
'vault, from this machine or any other. Nobody can reissue it: not this\n' +
|
|
341
|
+
'machine, not the server, not support.\n' +
|
|
342
|
+
'\n');
|
|
343
|
+
}
|
|
344
|
+
export async function showRecoveryKey(key, team) {
|
|
345
|
+
// Checked BEFORE the key reaches stdout, which is the whole point. Showing
|
|
346
|
+
// first and then discovering there is nobody to acknowledge means the only
|
|
347
|
+
// copy of the key is already in whatever the output was redirected to —
|
|
348
|
+
// `recovery setup <team> > log.txt` would write it to a file and report
|
|
349
|
+
// success, which is the opposite of "shown once, stored nowhere".
|
|
350
|
+
//
|
|
351
|
+
// Nothing has been created at this point, so refusing here leaves no vault
|
|
352
|
+
// and no key in use. The message says what to run instead.
|
|
353
|
+
if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== 'function') {
|
|
354
|
+
throw new NeedsTerminalError(team);
|
|
355
|
+
}
|
|
356
|
+
process.stdout.write(recoveryKeyNotice(key));
|
|
357
|
+
await confirmSaved();
|
|
358
|
+
}
|
|
359
|
+
// The one thing left of the withdrawn confirmation step: a keypress, not a
|
|
360
|
+
// transcription.
|
|
361
|
+
//
|
|
362
|
+
// It is load-bearing only because it happens BEFORE the vault is created.
|
|
363
|
+
// Cancelling here leaves no vault, so a person who is not ready to save the
|
|
364
|
+
// key can stop and nothing exists that needs it. Move the display after the
|
|
365
|
+
// upload and this button stops meaning anything — the vault is already there,
|
|
366
|
+
// and "cancel" would be a lie.
|
|
367
|
+
//
|
|
368
|
+
// Ctrl-C is the cancel. It throws rather than returning a flag, because every
|
|
369
|
+
// caller's correct response is to stop, and a flag invites one of them to
|
|
370
|
+
// carry on.
|
|
371
|
+
async function confirmSaved() {
|
|
372
|
+
// No non-TTY branch here: showRecoveryKey refuses before the key is printed,
|
|
373
|
+
// so by the time this runs there is a terminal. A second escape hatch would
|
|
374
|
+
// be a way to reach the vault-create without an acknowledgement.
|
|
375
|
+
await withRawStdin('Press Enter once you have saved it, or Ctrl-C to stop. ', (chunk, done, stop) => {
|
|
376
|
+
if (chunk.includes(CTRL_C)) {
|
|
377
|
+
process.stdout.write('\n\n');
|
|
378
|
+
stop(new Error('stopped before anything was created; no vault exists and no key is in use'));
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (chunk.includes('\r') || chunk.includes('\n')) {
|
|
382
|
+
process.stdout.write('\n\n');
|
|
383
|
+
done(undefined);
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
}
|
package/dist/src/sas.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Whether the SAS ceremony is pinned.
|
|
2
|
+
//
|
|
3
|
+
// It is. The derivations live in @agmsg-cloud/sas-core, fixed to the three test
|
|
4
|
+
// vectors of docs/spec/sas-pin-v1.md §6 and driven through the server as well as
|
|
5
|
+
// the clients, so both sides agree with the SPECIFICATION rather than merely
|
|
6
|
+
// with each other. The provisional derivation that used to live here — a short
|
|
7
|
+
// digest a malicious server could grind a match for — is gone.
|
|
8
|
+
//
|
|
9
|
+
// What this flag gates is approval, which seals real key history to a machine on
|
|
10
|
+
// the strength of eight digits a human compared. It stayed false while any part
|
|
11
|
+
// of that comparison was unfinished, and the order it was turned on in matters:
|
|
12
|
+
// the state machine and the client were each reviewed, an end-to-end run proved
|
|
13
|
+
// the two halves actually work together over real HTTP with real age keys, and
|
|
14
|
+
// only then did this become true.
|
|
15
|
+
//
|
|
16
|
+
// Turning it back to false is a real thing to do if the ceremony is ever in
|
|
17
|
+
// doubt: everything downstream of it is fail-closed by construction.
|
|
18
|
+
export const SAS_PINNED = true;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
const run = promisify(execFile);
|
|
4
|
+
const SERVICE = 'agmsg-cloud';
|
|
5
|
+
// macOS ships `security`. Linux would use libsecret's `secret-tool` and Windows
|
|
6
|
+
// the credential manager; neither is implemented here, and each reports
|
|
7
|
+
// unsupported rather than pretending. Callers must treat unsupported as "this
|
|
8
|
+
// machine keeps using the recovery key", never as "write it somewhere else".
|
|
9
|
+
function platformTool() {
|
|
10
|
+
return process.platform === 'darwin' ? { tool: 'security' } : null;
|
|
11
|
+
}
|
|
12
|
+
export async function secureStoreStatus() {
|
|
13
|
+
const platform = platformTool();
|
|
14
|
+
if (!platform) {
|
|
15
|
+
return {
|
|
16
|
+
kind: 'unsupported',
|
|
17
|
+
reason: `no supported secure store on ${process.platform} (this build knows macOS only)`,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
// Cheapest call that proves the tool exists AND answers: listing the
|
|
22
|
+
// default keychain touches the store without writing to it.
|
|
23
|
+
await run('security', ['default-keychain']);
|
|
24
|
+
return { kind: 'available' };
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
return { kind: 'locked', reason: messageOf(err) };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* How the store command is built, as a pure value.
|
|
32
|
+
*
|
|
33
|
+
* Separate from running it because the two have different requirements: the
|
|
34
|
+
* command can be checked anywhere, and only executing it needs macOS. Folding
|
|
35
|
+
* them together is what let a platform guard hide the check — on Linux CI the
|
|
36
|
+
* refusal came first and the argv assertions never ran, so reverting to the
|
|
37
|
+
* old `-w <secret>` shape would have stayed green.
|
|
38
|
+
*
|
|
39
|
+
* argv carries `-i` and nothing else. The command, with the value as hex,
|
|
40
|
+
* travels on stdin — anything in argv is readable by every process of the same
|
|
41
|
+
* user, and the blob is the KEK and the wrapped VDK together.
|
|
42
|
+
*/
|
|
43
|
+
export function buildStoreCommand(account, value) {
|
|
44
|
+
assertAddressable(account);
|
|
45
|
+
const line = [
|
|
46
|
+
'add-generic-password',
|
|
47
|
+
'-a',
|
|
48
|
+
hexArg(account),
|
|
49
|
+
'-s',
|
|
50
|
+
hexArg(SERVICE),
|
|
51
|
+
'-X',
|
|
52
|
+
Buffer.from(value, 'utf8').toString('hex'),
|
|
53
|
+
'-U',
|
|
54
|
+
].join(' ');
|
|
55
|
+
return { argv: ['-i'], stdin: `${line}\n` };
|
|
56
|
+
}
|
|
57
|
+
export async function storeSecret(account, value) {
|
|
58
|
+
// Built first, so a malformed account is refused the same way everywhere.
|
|
59
|
+
const command = buildStoreCommand(account, value);
|
|
60
|
+
requireDarwin();
|
|
61
|
+
// Not a temp file, deliberately. Writing the wrapped VDK to disk — even
|
|
62
|
+
// 0600, even briefly — breaks the property this whole feature rests on: the
|
|
63
|
+
// key material exists in memory for one command and nowhere else.
|
|
64
|
+
await runner(command);
|
|
65
|
+
}
|
|
66
|
+
// Names go through hex as well, on every operation.
|
|
67
|
+
//
|
|
68
|
+
// Measured, and the reason is not theoretical: `security -i` reads one command
|
|
69
|
+
// per LINE. An account containing a newline runs a second command — I stored an
|
|
70
|
+
// item, then wrote an address whose newline carried a `delete-generic-password`
|
|
71
|
+
// for it, and the item was gone. Whitespace and quotes reshape the token more
|
|
72
|
+
// quietly.
|
|
73
|
+
//
|
|
74
|
+
// A 0x-prefixed name is a distinct item from the decoded name (measured: a
|
|
75
|
+
// plain-text lookup does not find it), so this only works if EVERY operation
|
|
76
|
+
// uses the same form. Write, read and delete all call hexArg — that consistency
|
|
77
|
+
// is what makes it safe, and splitting it would lose slots rather than expose
|
|
78
|
+
// them.
|
|
79
|
+
function hexArg(value) {
|
|
80
|
+
return `0x${Buffer.from(value, 'utf8').toString('hex')}`;
|
|
81
|
+
}
|
|
82
|
+
const spawnRunner = ({ argv, stdin }) => new Promise((resolve, reject) => {
|
|
83
|
+
const child = spawn('security', argv, { stdio: ['pipe', 'ignore', 'pipe'] });
|
|
84
|
+
let stderr = '';
|
|
85
|
+
child.stderr.on('data', (chunk) => (stderr += String(chunk)));
|
|
86
|
+
child.on('error', (err) => reject(new SecureStoreUnavailable(err.message)));
|
|
87
|
+
child.stdin.on('error', (err) => reject(new SecureStoreUnavailable(`could not write the command: ${err.message}`)));
|
|
88
|
+
child.on('close', (code) => {
|
|
89
|
+
// `security -i` reports an unknown command as 1 and a failing one as 44
|
|
90
|
+
// (measured), so a non-zero exit is the inner command failing and must
|
|
91
|
+
// not be reported as a save.
|
|
92
|
+
if (code === 0)
|
|
93
|
+
resolve();
|
|
94
|
+
else
|
|
95
|
+
reject(new SecureStoreUnavailable(stderr.trim() || `security exited ${code}`));
|
|
96
|
+
});
|
|
97
|
+
child.stdin.end(stdin);
|
|
98
|
+
});
|
|
99
|
+
let runner = spawnRunner;
|
|
100
|
+
/** Test seam. Returns the previous runner so a caller can put it back. */
|
|
101
|
+
export function setSecurityRunner(next) {
|
|
102
|
+
const previous = runner;
|
|
103
|
+
runner = next;
|
|
104
|
+
return previous;
|
|
105
|
+
}
|
|
106
|
+
export async function readSecret(account) {
|
|
107
|
+
requireDarwin();
|
|
108
|
+
assertAddressable(account);
|
|
109
|
+
try {
|
|
110
|
+
const { stdout } = await run('security', [
|
|
111
|
+
'find-generic-password',
|
|
112
|
+
'-a',
|
|
113
|
+
hexArg(account),
|
|
114
|
+
'-s',
|
|
115
|
+
hexArg(SERVICE),
|
|
116
|
+
'-w',
|
|
117
|
+
]);
|
|
118
|
+
return stdout.trim();
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
// `security` exits non-zero both for "no such item" and for a locked
|
|
122
|
+
// keychain. They are different answers — one means "make a slot", the
|
|
123
|
+
// other means "do not touch anything" — so the distinction is preserved
|
|
124
|
+
// rather than collapsed into null.
|
|
125
|
+
if (/could not be found/i.test(messageOf(err)))
|
|
126
|
+
return null;
|
|
127
|
+
throw new SecureStoreUnavailable(messageOf(err));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
export async function deleteSecret(account) {
|
|
131
|
+
requireDarwin();
|
|
132
|
+
assertAddressable(account);
|
|
133
|
+
try {
|
|
134
|
+
await run('security', ['delete-generic-password', '-a', hexArg(account), '-s', hexArg(SERVICE)]);
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
if (/could not be found/i.test(messageOf(err)))
|
|
138
|
+
return;
|
|
139
|
+
throw new SecureStoreUnavailable(messageOf(err));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
export class SecureStoreUnavailable extends Error {
|
|
143
|
+
constructor(reason) {
|
|
144
|
+
super(`the OS secure store did not answer: ${reason}`);
|
|
145
|
+
this.name = 'SecureStoreUnavailable';
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// A second line of defence, at the boundary rather than in the encoding.
|
|
149
|
+
//
|
|
150
|
+
// Hex already stops a newline from becoming another command — measured, and
|
|
151
|
+
// the regression holds it. This refuses the shape outright, so a future change
|
|
152
|
+
// that alters how names are encoded cannot quietly re-open the door, and so a
|
|
153
|
+
// caller passing a control character learns it here rather than storing a slot
|
|
154
|
+
// under a name nobody will reconstruct.
|
|
155
|
+
function assertAddressable(account) {
|
|
156
|
+
// eslint-disable-next-line no-control-regex
|
|
157
|
+
if (/[\u0000-\u001f\u007f]/.test(account)) {
|
|
158
|
+
throw new SecureStoreUnavailable('a secure-store account name cannot contain control characters');
|
|
159
|
+
}
|
|
160
|
+
if (account.length === 0) {
|
|
161
|
+
throw new SecureStoreUnavailable('a secure-store account name cannot be empty');
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function requireDarwin() {
|
|
165
|
+
if (!platformTool()) {
|
|
166
|
+
throw new SecureStoreUnavailable(`no supported secure store on ${process.platform} (this build knows macOS only)`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function messageOf(err) {
|
|
170
|
+
if (err && typeof err === 'object' && 'stderr' in err) {
|
|
171
|
+
const stderr = String(err.stderr ?? '').trim();
|
|
172
|
+
if (stderr)
|
|
173
|
+
return stderr;
|
|
174
|
+
}
|
|
175
|
+
return err instanceof Error ? err.message : String(err);
|
|
176
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Quote a value so a command we PRINT can be pasted and run as-is.
|
|
2
|
+
//
|
|
3
|
+
// Every place that shows someone a command is showing them something they will
|
|
4
|
+
// select and paste. A team name is a string, not a shell token: it may contain
|
|
5
|
+
// spaces, quotes, or metacharacters, and interpolating it raw turns a helpful
|
|
6
|
+
// line into a different command than the one it appears to be. A display that
|
|
7
|
+
// breaks on a space is worse than a placeholder, because it looks like it works.
|
|
8
|
+
//
|
|
9
|
+
// Lives here rather than beside one command because it was written twice — the
|
|
10
|
+
// second copy was written by pasting the first, and a third would have been too.
|
|
11
|
+
const BARE_ARG = /^[A-Za-z0-9._-]+$/u;
|
|
12
|
+
export function shellArg(value) {
|
|
13
|
+
if (BARE_ARG.test(value))
|
|
14
|
+
return value;
|
|
15
|
+
// Single quotes stop every expansion; the only thing they cannot contain is a
|
|
16
|
+
// single quote, which is closed, escaped, and reopened.
|
|
17
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
18
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { shellArg } from './shell-arg.js';
|
|
2
|
+
/**
|
|
3
|
+
* Explain a slot that did not open, given the command that will now ask for the
|
|
4
|
+
* recovery key anyway.
|
|
5
|
+
*
|
|
6
|
+
* Every branch below ends the same way in practice — the run continues and the
|
|
7
|
+
* recovery key is typed. None of them is a dead end, which is the point: a
|
|
8
|
+
* machine with no secure store is not a machine that cannot back up.
|
|
9
|
+
*/
|
|
10
|
+
export function adviseOnSlot(result, ctx) {
|
|
11
|
+
if (result.ok)
|
|
12
|
+
return { tone: 'silent', lines: [] };
|
|
13
|
+
switch (result.reason) {
|
|
14
|
+
case 'no-slot':
|
|
15
|
+
// The first backup on this machine, or the first after a re-issuance.
|
|
16
|
+
// Worth one line, because the next sentence asks for the recovery key and
|
|
17
|
+
// the person deserves to know why this time and not next time.
|
|
18
|
+
return {
|
|
19
|
+
tone: 'note',
|
|
20
|
+
lines: [
|
|
21
|
+
'this machine has no key slot for this vault yet, so the recovery key is needed once.',
|
|
22
|
+
],
|
|
23
|
+
};
|
|
24
|
+
case 'no-store':
|
|
25
|
+
// Windows and Linux, every run. Stated as a property of the machine, not
|
|
26
|
+
// as a fault, and without advice — there is nothing to do about it.
|
|
27
|
+
return {
|
|
28
|
+
tone: 'note',
|
|
29
|
+
lines: [
|
|
30
|
+
'this machine has no secure store, so the recovery key is needed for each backup.',
|
|
31
|
+
],
|
|
32
|
+
};
|
|
33
|
+
case 'store-locked':
|
|
34
|
+
// The one the person can act on. The action is about later runs, not this
|
|
35
|
+
// one: this run proceeds with the recovery key either way, and saying
|
|
36
|
+
// "unlock and retry" as though it were required would be false.
|
|
37
|
+
return {
|
|
38
|
+
tone: 'note',
|
|
39
|
+
lines: [
|
|
40
|
+
'this machine has a secure store but it refused: ' + result.detail,
|
|
41
|
+
`unlock it and run \`agmsg-cloud recovery setup ${shellArg(ctx.team)}\` again to keep a key`,
|
|
42
|
+
'slot, so later backups do not ask for the recovery key. This run continues without one.',
|
|
43
|
+
],
|
|
44
|
+
};
|
|
45
|
+
case 'unusable':
|
|
46
|
+
// The only warning, and it is narrower than it first looks.
|
|
47
|
+
//
|
|
48
|
+
// A re-issued vault or another account does NOT arrive here: the store
|
|
49
|
+
// account name is built from (service, account, vault, generation), so
|
|
50
|
+
// those look up a different address and come back as `no-slot`. What
|
|
51
|
+
// reaches this branch is a slot at THIS address whose contents did not
|
|
52
|
+
// hold up — a shape this version does not wrap, or bytes whose tag failed.
|
|
53
|
+
// Naming re-issuance here would put another branch's cause on this one's
|
|
54
|
+
// warning, which is the blurring the four reasons exist to end
|
|
55
|
+
// (raised in review).
|
|
56
|
+
return {
|
|
57
|
+
tone: 'warning',
|
|
58
|
+
lines: [
|
|
59
|
+
'this machine has a key slot for this vault and its contents did not hold up: ' +
|
|
60
|
+
result.detail,
|
|
61
|
+
'the stored slot was written by a different version of this tool, or it has been',
|
|
62
|
+
'altered. It is not used. The recovery key is needed for this backup, and a fresh',
|
|
63
|
+
'slot is kept once that succeeds.',
|
|
64
|
+
],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Render advice for a terminal. Empty string when there is nothing to say. */
|
|
69
|
+
export function renderSlotAdvice(advice) {
|
|
70
|
+
if (advice.lines.length === 0)
|
|
71
|
+
return '';
|
|
72
|
+
const prefix = advice.tone === 'warning' ? 'warning: ' : 'note: ';
|
|
73
|
+
return `${prefix}${advice.lines.join('\n ')}\n`;
|
|
74
|
+
}
|