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