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