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,242 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { loadConfig, resolveScriptsDir } from './config.js';
|
|
3
|
+
import { cmdApprove } from './commands/approve.js';
|
|
4
|
+
import { cmdConnect, cmdConnectPreflight } from './commands/connect.js';
|
|
5
|
+
import { cmdFetch } from './commands/fetch.js';
|
|
6
|
+
import { cmdSync } from './commands/sync.js';
|
|
7
|
+
import { cmdLogin } from './commands/login.js';
|
|
8
|
+
import { cmdLogout, logoutEndpoint } from './commands/logout.js';
|
|
9
|
+
import { cmdPull } from './commands/pull.js';
|
|
10
|
+
import { cmdRequest } from './commands/request.js';
|
|
11
|
+
import { cmdVaultPut, cmdVaultRestore } from './commands/vault.js';
|
|
12
|
+
import { cmdWatch } from './commands/watch.js';
|
|
13
|
+
import { packageVersion } from './version.js';
|
|
14
|
+
const USAGE = `agmsg-cloud — hosted agmsg from this machine
|
|
15
|
+
|
|
16
|
+
login sign this machine in; approve it in your own browser
|
|
17
|
+
[--endpoint <url>] a self-hosted or development stack (default: the hosted service)
|
|
18
|
+
[--machine-name <n>] the name this machine is registered under (default: hostname)
|
|
19
|
+
|
|
20
|
+
logout remove this machine's stored credential for the service
|
|
21
|
+
[--endpoint <url>] the same address login used (default: the hosted service)
|
|
22
|
+
|
|
23
|
+
connect <team> put a team this machine already runs onto the hosted service
|
|
24
|
+
connect --preflight check what connect needs, without connecting anything
|
|
25
|
+
|
|
26
|
+
pull <team> (on a SECOND machine) take a team the first one already connected
|
|
27
|
+
[--team-id <id>] when the name is ambiguous, or already known
|
|
28
|
+
|
|
29
|
+
sync <team> (on the NEW machine) join <team>: ask, wait for approval, take the team
|
|
30
|
+
request <label> (on the NEW machine) ask to be added; prints a code to read to the approver
|
|
31
|
+
fetch <team> (on the NEW machine) unlock <team> from the approved bundle;
|
|
32
|
+
it is matched against the snapshot your code authenticated
|
|
33
|
+
approve <team> [request-id] (on a key-holding machine) approve the waiting request after the codes match
|
|
34
|
+
watch [--interval-ms <n>] (on a key-holding machine) print pending requests as they arrive
|
|
35
|
+
recovery setup [team] create this account's recovery key and back up every active
|
|
36
|
+
team this machine's store reports; name one to back up
|
|
37
|
+
only that team
|
|
38
|
+
recovery restore <team> (on any approved machine) open the backup and unlock <team>
|
|
39
|
+
version the version of this CLI (also --version, -v)
|
|
40
|
+
the OSS scripts it drives are reported by
|
|
41
|
+
\`connect --preflight\`, which is a separate answer
|
|
42
|
+
|
|
43
|
+
The recovery key is generated by \`recovery setup\` on the first run, shown once,
|
|
44
|
+
and never stored anywhere. There is ONE for the account, not one per team — which
|
|
45
|
+
is why it is not something to repeat each time a team is added. What one run
|
|
46
|
+
covers is every active team THIS MACHINE's store reports, and it names them when
|
|
47
|
+
it finishes; a team connected only on another machine is backed up by running it
|
|
48
|
+
there. It is the only thing that opens the backup — if it is lost, so is the
|
|
49
|
+
backup. After the first run, a machine with an OS secure store
|
|
50
|
+
keeps a key slot and does not ask for it again; a machine without one asks every
|
|
51
|
+
time, and says so. It can only be typed at a terminal: there is no flag and no
|
|
52
|
+
environment variable for it.
|
|
53
|
+
|
|
54
|
+
Environment (for CI and headless runs; \`login\` is the normal path):
|
|
55
|
+
AGMSG_CLOUD_ENDPOINT base URL of the hosted edge
|
|
56
|
+
AGMSG_CLOUD_SECRET this machine's capability secret
|
|
57
|
+
|
|
58
|
+
Both or neither: a stored secret is only ever sent to the endpoint it was
|
|
59
|
+
minted for, so one of these without the other is refused rather than mixed
|
|
60
|
+
with what \`login\` saved.
|
|
61
|
+
`;
|
|
62
|
+
// A flag whose value is missing must not silently swallow the NEXT flag:
|
|
63
|
+
// `login --endpoint --machine-name x` would otherwise send this machine's login
|
|
64
|
+
// to an endpoint called "--machine-name".
|
|
65
|
+
function flagValue(argv, flag) {
|
|
66
|
+
const i = argv.indexOf(flag);
|
|
67
|
+
if (i < 0)
|
|
68
|
+
return undefined;
|
|
69
|
+
const value = argv[i + 1];
|
|
70
|
+
if (value === undefined || value.startsWith('--')) {
|
|
71
|
+
throw new Error(`${flag} needs a value (it was given none, or the next argument is another flag)`);
|
|
72
|
+
}
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
async function main(argv) {
|
|
76
|
+
const [cmd, ...rest] = argv;
|
|
77
|
+
if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
78
|
+
process.stdout.write(USAGE);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
// `loadConfig()` is resolved PER COMMAND, not up front: `login` is the one
|
|
82
|
+
// subcommand that runs before a credential exists, and a top-level call would
|
|
83
|
+
// refuse it before its branch was ever reached. Every other command resolves
|
|
84
|
+
// the same config it always did, one step later.
|
|
85
|
+
switch (cmd) {
|
|
86
|
+
// Answers for THIS binary only, and resolves no config: someone asking what
|
|
87
|
+
// they are running is often asking because something else refused, and a
|
|
88
|
+
// version that needs a working credential to print cannot be read at the
|
|
89
|
+
// moment it is wanted.
|
|
90
|
+
case 'version':
|
|
91
|
+
case '--version':
|
|
92
|
+
case '-v': {
|
|
93
|
+
// `name/version`, not `name version`. The space form is shaped exactly
|
|
94
|
+
// like an invocation of this binary, so it reads as though the version
|
|
95
|
+
// were the subcommand — and the printed-commands check says the same
|
|
96
|
+
// thing from the other side, since a line beginning `agmsg-cloud ` is
|
|
97
|
+
// one it must assume someone will paste. Written without naming a
|
|
98
|
+
// version: an example that has to be bumped is a second place to bump,
|
|
99
|
+
// which is the reason this command reads the manifest at all.
|
|
100
|
+
process.stdout.write(`agmsg-cloud/${packageVersion()}\n`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
case 'login': {
|
|
104
|
+
// No endpoint means the official deployment. The flag stays for
|
|
105
|
+
// self-hosted and development stacks; either way the destination is
|
|
106
|
+
// printed before anything is sent to it.
|
|
107
|
+
const endpoint = flagValue(rest, '--endpoint');
|
|
108
|
+
const machineName = flagValue(rest, '--machine-name');
|
|
109
|
+
return cmdLogin({
|
|
110
|
+
...(endpoint === undefined ? {} : { endpoint }),
|
|
111
|
+
...(machineName === undefined ? {} : { machineName }),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
case 'logout': {
|
|
115
|
+
// Local only, and its own subcommand rather than a flag on `login`: the
|
|
116
|
+
// two do opposite things, and a flag that inverts a command is how
|
|
117
|
+
// someone signs out while meaning to sign in.
|
|
118
|
+
// Closed argv, not a filtered read: this removes a secret and defaults
|
|
119
|
+
// to the hosted service, so an argument it does not understand must not
|
|
120
|
+
// become "the default one".
|
|
121
|
+
const endpoint = logoutEndpoint(rest);
|
|
122
|
+
return cmdLogout(endpoint === undefined ? {} : { endpoint });
|
|
123
|
+
}
|
|
124
|
+
case 'connect': {
|
|
125
|
+
// The dry run resolves no credential: its job is to be usable before
|
|
126
|
+
// anything is set up.
|
|
127
|
+
if (rest.includes('--preflight'))
|
|
128
|
+
return cmdConnectPreflight(resolveScriptsDir());
|
|
129
|
+
const team = rest[0];
|
|
130
|
+
if (!team || team.startsWith('--')) {
|
|
131
|
+
throw new Error('usage: agmsg-cloud connect <team> (or: agmsg-cloud connect --preflight)');
|
|
132
|
+
}
|
|
133
|
+
return cmdConnect(loadConfig(), { team });
|
|
134
|
+
}
|
|
135
|
+
case 'pull': {
|
|
136
|
+
const team = rest[0];
|
|
137
|
+
if (!team || team.startsWith('--')) {
|
|
138
|
+
throw new Error('usage: agmsg-cloud pull <team> [--team-id <id>]');
|
|
139
|
+
}
|
|
140
|
+
const teamId = flagValue(rest, '--team-id');
|
|
141
|
+
return cmdPull(loadConfig(), { team, ...(teamId === undefined ? {} : { teamId }) });
|
|
142
|
+
}
|
|
143
|
+
case 'request': {
|
|
144
|
+
const label = rest[0];
|
|
145
|
+
if (!label)
|
|
146
|
+
throw new Error('usage: agmsg-cloud request <label>');
|
|
147
|
+
return cmdRequest(loadConfig(), { label });
|
|
148
|
+
}
|
|
149
|
+
case 'sync': {
|
|
150
|
+
// The whole second-machine path. `request`, `fetch` and `pull` remain as
|
|
151
|
+
// their own commands — someone recovering a half-finished join still
|
|
152
|
+
// needs to run one of them alone — but nobody has to know that on a
|
|
153
|
+
// first machine.
|
|
154
|
+
const team = rest[0];
|
|
155
|
+
if (!team)
|
|
156
|
+
throw new Error('usage: agmsg-cloud sync <team> [--label <name>] [--team-id <uuid>]');
|
|
157
|
+
const label = flagValue(rest, '--label');
|
|
158
|
+
const teamId = flagValue(rest, '--team-id');
|
|
159
|
+
return cmdSync(loadConfig(), {
|
|
160
|
+
team,
|
|
161
|
+
...(label === undefined ? {} : { label }),
|
|
162
|
+
...(teamId === undefined ? {} : { teamId }),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
case 'fetch': {
|
|
166
|
+
const team = rest[0];
|
|
167
|
+
if (!team)
|
|
168
|
+
throw new Error('usage: agmsg-cloud fetch <team>');
|
|
169
|
+
// `--confirm-digest` is no longer read. The digest is bound into the
|
|
170
|
+
// eight digits and recorded by `request` from the transcript it derived
|
|
171
|
+
// them over, so the bundle is checked against what the ceremony
|
|
172
|
+
// authenticated rather than against a string retyped from a phone call.
|
|
173
|
+
//
|
|
174
|
+
// Accepted and ignored rather than rejected: pasted instructions and
|
|
175
|
+
// scripts still carry it, and failing them would be a worse first
|
|
176
|
+
// impression than telling the truth about what happens now. It is said
|
|
177
|
+
// out loud, because a flag that is silently ignored is how someone keeps
|
|
178
|
+
// believing they are the check.
|
|
179
|
+
if (flagValue(rest, '--confirm-digest') !== undefined) {
|
|
180
|
+
process.stderr.write('--confirm-digest is no longer used, and was ignored.\n\n' +
|
|
181
|
+
'The bundle is now checked against the snapshot your eight-digit code\n' +
|
|
182
|
+
'authenticated, which this machine recorded when you compared it. Reading\n' +
|
|
183
|
+
'the digest aloud is not needed and no longer decides anything.\n\n');
|
|
184
|
+
}
|
|
185
|
+
return cmdFetch(loadConfig(), { team });
|
|
186
|
+
}
|
|
187
|
+
case 'approve': {
|
|
188
|
+
// The id is optional. With one machine waiting — the ordinary case — the
|
|
189
|
+
// approver has nothing to copy from another screen, which is the point:
|
|
190
|
+
// the fewer values a person carries between machines, the fewer of them
|
|
191
|
+
// are the check. With more than one waiting it refuses and lists them,
|
|
192
|
+
// because which one is on the other end of the call is not knowable here.
|
|
193
|
+
const [team, requestId] = rest;
|
|
194
|
+
if (!team)
|
|
195
|
+
throw new Error('usage: agmsg-cloud approve <team> [request-id]');
|
|
196
|
+
return cmdApprove(loadConfig(), requestId ? { team, requestId } : { team });
|
|
197
|
+
}
|
|
198
|
+
case 'watch': {
|
|
199
|
+
const i = rest.indexOf('--interval-ms');
|
|
200
|
+
const intervalMs = i >= 0 && rest[i + 1] ? Number(rest[i + 1]) : undefined;
|
|
201
|
+
if (intervalMs !== undefined && (!Number.isFinite(intervalMs) || intervalMs < 1000)) {
|
|
202
|
+
// A positive floor: a zero/negative interval would busy-loop the poller.
|
|
203
|
+
throw new Error('--interval-ms must be a number >= 1000');
|
|
204
|
+
}
|
|
205
|
+
return cmdWatch(loadConfig(), intervalMs === undefined ? {} : { intervalMs });
|
|
206
|
+
}
|
|
207
|
+
// `recovery`, not `vault`. `vault put <team>` reads as filing a copy of
|
|
208
|
+
// something that already exists; the command MINTS the recovery key —
|
|
209
|
+
// the one thing that opens the backup, shown once and stored nowhere.
|
|
210
|
+
// A name that hides that is a name someone runs without reading.
|
|
211
|
+
case 'recovery': {
|
|
212
|
+
const [sub, team] = rest;
|
|
213
|
+
if (sub !== 'setup' && sub !== 'restore') {
|
|
214
|
+
// The two take different arguments, so one line covering both said the
|
|
215
|
+
// wrong thing about each: `setup` does not need a team and `restore`
|
|
216
|
+
// does. A usage message is read as the contract (raised in review).
|
|
217
|
+
throw new Error('usage: agmsg-cloud recovery setup [team]\n' +
|
|
218
|
+
' agmsg-cloud recovery restore <team>');
|
|
219
|
+
}
|
|
220
|
+
// `setup` takes no team in its ordinary form: the vault is the ACCOUNT's,
|
|
221
|
+
// so setting it up covers every connected team, and naming one made it a
|
|
222
|
+
// thing to repeat each time a team was added. A team may still be given,
|
|
223
|
+
// to add that one on its own. `restore` still requires one — it unlocks a
|
|
224
|
+
// specific team on this machine.
|
|
225
|
+
//
|
|
226
|
+
// The excuse rides inside the slot, so it cannot apply to anything else.
|
|
227
|
+
if (sub === 'restore' && !team) {
|
|
228
|
+
throw new Error(`usage: agmsg-cloud recovery ${ /* printed-commands: a subcommand name this file chooses, narrowed to 'setup' | 'restore' two lines up — not a value anyone supplies, and quoting it would print `recovery 'setup'` */sub} <team>`);
|
|
229
|
+
}
|
|
230
|
+
const config = loadConfig();
|
|
231
|
+
return sub === 'setup'
|
|
232
|
+
? cmdVaultPut(config, team === undefined ? {} : { team })
|
|
233
|
+
: cmdVaultRestore(config, { team: team });
|
|
234
|
+
}
|
|
235
|
+
default:
|
|
236
|
+
throw new Error(`unknown command: ${cmd}\n\n${USAGE}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
main(process.argv.slice(2)).catch((err) => {
|
|
240
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
241
|
+
process.exitCode = 1;
|
|
242
|
+
});
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { chmodSync, mkdirSync, renameSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { readIfPresent, uniqueTempPath, withFileLock } from './filelock.js';
|
|
6
|
+
// The local attempt ledger of spec §4.1.
|
|
7
|
+
//
|
|
8
|
+
// WHY A LOCAL LEDGER EXISTS AT ALL. An attacker who can relay the ceremony can
|
|
9
|
+
// abort every attempt whose code does not match a value they guessed, and ask
|
|
10
|
+
// the user to "try again". Each retry is a fresh eight-digit guess, so without a
|
|
11
|
+
// bound the attacker eventually wins by repetition rather than by breaking
|
|
12
|
+
// anything. Counting the attempts is what turns an eight-digit code into an
|
|
13
|
+
// eight-digit code you only get five shots at.
|
|
14
|
+
//
|
|
15
|
+
// It has to be LOCAL because the server is inside the threat model. The server
|
|
16
|
+
// keeps its own counters as defense in depth, but a malicious one can simply
|
|
17
|
+
// ignore them, so those counters cannot be the bound. The same reasoning is why
|
|
18
|
+
// nothing here consults the network, and why a new server-supplied request id
|
|
19
|
+
// must never reset anything.
|
|
20
|
+
//
|
|
21
|
+
// It has to be DURABLE for the same reason: if restarting the CLI cleared the
|
|
22
|
+
// count, "try again" would just mean "restart the tool".
|
|
23
|
+
const WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
24
|
+
const MAX_ATTEMPTS = 5;
|
|
25
|
+
// §4.1: the interception warning starts at the SECOND failure in a window.
|
|
26
|
+
const WARN_AFTER_FAILURES = 2;
|
|
27
|
+
/**
|
|
28
|
+
* A ledger scope: canonical server origin plus a fingerprint of the local
|
|
29
|
+
* identity that owns the budget.
|
|
30
|
+
*
|
|
31
|
+
* §4.1 gives the two roles DIFFERENT identities on purpose, so they get
|
|
32
|
+
* different functions below rather than one that callers must remember to feed
|
|
33
|
+
* correctly. The role is part of the domain tag as well, so the two can never
|
|
34
|
+
* collide even if a deployment somehow used the same string for both.
|
|
35
|
+
*
|
|
36
|
+
* The identity is domain-separated and truncated rather than hashed raw: the
|
|
37
|
+
* approver's identity is a credential, and a filename must not carry a bare
|
|
38
|
+
* prefix of a credential digest that could be matched against one computed
|
|
39
|
+
* elsewhere.
|
|
40
|
+
*/
|
|
41
|
+
function scopeFor(role, serverOrigin, identity) {
|
|
42
|
+
const origin = new URL(serverOrigin).origin;
|
|
43
|
+
const fingerprint = createHash('sha256')
|
|
44
|
+
.update(`agmsg-cloud-ledger-scope-v1\0${role}\0`)
|
|
45
|
+
.update(identity)
|
|
46
|
+
.digest('hex')
|
|
47
|
+
.slice(0, 16);
|
|
48
|
+
return `${createHash('sha256').update(origin).digest('hex').slice(0, 16)}-${role[0]}${fingerprint}`;
|
|
49
|
+
}
|
|
50
|
+
// A's scope: "canonical server origin plus the fingerprint of the local
|
|
51
|
+
// capability URL". The capability secret is that URL's credential.
|
|
52
|
+
export function approverLedgerScope(serverOrigin, capabilitySecret) {
|
|
53
|
+
return scopeFor('approver', serverOrigin, capabilitySecret);
|
|
54
|
+
}
|
|
55
|
+
// B's scope: "the same local device identity and canonical server origin". The
|
|
56
|
+
// device public key is that identity — deliberately not the capability, because
|
|
57
|
+
// a machine that re-authenticates must not thereby get a fresh budget.
|
|
58
|
+
export function requesterLedgerScope(serverOrigin, devicePubkey) {
|
|
59
|
+
return scopeFor('requester', serverOrigin, devicePubkey);
|
|
60
|
+
}
|
|
61
|
+
export function ledgerPath(scope, env = process.env) {
|
|
62
|
+
const base = env['AGMSG_CLOUD_HOME'] ?? join(homedir(), '.agmsg-cloud');
|
|
63
|
+
return join(base, 'ledger', `${scope}.json`);
|
|
64
|
+
}
|
|
65
|
+
// A timestamp this code wrote: an ISO string that parses to a finite time and
|
|
66
|
+
// round-trips. Date.parse('garbage') is NaN, and NaN silently drops an attempt
|
|
67
|
+
// out of every window comparison — so an unvalidated timestamp is not a
|
|
68
|
+
// cosmetic problem, it is a budget reset.
|
|
69
|
+
function validTimestamp(value) {
|
|
70
|
+
if (typeof value !== 'string')
|
|
71
|
+
return false;
|
|
72
|
+
const ms = Date.parse(value);
|
|
73
|
+
if (!Number.isFinite(ms))
|
|
74
|
+
return false;
|
|
75
|
+
return new Date(ms).toISOString() === value;
|
|
76
|
+
}
|
|
77
|
+
function validAttempt(value) {
|
|
78
|
+
if (typeof value !== 'object' || value === null)
|
|
79
|
+
return false;
|
|
80
|
+
const a = value;
|
|
81
|
+
if (!validTimestamp(a['startedAt']))
|
|
82
|
+
return false;
|
|
83
|
+
if (typeof a['ceremony'] !== 'string' || a['ceremony'].length === 0)
|
|
84
|
+
return false;
|
|
85
|
+
if (a['outcome'] !== null && a['outcome'] !== 'succeeded' && a['outcome'] !== 'failed') {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
if (a['note'] !== undefined && typeof a['note'] !== 'string')
|
|
89
|
+
return false;
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
// Every field is checked, not just that the file is JSON.
|
|
93
|
+
//
|
|
94
|
+
// The earlier version only caught a parse failure, which meant a file that was
|
|
95
|
+
// syntactically valid and semantically nonsense — `startedAt: "garbage"` — was
|
|
96
|
+
// accepted and emptied the window. Rewriting one string was the cheapest
|
|
97
|
+
// possible way around the limit this file exists to impose. "Unreadable" has to
|
|
98
|
+
// mean "does not satisfy the shape", or the check is decoration.
|
|
99
|
+
function load(path, scope) {
|
|
100
|
+
const raw = readIfPresent(path);
|
|
101
|
+
if (raw === null)
|
|
102
|
+
return { version: 1, scope, attempts: [] };
|
|
103
|
+
let parsed;
|
|
104
|
+
try {
|
|
105
|
+
parsed = JSON.parse(raw);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
throw new Error(`attempt ledger is unreadable: ${path}`);
|
|
109
|
+
}
|
|
110
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
111
|
+
throw new Error(`attempt ledger is unreadable: ${path}`);
|
|
112
|
+
}
|
|
113
|
+
const doc = parsed;
|
|
114
|
+
if (doc['version'] !== 1 || !Array.isArray(doc['attempts'])) {
|
|
115
|
+
throw new Error(`attempt ledger is unreadable: ${path}`);
|
|
116
|
+
}
|
|
117
|
+
// The scope is in the file AND in the filename; disagreement means the file is
|
|
118
|
+
// not this scope's, and inventing an empty one in its place would hand the
|
|
119
|
+
// caller a fresh budget.
|
|
120
|
+
if (doc['scope'] !== scope) {
|
|
121
|
+
throw new Error(`attempt ledger does not belong to this scope: ${path}`);
|
|
122
|
+
}
|
|
123
|
+
for (const attempt of doc['attempts']) {
|
|
124
|
+
if (!validAttempt(attempt)) {
|
|
125
|
+
throw new Error(`attempt ledger is unreadable: ${path}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return doc;
|
|
129
|
+
}
|
|
130
|
+
function save(path, ledger) {
|
|
131
|
+
mkdirSync(join(path, '..'), { recursive: true, mode: 0o700 });
|
|
132
|
+
chmodSync(join(path, '..'), 0o700);
|
|
133
|
+
// A per-process temp name. A shared `${path}.tmp` is written by every process
|
|
134
|
+
// updating this file, so two of them rename over each other and one update
|
|
135
|
+
// vanishes — which for a ledger means an attempt that was charged is not.
|
|
136
|
+
const tmp = uniqueTempPath(path);
|
|
137
|
+
writeFileSync(tmp, `${JSON.stringify(ledger, null, 2)}\n`, { mode: 0o600 });
|
|
138
|
+
chmodSync(tmp, 0o600);
|
|
139
|
+
renameSync(tmp, path);
|
|
140
|
+
}
|
|
141
|
+
function withinWindow(ledger, now) {
|
|
142
|
+
return ledger.attempts.filter((a) => now - Date.parse(a.startedAt) < WINDOW_MS);
|
|
143
|
+
}
|
|
144
|
+
// The pure part, so a caller already holding the lock does not try to take it
|
|
145
|
+
// again. Re-entering would deadlock against itself.
|
|
146
|
+
function budgetOf(ledger, now) {
|
|
147
|
+
const live = withinWindow(ledger, now);
|
|
148
|
+
const failures = live.filter((a) => a.outcome === 'failed').length;
|
|
149
|
+
const open = live.find((a) => a.outcome === null) ?? null;
|
|
150
|
+
const oldest = live[0];
|
|
151
|
+
return {
|
|
152
|
+
used: live.length,
|
|
153
|
+
remaining: Math.max(0, MAX_ATTEMPTS - live.length),
|
|
154
|
+
failuresInWindow: failures,
|
|
155
|
+
openAttempt: open,
|
|
156
|
+
nextAvailableAt: live.length >= MAX_ATTEMPTS && oldest
|
|
157
|
+
? new Date(Date.parse(oldest.startedAt) + WINDOW_MS).toISOString()
|
|
158
|
+
: null,
|
|
159
|
+
shouldWarnAboutInterception: failures >= WARN_AFTER_FAILURES,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
export function readBudget(scope, env = process.env, now = Date.now()) {
|
|
163
|
+
const path = ledgerPath(scope, env);
|
|
164
|
+
return withFileLock(path, () => budgetOf(load(path, scope), now));
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Consume one attempt. MUST be called before the approver commitment leaves this
|
|
168
|
+
* machine (§4.1) — not after the reply, not on success.
|
|
169
|
+
*
|
|
170
|
+
* Charging on the reply would leave every network failure free, and "the server
|
|
171
|
+
* hung up" is something an attacker can produce at will. So the attempt is on
|
|
172
|
+
* the books before the bytes go out, and stays there whatever happens next.
|
|
173
|
+
*/
|
|
174
|
+
/**
|
|
175
|
+
* Consume one attempt for `ceremony`, or resume the one it already has.
|
|
176
|
+
*
|
|
177
|
+
* Resuming is not a loophole: the same ceremony means the same commitment going
|
|
178
|
+
* back out, which is one guess however many times the process died trying to
|
|
179
|
+
* finish it. Charging again would mean a crash loop spends the whole day's
|
|
180
|
+
* budget without a single completed comparison — and an attacker can cause
|
|
181
|
+
* crashes far more easily than they can guess eight digits.
|
|
182
|
+
*
|
|
183
|
+
* A DIFFERENT ceremony while one is open is still refused: §4.1 allows one
|
|
184
|
+
* nonterminal attempt per scope.
|
|
185
|
+
*/
|
|
186
|
+
export function consumeAttempt(scope, ceremony, env = process.env, now = Date.now(),
|
|
187
|
+
// True when this run would send a commitment this scope has ALREADY been
|
|
188
|
+
// charged for — same request, same bytes. The caller establishes it from the
|
|
189
|
+
// local reservation; see the note below for why that is the whole proof.
|
|
190
|
+
sameCommitment = false) {
|
|
191
|
+
const path = ledgerPath(scope, env);
|
|
192
|
+
// The whole read-decide-write happens under the lock. Deciding outside it and
|
|
193
|
+
// writing inside would be no better than not locking at all: two invocations
|
|
194
|
+
// would each read a ledger with budget left, each conclude they may proceed,
|
|
195
|
+
// and each send a commitment — spending two guesses while the file records one.
|
|
196
|
+
return withFileLock(path, () => {
|
|
197
|
+
const ledger = load(path, scope);
|
|
198
|
+
const before = budgetOf(ledger, now);
|
|
199
|
+
if (before.openAttempt) {
|
|
200
|
+
if (before.openAttempt.ceremony === ceremony) {
|
|
201
|
+
return { ok: true, budget: before, resumed: true };
|
|
202
|
+
}
|
|
203
|
+
return { ok: false, reason: 'attempt_already_open', budget: before };
|
|
204
|
+
}
|
|
205
|
+
// One reservation is one contribution is one attempt.
|
|
206
|
+
//
|
|
207
|
+
// §4.1 charges the commitment leaving A. What matters is not whether the
|
|
208
|
+
// earlier run got its bytes out — that would need the server, and the
|
|
209
|
+
// budget must be able to refuse before a single request goes out. It is
|
|
210
|
+
// that the bytes THIS run would send are the same bytes: the reservation
|
|
211
|
+
// holds nonce, digest and commitment together and is reused whole, so a
|
|
212
|
+
// second run of the same ceremony re-sends one guess the ledger already
|
|
213
|
+
// records. Charging again would count one contribution twice.
|
|
214
|
+
//
|
|
215
|
+
// The entry is reopened rather than duplicated, keeping its place in the
|
|
216
|
+
// window. It loses its recorded failure, and that is the intended meaning:
|
|
217
|
+
// `failuresInWindow` drives the interception warning, which counts
|
|
218
|
+
// comparisons that ENDED wrong, and this one has not ended — the run
|
|
219
|
+
// reopening it will close it again, as succeeded or as failed. An attacker
|
|
220
|
+
// retrying gets a NEW enrollment and a new reservation, so those still
|
|
221
|
+
// count separately (raised in review).
|
|
222
|
+
const alreadyCharged = sameCommitment
|
|
223
|
+
? withinWindow(ledger, now).find((a) => a.ceremony === ceremony)
|
|
224
|
+
: undefined;
|
|
225
|
+
if (alreadyCharged) {
|
|
226
|
+
alreadyCharged.outcome = null;
|
|
227
|
+
delete alreadyCharged.note;
|
|
228
|
+
save(path, ledger);
|
|
229
|
+
return { ok: true, budget: budgetOf(ledger, now), resumed: true };
|
|
230
|
+
}
|
|
231
|
+
if (before.remaining === 0)
|
|
232
|
+
return { ok: false, reason: 'budget_exhausted', budget: before };
|
|
233
|
+
ledger.attempts.push({ startedAt: new Date(now).toISOString(), ceremony, outcome: null });
|
|
234
|
+
// Pruned only on write, and only outside the window. Nothing removes an
|
|
235
|
+
// attempt because it succeeded or failed: §4.1 keeps both counted.
|
|
236
|
+
ledger.attempts = ledger.attempts.filter((a) => now - Date.parse(a.startedAt) < WINDOW_MS);
|
|
237
|
+
save(path, ledger);
|
|
238
|
+
return { ok: true, budget: budgetOf(ledger, now), resumed: false };
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Close the open attempt. It stays counted either way — a success closes the
|
|
243
|
+
* attempt, it does not refund it.
|
|
244
|
+
*/
|
|
245
|
+
/**
|
|
246
|
+
* Close the attempt belonging to `ceremony`. It stays counted either way — a
|
|
247
|
+
* success closes the attempt, it does not refund it.
|
|
248
|
+
*
|
|
249
|
+
* Closing by ceremony rather than "the most recent open one" is what makes a
|
|
250
|
+
* resumed attempt safe: whoever finishes closes the entry that was actually
|
|
251
|
+
* paid for, not whichever happened to be last.
|
|
252
|
+
*/
|
|
253
|
+
export function closeAttempt(scope, ceremony, outcome, note, env = process.env, now = Date.now()) {
|
|
254
|
+
const path = ledgerPath(scope, env);
|
|
255
|
+
return withFileLock(path, () => {
|
|
256
|
+
const ledger = load(path, scope);
|
|
257
|
+
for (let i = ledger.attempts.length - 1; i >= 0; i--) {
|
|
258
|
+
const attempt = ledger.attempts[i];
|
|
259
|
+
if (attempt.outcome === null && attempt.ceremony === ceremony) {
|
|
260
|
+
attempt.outcome = outcome;
|
|
261
|
+
if (note !== undefined)
|
|
262
|
+
attempt.note = note;
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
save(path, ledger);
|
|
267
|
+
return budgetOf(ledger, now);
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
// What the user is told. Kept here so the wording cannot drift between the two
|
|
271
|
+
// places that must show it (§4.1 requires the same warning from A and from B).
|
|
272
|
+
export function renderBudgetWarning(budget) {
|
|
273
|
+
if (!budget.shouldWarnAboutInterception)
|
|
274
|
+
return '';
|
|
275
|
+
return [
|
|
276
|
+
'',
|
|
277
|
+
` WARNING: ${budget.failuresInWindow} enrollment attempts have failed here in the`,
|
|
278
|
+
' last 24 hours. Repeated failures are what interception looks like from the',
|
|
279
|
+
' inside — someone relaying the ceremony has to make you retry until a code',
|
|
280
|
+
' they guessed comes up. Before trying again, confirm out of band that the',
|
|
281
|
+
' other machine really is the one you think it is.',
|
|
282
|
+
'',
|
|
283
|
+
].join('\n');
|
|
284
|
+
}
|
|
285
|
+
export function renderBudgetExhausted(budget) {
|
|
286
|
+
return [
|
|
287
|
+
'',
|
|
288
|
+
' Enrollment is blocked here: five attempts have been used in the last 24',
|
|
289
|
+
` hours. The next attempt becomes available at ${budget.nextAvailableAt}.`,
|
|
290
|
+
'',
|
|
291
|
+
' This limit is part of what makes an eight-digit code safe to rely on, so',
|
|
292
|
+
' there is no override. Waiting is the intended response; clearing local',
|
|
293
|
+
' security state is not part of enrolling a device.',
|
|
294
|
+
'',
|
|
295
|
+
].join('\n');
|
|
296
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { hostname } from 'node:os';
|
|
2
|
+
import { createInterface } from 'node:readline/promises';
|
|
3
|
+
// What this machine is called, and who gets to see it before it is used.
|
|
4
|
+
//
|
|
5
|
+
// The name is the ONLY thing that distinguishes one machine from another on
|
|
6
|
+
// the approver's screen. The eight digits say the machine on the other end is
|
|
7
|
+
// the one that started the ceremony; the name says WHICH machine that is. Two
|
|
8
|
+
// entries reading `mbp2024` make the second question unanswerable, and the
|
|
9
|
+
// approver's only honest move is to refuse a join that is probably fine.
|
|
10
|
+
//
|
|
11
|
+
// It happened in production, on a walkthrough: the same laptop, a second user
|
|
12
|
+
// account, and both machines named for the host.
|
|
13
|
+
//
|
|
14
|
+
// Defaulting to the hostname is not the bug. Defaulting to it WITHOUT SHOWING
|
|
15
|
+
// IT is: a collision is obvious the moment someone sees the name, and invisible
|
|
16
|
+
// until an approver is staring at two identical rows.
|
|
17
|
+
// The same class login.ts refused before this moved here, character for
|
|
18
|
+
// character. Two validators for one field is how a name is accepted by the
|
|
19
|
+
// prompt and then refused by the server after a browser approval was spent.
|
|
20
|
+
const CONTROL_CHARS_RE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
|
|
21
|
+
/** The hostname, normalized the same way a typed name is. */
|
|
22
|
+
export function defaultMachineName() {
|
|
23
|
+
return hostname().normalize('NFC').trim();
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Check a name and return it, or say what is wrong with it.
|
|
27
|
+
*
|
|
28
|
+
* The same bounds the server applies (edge/capability.ts validMachineName), so
|
|
29
|
+
* a name this accepts is not refused after a browser approval has been spent.
|
|
30
|
+
*/
|
|
31
|
+
export function validateMachineName(name) {
|
|
32
|
+
const cleaned = name.normalize('NFC').trim();
|
|
33
|
+
if (cleaned.length < 1 || cleaned.length > 128) {
|
|
34
|
+
throw new Error(`machine name must be 1-128 characters (got ${cleaned.length})`);
|
|
35
|
+
}
|
|
36
|
+
if (CONTROL_CHARS_RE.test(cleaned)) {
|
|
37
|
+
throw new Error('machine name must not contain control characters');
|
|
38
|
+
}
|
|
39
|
+
return cleaned;
|
|
40
|
+
}
|
|
41
|
+
// Pre-filled, not merely suggested. `rl.write(prefill)` puts the default on the
|
|
42
|
+
// input line where it can be edited or accepted with Enter — the operator sees
|
|
43
|
+
// the actual value rather than a placeholder they have to retype to change.
|
|
44
|
+
async function askAtTerminal(prompt, prefill) {
|
|
45
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
46
|
+
try {
|
|
47
|
+
const answer = rl.question(prompt);
|
|
48
|
+
rl.write(prefill);
|
|
49
|
+
return await answer;
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
rl.close();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Settle this machine's name: use what was given, or ask, or say what was
|
|
57
|
+
* assumed.
|
|
58
|
+
*
|
|
59
|
+
* Three paths, and the third is why this is not just a prompt:
|
|
60
|
+
*
|
|
61
|
+
* given an explicit --machine-name. Used as-is, no question. Someone
|
|
62
|
+
* who said what they wanted is not asked again.
|
|
63
|
+
* a terminal nothing given, and someone is there. Ask, with the hostname
|
|
64
|
+
* already typed in. This is the ordinary path.
|
|
65
|
+
* no tty nothing given, nobody there. Use the hostname AND SAY SO. CI
|
|
66
|
+
* and headless runs must not hang on a question nobody can
|
|
67
|
+
* answer — the same call `recovery setup` makes about the
|
|
68
|
+
* recovery key — but a name chosen silently is how this got
|
|
69
|
+
* missed in the first place, so the run reports what it used.
|
|
70
|
+
*/
|
|
71
|
+
export async function settleMachineName(given, deps = {}) {
|
|
72
|
+
if (given !== undefined)
|
|
73
|
+
return validateMachineName(given);
|
|
74
|
+
const out = deps.out ?? ((text) => void process.stdout.write(text));
|
|
75
|
+
const isTty = deps.isTty ?? (() => Boolean(process.stdin.isTTY && process.stdout.isTTY));
|
|
76
|
+
const fallback = defaultMachineName();
|
|
77
|
+
if (!isTty()) {
|
|
78
|
+
out(`No machine name given and no terminal to ask at — using "${fallback}".\n`);
|
|
79
|
+
return validateMachineName(fallback);
|
|
80
|
+
}
|
|
81
|
+
const ask = deps.ask ?? askAtTerminal;
|
|
82
|
+
out('\nThis name is what an approver sees when this machine asks to join a team,\n');
|
|
83
|
+
out('and it is the only thing that tells one machine from another there.\n\n');
|
|
84
|
+
const answer = await ask('Machine name: ', fallback);
|
|
85
|
+
// An empty answer is the default, not an error: someone who pressed Enter
|
|
86
|
+
// through a pre-filled line meant to accept it, and a cleared line means the
|
|
87
|
+
// same thing to anyone who is not reading the source.
|
|
88
|
+
const chosen = answer.trim().length === 0 ? fallback : answer;
|
|
89
|
+
return validateMachineName(chosen);
|
|
90
|
+
}
|