agmsg-cloud 0.0.1 → 0.1.0-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +39 -2
  2. package/dist/src/api.js +517 -0
  3. package/dist/src/authenticated-digest.js +234 -0
  4. package/dist/src/browser.js +241 -0
  5. package/dist/src/ceremony.js +181 -0
  6. package/dist/src/commands/approve.js +392 -0
  7. package/dist/src/commands/connect.js +273 -0
  8. package/dist/src/commands/fetch.js +249 -0
  9. package/dist/src/commands/login.js +334 -0
  10. package/dist/src/commands/logout.js +74 -0
  11. package/dist/src/commands/pull.js +80 -0
  12. package/dist/src/commands/request.js +371 -0
  13. package/dist/src/commands/sync.js +138 -0
  14. package/dist/src/commands/vault.js +478 -0
  15. package/dist/src/commands/watch.js +47 -0
  16. package/dist/src/config.js +34 -0
  17. package/dist/src/credentials.js +374 -0
  18. package/dist/src/device-slot.js +148 -0
  19. package/dist/src/filelock.js +167 -0
  20. package/dist/src/index.js +242 -0
  21. package/dist/src/ledger.js +296 -0
  22. package/dist/src/machine-name.js +90 -0
  23. package/dist/src/oss-env.js +49 -0
  24. package/dist/src/oss.js +289 -0
  25. package/dist/src/paths.js +8 -0
  26. package/dist/src/pending.js +330 -0
  27. package/dist/src/pick-request.js +56 -0
  28. package/dist/src/preflight.js +257 -0
  29. package/dist/src/recovery-key.js +386 -0
  30. package/dist/src/sas.js +18 -0
  31. package/dist/src/secure-store.js +176 -0
  32. package/dist/src/shell-arg.js +18 -0
  33. package/dist/src/slot-advice.js +74 -0
  34. package/dist/src/vault-container.js +115 -0
  35. package/dist/src/vault-crypto.js +190 -0
  36. package/dist/src/vault-protocol.js +358 -0
  37. package/dist/src/version.js +57 -0
  38. package/node_modules/@agmsg-cloud/sas-core/dist/src/bech32.d.ts +17 -0
  39. package/node_modules/@agmsg-cloud/sas-core/dist/src/bech32.js +103 -0
  40. package/node_modules/@agmsg-cloud/sas-core/dist/src/index.d.ts +17 -0
  41. package/node_modules/@agmsg-cloud/sas-core/dist/src/index.js +147 -0
  42. package/node_modules/@agmsg-cloud/sas-core/package.json +30 -0
  43. package/package.json +50 -7
  44. package/bin/agmsg-cloud.js +0 -4
@@ -0,0 +1,234 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { closeSync, constants, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, statSync, chmodSync, rmSync, writeSync, } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ /**
6
+ * Identify the (credential, device identity) pair without storing either.
7
+ *
8
+ * The device key is in here because a ceremony authenticates a DEVICE. Under
9
+ * one capability that key can be regenerated, and then an old ceremony's
10
+ * record would sit in the same scope as the new identity's: the old digest
11
+ * would be applied to a bundle the new identity cannot decrypt, or the two
12
+ * would collide and refuse (raised in review). A record whose device key no longer
13
+ * exists on this machine is a record about a different machine-identity, and
14
+ * this is what says so.
15
+ */
16
+ export function fingerprintScope(secret, devicePubkey) {
17
+ return createHash('sha256')
18
+ .update(Buffer.from('agmsg-cloud-authenticated-digest-scope-v2\0', 'ascii'))
19
+ .update(Buffer.from(secret, 'utf8'))
20
+ .update(Buffer.from('\0', 'ascii'))
21
+ .update(Buffer.from(devicePubkey, 'utf8'))
22
+ .digest('hex')
23
+ .slice(0, 32);
24
+ }
25
+ const HEX64 = /^[0-9a-f]{64}$/;
26
+ const SCOPE_HEX = /^[0-9a-f]{32}$/;
27
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
28
+ // A file rewritten in place keeps the mode it already had, and a directory
29
+ // someone created with a looser umask keeps that too. Both are repaired here
30
+ // rather than trusted.
31
+ function enforceMode(path, want) {
32
+ try {
33
+ if ((statSync(path).mode & 0o777) !== want)
34
+ chmodSync(path, want);
35
+ }
36
+ catch (err) {
37
+ if (err.code !== 'ENOENT')
38
+ throw err;
39
+ }
40
+ }
41
+ function baseDir(env) {
42
+ return join(env.AGMSG_CLOUD_HOME ?? join(homedir(), '.agmsg-cloud'), 'authenticated');
43
+ }
44
+ // One file per settled ceremony rather than one per origin. Two of them is a
45
+ // state this refuses to guess in (see readAuthenticatedDigests), and a single
46
+ // file would make that state unrepresentable by overwriting — which is not the
47
+ // same as it not happening.
48
+ function pathFor(dir, origin, requestId) {
49
+ const key = Buffer.from(origin, 'utf8').toString('base64url');
50
+ return join(dir, `${key}.${requestId}.json`);
51
+ }
52
+ /**
53
+ * Write every byte, or throw.
54
+ *
55
+ * Its own function, and injectable, because the defect it fixes cannot be
56
+ * reproduced on an ordinary filesystem: `writeSync` returns having written
57
+ * fewer bytes than it was given without throwing, and the fragment would then
58
+ * be fsynced and renamed into place as a valid record. A test needs a writer
59
+ * that behaves that way to hold this closed — without one, deleting the loop
60
+ * leaves every suite green (raised in review).
61
+ */
62
+ export function writeAll(fd, bytes, what, write = writeSync) {
63
+ let written = 0;
64
+ while (written < bytes.length) {
65
+ const n = write(fd, bytes, written, bytes.length - written);
66
+ // Zero means no progress: looping again would spin instead of failing, and
67
+ // a negative is a contract this cannot interpret. Both refuse.
68
+ if (n <= 0)
69
+ throw new Error(`short write to ${what}: ${written}/${bytes.length} bytes`);
70
+ written += n;
71
+ }
72
+ }
73
+ /**
74
+ * Record what this machine's SAS comparison authenticated.
75
+ *
76
+ * Written with the same durability as a credential — 0600 in a 0700 directory,
77
+ * temp file, fsync, rename, fsync the directory — because a half-written
78
+ * record is one `fetch` would either reject a good bundle over or, worse, read
79
+ * a truncated digest from.
80
+ */
81
+ export function recordAuthenticatedDigest(input, env = process.env) {
82
+ if (!HEX64.test(input.handoffDigest)) {
83
+ throw new Error('handoff digest must be 64 lowercase hexadecimal characters');
84
+ }
85
+ if (!UUID.test(input.requestId)) {
86
+ throw new Error('request id must be a uuid');
87
+ }
88
+ const dir = baseDir(env);
89
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
90
+ // `mkdirSync`'s mode applies only when it CREATES the directory; one that
91
+ // already exists keeps whatever it had. The comment above claims credential
92
+ // durability, so the claim is enforced on every write rather than assumed
93
+ // from the first one — the same reason credentials.ts does it (raised in review).
94
+ enforceMode(dir, 0o700);
95
+ const path = pathFor(dir, input.serverOrigin, input.requestId);
96
+ const record = {
97
+ requestId: input.requestId,
98
+ handoffDigest: input.handoffDigest,
99
+ scopeFingerprint: fingerprintScope(input.secret, input.devicePubkey),
100
+ recordedAt: new Date().toISOString(),
101
+ };
102
+ const tmp = `${path}.${randomBytes(8).toString('hex')}.tmp`;
103
+ // ONE interval: from the moment this temp exists until the rename returns,
104
+ // every failure removes it. Not "on a write error" and not "on a rename
105
+ // error" — those are two of the ways out, and each version of this that
106
+ // named the operations left another one uncovered. `closeSync` throws too,
107
+ // and it used to run BEFORE the removal in one branch and outside the
108
+ // guarded region in the other, so a failure there left the temp behind in
109
+ // both (raised in review).
110
+ //
111
+ // Until the rename returns, this file's existence is known only here, so
112
+ // nothing else can ever collect it.
113
+ const fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
114
+ try {
115
+ try {
116
+ // Written to completion, not "written once". `writeSync` may return
117
+ // having written FEWER bytes than it was given, without throwing, and
118
+ // that fragment would then be fsynced and renamed into place as a valid
119
+ // `.json` — replacing a correct approval with a truncated one and
120
+ // stopping `fetch` for good. The read side refuses it, which is the
121
+ // right end state and not a recovery (raised in review).
122
+ //
123
+ // Same reasoning as letting `closeSync` throw: a partial success is not
124
+ // a success, and publishing it is worse than failing here.
125
+ writeAll(fd, Buffer.from(`${JSON.stringify(record, null, 2)}\n`, 'utf8'), tmp);
126
+ fsyncSync(fd);
127
+ }
128
+ finally {
129
+ // Deliberately allowed to throw into the catch below: a close that fails
130
+ // can be reporting a deferred write error, and treating that as success
131
+ // would publish a record nobody knows is complete.
132
+ closeSync(fd);
133
+ }
134
+ renameSync(tmp, path);
135
+ }
136
+ catch (err) {
137
+ rmSync(tmp, { force: true });
138
+ throw err;
139
+ }
140
+ enforceMode(path, 0o600);
141
+ const dirFd = openSync(dirname(path), constants.O_RDONLY);
142
+ try {
143
+ fsyncSync(dirFd);
144
+ }
145
+ finally {
146
+ closeSync(dirFd);
147
+ }
148
+ }
149
+ /**
150
+ * Every settled ceremony recorded for this origin.
151
+ *
152
+ * Returns them all rather than choosing. Picking the newest would let a bundle
153
+ * be checked against a digest from a DIFFERENT ceremony than the one whose
154
+ * digits a person read aloud, and a check against the wrong value is not a
155
+ * weaker check — it is no check, wearing the shape of one.
156
+ */
157
+ export function readAuthenticatedDigests(serverOrigin,
158
+ // Only the ceremonies THIS credential performed. An origin can hold more
159
+ // than one over its life — a rotation, a second account — and mixing them
160
+ // turns a single clear approval into an ambiguity, or points the comparison
161
+ // at another credential's ceremony.
162
+ secret, devicePubkey, env = process.env) {
163
+ const mine = fingerprintScope(secret, devicePubkey);
164
+ const dir = baseDir(env);
165
+ const prefix = `${Buffer.from(serverOrigin, 'utf8').toString('base64url')}.`;
166
+ let names;
167
+ try {
168
+ names = readdirSync(dir);
169
+ }
170
+ catch (err) {
171
+ if (err.code === 'ENOENT')
172
+ return [];
173
+ throw err;
174
+ }
175
+ const found = [];
176
+ for (const name of names) {
177
+ if (!name.startsWith(prefix) || !name.endsWith('.json'))
178
+ continue;
179
+ const path = join(dir, name);
180
+ // O_NOFOLLOW: a symlink here would let someone else's file decide which
181
+ // bundle this machine accepts.
182
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
183
+ let parsed;
184
+ try {
185
+ parsed = JSON.parse(readFileSync(fd, 'utf8'));
186
+ }
187
+ finally {
188
+ closeSync(fd);
189
+ }
190
+ const r = parsed;
191
+ // Unreadable is not absent. A record this cannot vouch for must not become
192
+ // "no record", because "no record" is the branch that refuses every
193
+ // bundle — turning a corrupted file into a machine that quietly stops
194
+ // fetching rather than one that says its record is broken.
195
+ if (typeof r?.['handoffDigest'] !== 'string' ||
196
+ !HEX64.test(r['handoffDigest']) ||
197
+ typeof r['requestId'] !== 'string' ||
198
+ !UUID.test(r['requestId']) ||
199
+ typeof r['scopeFingerprint'] !== 'string' ||
200
+ !SCOPE_HEX.test(r['scopeFingerprint'])) {
201
+ throw new Error(`authenticated digest record is unreadable: ${path}`);
202
+ }
203
+ // A different scope's ceremony is not this one's business — skipped, not
204
+ // an error, because its presence is normal. This only holds because the
205
+ // value was checked for SHAPE above: without that, a corrupted
206
+ // fingerprint would be silently filed as "someone else's" and the record
207
+ // would vanish from view instead of failing, which is the collapse of
208
+ // unreadable into absent that this file refuses everywhere else.
209
+ //
210
+ // What the shape check cannot do: a well-formed value that is simply wrong
211
+ // is indistinguishable from a genuine other scope. That limit is real and
212
+ // is not claimed away (raised in review).
213
+ if (r['scopeFingerprint'] !== mine)
214
+ continue;
215
+ found.push({
216
+ requestId: r['requestId'],
217
+ handoffDigest: r['handoffDigest'],
218
+ scopeFingerprint: r['scopeFingerprint'],
219
+ recordedAt: typeof r['recordedAt'] === 'string' ? r['recordedAt'] : '',
220
+ });
221
+ }
222
+ return found;
223
+ }
224
+ /** Forget one, once its bundle has been taken. */
225
+ export function clearAuthenticatedDigest(serverOrigin, requestId,
226
+ // Taken but unused for the path, and required anyway: the caller must have
227
+ // the credential whose ceremony this was. A signature that did not ask for
228
+ // it would let one credential's fetch delete another's approval.
229
+ secret, devicePubkey, env = process.env) {
230
+ const records = readAuthenticatedDigests(serverOrigin, secret, devicePubkey, env);
231
+ if (!records.some((r) => r.requestId === requestId))
232
+ return;
233
+ rmSync(pathFor(baseDir(env), serverOrigin, requestId), { force: true });
234
+ }
@@ -0,0 +1,241 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { platform } from 'node:process';
3
+ // "Press Enter to open it" — deliberately not "open it automatically".
4
+ //
5
+ // The URL and the code stay on screen either way. Opening the page for someone
6
+ // who never saw where it points would take away the one thing that makes a
7
+ // default endpoint safe: the destination is visible before anyone arrives.
8
+ //
9
+ // Two constraints shape everything here:
10
+ //
11
+ // 1. Waiting for Enter must NOT pause the poll. Someone who opens the URL by
12
+ // hand, or on their phone, gets approved without ever pressing a key, and
13
+ // the CLI has to notice that. So the keypress is armed alongside the poll,
14
+ // never awaited by it.
15
+ // 2. Failing to open a browser is NOT a failure to log in. Over ssh, in a
16
+ // container, on a headless box, there is no browser and there never will
17
+ // be — exiting there would make those machines unable to log in at all.
18
+ //
19
+ // Raw mode is not used: Enter arrives on its own in line mode, so the terminal's
20
+ // external state is never modified and there is nothing to restore.
21
+ // The URL comes from the SERVER, so it is attacker-controlled whenever the
22
+ // server is. Two independent things keep that from becoming code execution:
23
+ //
24
+ // 1. No shell, on any platform. `cmd /c start` was the original Windows
25
+ // opener and it is wrong: `shell: false` does not help when the program
26
+ // being run IS cmd, which re-interprets `&`, `|`, `^` and `%` inside the
27
+ // argument. `rundll32 url.dll,FileProtocolHandler` takes the URL as a
28
+ // plain argument, like `open` and `xdg-open` already do.
29
+ // 2. The URL is checked before it is handed to any of them.
30
+ function openerFor(url) {
31
+ switch (platform) {
32
+ case 'darwin':
33
+ return { command: 'open', args: [url] };
34
+ case 'win32':
35
+ return { command: 'rundll32', args: ['url.dll,FileProtocolHandler', url] };
36
+ default:
37
+ return { command: 'xdg-open', args: [url] };
38
+ }
39
+ }
40
+ // Two different dangers, two different answers — conflating them is how the
41
+ // wrong control ends up carrying the weight.
42
+ //
43
+ // INJECTION is closed by never involving a shell. That is `openerFor` above,
44
+ // and it has nothing to do with which host the URL names.
45
+ // MISDIRECTION is a host question, and the first answer is that the URL is
46
+ // always printed, so a person can see where they are being sent.
47
+ //
48
+ // The host pin below is the second answer, and it only applies where we
49
+ // actually know both ends: the official deployment, whose control plane and
50
+ // console are both names we ship. Pinning a self-hosted deployment's console
51
+ // would be guesswork — its layout is not ours to know — so there the printed
52
+ // URL is the whole control.
53
+ export const DEFAULT_ENDPOINT = 'https://api.agmsg.cloud';
54
+ // The ORIGIN, not the host. We know the official console's scheme and port as
55
+ // surely as its name, and a check looser than what we know is a hole the exact
56
+ // width of the difference: `https://app.agmsg.cloud:8443` is a different service
57
+ // on a name the operator would read as correct. The port freedom below belongs
58
+ // to the local/self-hosted branch, where the topology genuinely is not ours.
59
+ const OFFICIAL_CONSOLE_ORIGIN = 'https://app.agmsg.cloud';
60
+ const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
61
+ // `https` everywhere, with plain http allowed only to the loopback interface.
62
+ // That is an exception to a rule, so it is worth saying why it is not a hole:
63
+ // a host an attacker controls cannot be 127.0.0.1, so the case the rule exists
64
+ // to stop cannot reach it.
65
+ function schemeIsAcceptable(target) {
66
+ if (target.protocol === 'https:')
67
+ return true;
68
+ return target.protocol === 'http:' && LOOPBACK.has(target.hostname);
69
+ }
70
+ export function verificationUrlIsSafe(url, endpoint) {
71
+ let target;
72
+ let configured;
73
+ try {
74
+ target = new URL(url);
75
+ configured = new URL(endpoint);
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ if (!schemeIsAcceptable(target))
81
+ return false;
82
+ // Credentials in the authority are never part of a verification URL we would
83
+ // send someone to, and they make the authority ambiguous to a reader.
84
+ if (target.username !== '' || target.password !== '')
85
+ return false;
86
+ const usingDefault = configured.origin === new URL(DEFAULT_ENDPOINT).origin;
87
+ if (usingDefault)
88
+ return target.origin === OFFICIAL_CONSOLE_ORIGIN;
89
+ // A locally-run stack still gets the loopback check above; beyond that, an
90
+ // operator who named their own endpoint owns their own topology.
91
+ return true;
92
+ }
93
+ export function openUrl(url) {
94
+ const opener = openerFor(url);
95
+ return new Promise((resolve) => {
96
+ try {
97
+ const child = spawn(opener.command, opener.args, {
98
+ stdio: 'ignore',
99
+ detached: true,
100
+ });
101
+ // An opener that is missing (no xdg-open) emits 'error', not an exit code.
102
+ child.once('error', () => resolve(false));
103
+ child.once('spawn', () => {
104
+ child.unref();
105
+ resolve(true);
106
+ });
107
+ }
108
+ catch {
109
+ resolve(false);
110
+ }
111
+ });
112
+ }
113
+ // What "nobody is here" actually means.
114
+ //
115
+ // This used to ask `stdin.isTTY`, and an owner's end-to-end run through an
116
+ // agent printed "Press Enter to open it in your browser" into a stream that
117
+ // was already at EOF: the runner handed the process a terminal and no keys.
118
+ // A terminal is where a keystroke would ARRIVE if one were sent; it is not
119
+ // evidence that anyone can send one. So the question is whether input is
120
+ // still coming, and a closed or finished stream answers no whatever its
121
+ // isTTY says.
122
+ //
123
+ // It stays conservative in the other direction: an open TTY is treated as
124
+ // interactive even though the person may have walked away. Offering a key
125
+ // that nobody presses costs nothing — the poll runs regardless — while
126
+ // withholding it from someone who is there would take away the only
127
+ // convenience this function exists to provide.
128
+ function keypressCanArrive(stdin) {
129
+ if (!stdin.isTTY)
130
+ return false;
131
+ if (stdin.destroyed)
132
+ return false;
133
+ if (stdin.readableEnded)
134
+ return false;
135
+ // `readable` is false once the stream has ended or been destroyed; an
136
+ // undefined value (a stream that never set it) is not evidence of either.
137
+ return stdin.readable !== false;
138
+ }
139
+ // What to say when it cannot. The caller has already printed the URL and the
140
+ // code, so nothing is missing from the screen — what is missing is what the
141
+ // agent reading this should DO, and the failure to say it is what makes an
142
+ // agent answer on the person's behalf.
143
+ const NO_KEYS_GUIDANCE = 'No keypress can reach this command. Show the URL and the code above to the person approving, ' +
144
+ 'and wait: do not open the page for them and do not approve on their behalf. ' +
145
+ 'This command returns on its own once they answer.\n';
146
+ // How long to let the input declare itself finished before offering a key.
147
+ // Measured, not chosen: a pty at EOF answers in single-digit milliseconds
148
+ // locally, and this is two orders above that. It is a delay before a
149
+ // convenience, never before the login itself — the poll is already running
150
+ // and the URL is already on screen.
151
+ const EOF_GRACE_MS = 250;
152
+ // Arms a one-shot Enter -> open. Returns a disarm that is idempotent and must be
153
+ // called on every exit path: a live stdin listener would otherwise hold the
154
+ // process open after login has already finished.
155
+ export function armEnterToOpen(url, endpoint, deps = {}) {
156
+ const stdin = deps.stdin ?? process.stdin;
157
+ const write = deps.write ?? ((s) => void process.stdout.write(s));
158
+ const open = deps.open ?? openUrl;
159
+ // Refused before anything is offered, not at the moment a key is pressed: a
160
+ // destination we would not open is not one to advertise either. Login itself
161
+ // continues — the URL is on screen, and a human who inspects it can still
162
+ // decide for themselves.
163
+ if (!verificationUrlIsSafe(url, endpoint)) {
164
+ // Not an error: login continues and the URL is already on screen. We simply
165
+ // decline to send someone somewhere we cannot vouch for, and say so.
166
+ write(`Not offering to open that page — check the URL above before you open it yourself.\n`);
167
+ return { disarm: () => { } };
168
+ }
169
+ // Saying "press Enter" where no keystroke can arrive is an instruction that
170
+ // can never be followed — so say what CAN be done there instead.
171
+ if (!keypressCanArrive(stdin)) {
172
+ write(NO_KEYS_GUIDANCE);
173
+ return { disarm: () => { } };
174
+ }
175
+ // The offer waits for the input to prove itself finished, or not.
176
+ //
177
+ // An earlier revision printed it immediately and withdrew it two lines
178
+ // later when the input turned out to be at EOF. The withdrawal worked and
179
+ // was still wrong: an owner's run had an agent read the output as it
180
+ // arrived and report the FIRST line, so the person was told to press a key
181
+ // that could never be pressed. Order is part of the message when something
182
+ // else is reading it a line at a time.
183
+ //
184
+ // The next revision deferred by one `setImmediate`, which passes against a
185
+ // fake stream and FAILS on a real one — measured on a pty whose input was
186
+ // at EOF, the shape the owner actually hit: EOF arrives from a read in a
187
+ // later poll phase, and the check phase runs before it. There is no
188
+ // positive signal to wait for instead. A live terminal that nobody is
189
+ // touching emits nothing at all, so "still open" cannot be observed; only
190
+ // "finished" can.
191
+ //
192
+ // So the wait is for the negative signal, bounded. A finished stream says
193
+ // so within a read; a live one stays silent and the offer goes out a
194
+ // fraction of a second later, which costs a person nothing. Anything that
195
+ // ends after the window is still retracted below — the window narrows the
196
+ // hole, it does not pretend to close it.
197
+ let done = false;
198
+ const disarm = () => {
199
+ if (done)
200
+ return;
201
+ done = true;
202
+ stdin.off('data', onData);
203
+ stdin.off('end', onEnd);
204
+ try {
205
+ stdin.pause();
206
+ }
207
+ catch {
208
+ // Already closed; there is nothing left to pause.
209
+ }
210
+ };
211
+ const onData = () => {
212
+ disarm();
213
+ void open(url).then((ok) => {
214
+ if (!ok) {
215
+ write(`Could not open a browser here — open the URL above yourself.\n`);
216
+ }
217
+ });
218
+ };
219
+ // EOF. If the offer has not gone out yet, it never does — nothing false is
220
+ // said at all. If a slower runner ends the stream after the offer was made,
221
+ // the offer is withdrawn, which is the best that can be done once it is on
222
+ // screen.
223
+ const onEnd = () => {
224
+ disarm();
225
+ write(NO_KEYS_GUIDANCE);
226
+ };
227
+ stdin.on('data', onData);
228
+ stdin.on('end', onEnd);
229
+ stdin.resume();
230
+ const pending = deps.defer ?? ((fn) => setTimeout(fn, EOF_GRACE_MS));
231
+ const timer = pending(() => {
232
+ if (done)
233
+ return;
234
+ write(`Press Enter to open it in your browser (or open the URL yourself).\n`);
235
+ });
236
+ timer?.unref?.();
237
+ // Without this a waiting listener keeps the event loop alive, so the process
238
+ // would hang after a successful login instead of exiting.
239
+ stdin.unref?.();
240
+ return { disarm };
241
+ }
@@ -0,0 +1,181 @@
1
+ import { deriveRequestNonce, deriveSas, verifyApproverCommitment, verifySasCommitment, } from '@agmsg-cloud/sas-core';
2
+ // Shared orchestration for the SAS ceremony (spec §3-§4). Both sides run the
3
+ // same waits and the same verification, so there is one place where "is this
4
+ // transcript trustworthy" is decided.
5
+ //
6
+ // THE RULE THIS FILE EXISTS FOR: neither side may take the SAS, or any input to
7
+ // it, from the server. The server returns request_nonce because it is part of
8
+ // the public transcript, and a client that displayed a code derived from it
9
+ // would be asking the server what code to show — which is exactly the check the
10
+ // ceremony is supposed to make impossible to fake. So the value is recomputed
11
+ // here from the two opened nonces every time, and the server's copy is only ever
12
+ // compared against, never used.
13
+ export class CeremonyError extends Error {
14
+ reason;
15
+ constructor(reason, message) {
16
+ super(message);
17
+ this.reason = reason;
18
+ this.name = 'CeremonyError';
19
+ }
20
+ }
21
+ // The ceremony only ever moves forwards, so waiting is "has it reached at least
22
+ // here", not "is it exactly here".
23
+ //
24
+ // Waiting for equality is a race the requester loses routinely: the approver
25
+ // opens and then approves, and the row is 'consumed' a few milliseconds later.
26
+ // A requester polling for exactly 'opened' can step straight over that window
27
+ // and report a successful enrollment as a failure. Found by running both sides
28
+ // against one real server — every test that stubbed a side had it exactly-equal
29
+ // and was green.
30
+ const PROGRESSION = ['requester_committed', 'both_committed', 'requester_opened', 'opened', 'consumed'];
31
+ // Ends the ceremony without reaching the end. Not the same as 'consumed', which
32
+ // is the end.
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'];
38
+ function rank(status) {
39
+ return PROGRESSION.indexOf(status);
40
+ }
41
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
42
+ /**
43
+ * Poll one request until it reaches `want`, or fail.
44
+ *
45
+ * A ceremony that reaches a terminal state stops immediately rather than waiting
46
+ * out the clock: §3.3 makes failure permanent, so continuing to poll would only
47
+ * delay telling the human that this attempt is over — and §4.1 has already
48
+ * charged them for it.
49
+ */
50
+ export async function waitForStatus(client, requestId, want, options = {}) {
51
+ const timeoutMs = options.timeoutMs ?? 5 * 60_000;
52
+ const pollMs = options.pollMs ?? 1_000;
53
+ const sleep = options.sleep ?? defaultSleep;
54
+ const now = options.now ?? Date.now;
55
+ const deadline = now() + timeoutMs;
56
+ for (;;) {
57
+ const transcript = await client.getEnrollment(requestId);
58
+ if (TERMINAL_FAILURE.includes(transcript.status)) {
59
+ throw new CeremonyError('ended', `enrollment ${requestId} is ${transcript.status}`);
60
+ }
61
+ const reached = rank(transcript.status);
62
+ if (reached >= 0 && reached >= rank(want))
63
+ return transcript;
64
+ if (now() >= deadline) {
65
+ throw new CeremonyError('timed_out', `enrollment ${requestId} did not reach ${want} in time`);
66
+ }
67
+ await sleep(pollMs);
68
+ }
69
+ }
70
+ function requireBytes(hex, what) {
71
+ if (hex === null)
72
+ throw new CeremonyError('server_transcript_disagrees', `${what} is missing`);
73
+ return new Uint8Array(Buffer.from(hex, 'hex'));
74
+ }
75
+ /**
76
+ * Turn a fully opened transcript into the code a human reads aloud.
77
+ *
78
+ * Every input is re-derived or re-verified here. `request_nonce` from the server
79
+ * is checked against the locally computed value and then discarded — if they
80
+ * disagree, the server is not relaying, and no code should be shown at all.
81
+ */
82
+ export function sasFromOpenedTranscript(transcript) {
83
+ if (transcript.status !== 'opened' && transcript.status !== 'consumed') {
84
+ throw new CeremonyError('server_transcript_disagrees', `transcript is ${transcript.status}`);
85
+ }
86
+ const devicePubkey = transcript.device_pubkey;
87
+ if (devicePubkey === null) {
88
+ throw new CeremonyError('server_transcript_disagrees', 'device_pubkey is missing');
89
+ }
90
+ const openingNonce = requireBytes(transcript.opening_nonce, 'opening_nonce');
91
+ const approverNonce = requireBytes(transcript.approver_nonce, 'approver_nonce');
92
+ // The snapshot A committed to. Required, not optional: it is an input to
93
+ // both the approver commitment and the SAS, so a transcript without it is
94
+ // one this cannot check or derive from — treating it as absent would mean
95
+ // showing digits that attest less than the person reading them believes.
96
+ const handoffDigest = transcript.handoff_digest;
97
+ if (handoffDigest === null) {
98
+ throw new CeremonyError('server_transcript_disagrees', 'handoff_digest is missing');
99
+ }
100
+ // B's opening must match the commitment fixed before A ever committed.
101
+ if (!verifySasCommitment(transcript.commitment, devicePubkey, openingNonce)) {
102
+ throw new CeremonyError('requester_opening_invalid', 'the requester opening does not match its commitment');
103
+ }
104
+ // A's opening must match the commitment fixed before B opened.
105
+ if (transcript.approver_commitment === null) {
106
+ throw new CeremonyError('server_transcript_disagrees', 'approver_commitment is missing');
107
+ }
108
+ if (!verifyApproverCommitment(transcript.approver_commitment, approverNonce, handoffDigest)) {
109
+ throw new CeremonyError('approver_commitment_invalid', 'the approver opening does not match its commitment');
110
+ }
111
+ const requestNonce = deriveRequestNonce(approverNonce, openingNonce);
112
+ const localHex = Buffer.from(requestNonce).toString('hex');
113
+ if (transcript.request_nonce !== null && transcript.request_nonce !== localHex) {
114
+ // Not a mismatch to paper over. The server derived something else from the
115
+ // same two nonces, which means it is not running the protocol.
116
+ throw new CeremonyError('server_transcript_disagrees', 'the server request nonce does not match the one derived locally');
117
+ }
118
+ return deriveSas(devicePubkey, requestNonce, handoffDigest);
119
+ }
120
+ // How the code is shown. Kept in one place so both terminals render it
121
+ // identically: two machines formatting the same eight digits differently is a
122
+ // comparison the human is more likely to get wrong, and "it looked different on
123
+ // the other screen" is the exact doubt this step must not introduce.
124
+ //
125
+ // The frame is measured from the content rather than written out, because a
126
+ // hand-drawn box drifts the moment the code changes width — and a box that does
127
+ // not line up reads as a rendering bug, which is not what you want the user
128
+ // looking at while deciding whether to trust a key.
129
+ export function renderSasBlock(sas, role) {
130
+ const other = role === 'requester' ? 'The approving machine' : 'The joining machine';
131
+ const inner = ` ${sas} `;
132
+ const rule = '─'.repeat([...inner].length);
133
+ return [
134
+ '',
135
+ ` ┌${rule}┐`,
136
+ ` │${inner}│`,
137
+ ` └${rule}┘`,
138
+ '',
139
+ ` ${other} is showing eight digits too. Check they are the same.`,
140
+ '',
141
+ // Only the approver is asked anything. Telling the joining machine's
142
+ // operator to "answer no" pointed at a prompt that does not exist on their
143
+ // side, one line above the sentence explaining that the approver decides.
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
+ ]),
160
+ '',
161
+ // Addressed to the people it applies to, and to nobody else.
162
+ //
163
+ // What used to be here was a paragraph for everyone: read the digits
164
+ // aloud, use a channel where you recognise the voice, do not paste into a
165
+ // chat, and do not read the code from any window other than this one.
166
+ //
167
+ // The ordinary case is one person with two machines in front of them, and
168
+ // for that person the paragraph was wrong in every line — there is nobody
169
+ // to read aloud to, no channel, and the last instruction forbids the check
170
+ // itself: comparing means looking at the other screen. It also introduced
171
+ // pasting, which is not a thing anyone comparing two screens would have
172
+ // thought of; naming a wrong way to do this is how it becomes an option.
173
+ //
174
+ // And listing prohibitions has a cost beyond the words: whatever the list
175
+ // leaves out reads as allowed. One sentence saying where the digits must
176
+ // come from does the work the list was trying to do.
177
+ ' If you cannot see that screen yourself, have the digits read to you on a',
178
+ ' call you already trust.',
179
+ '',
180
+ ].join('\n');
181
+ }