@bitspark/archon-cli 0.6.1

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.
@@ -0,0 +1,777 @@
1
+ // archon login — prove possession of your key to a service, so a browser key it names may
2
+ // act for you within a scope you are shown BEFORE signing.
3
+ //
4
+ // archon login <url> [--key <name> | --seed <hex> | --key-file <pkcs8.pem> | --seed-file <file>]
5
+ // [--authority-file <file>] [--yes]
6
+ // archon login --audience <base> [--scope <entry>]... --valid-for <seconds> [custody] [--authority-file <file>]
7
+ //
8
+ // This module owns the TRANSPORT (HTTP/JSON), the DISPLAY (the statement the person
9
+ // confirms), and the FLOW. It owns NO scheme: the binding layout and the proof are
10
+ // sdk/ts/login's (archon#16, seat:cca authorship comment 2026-09-10T13:01Z), reached
11
+ // through the single seam proveLogin below. Nothing here computes signed bytes — a binding
12
+ // written twice is a binding that drifts.
13
+ //
14
+ // TWO FORMS, one command. With a URL (docs/login.md §4) the page started and the person
15
+ // finishes here: the audience is DERIVED FROM THE INVOCATION URL, never read from the wire
16
+ // (archon#16 Finding 1: a server that may name its own audience can name someone else's),
17
+ // the statement is shown, and nothing is signed until the person says yes. With no URL
18
+ // (§4.1, the offers form) the CLI starts and the page finishes: the audience is the CLI's
19
+ // OWN configuration, the CLI offers exactly what was typed, answers only the request that
20
+ // took its offer, asks no confirmation, and prints the ledger of that decision afterwards.
21
+ //
22
+ // HTTP is the platform's `fetch` (Node 18+), so this lane adds no dependency at all.
23
+ import { createInterface } from "node:readline";
24
+ import { randomBytes } from "node:crypto";
25
+ import { readFileSync } from "node:fs";
26
+ import { encodeKey, decodeKey, getPublicKey } from "@bitspark/archon";
27
+ // Aliased on import: the scheme exports its own `LoginRequest` (bytes, camelCase) and its
28
+ // own `proveLogin`. Those are the SCHEME's types; the ones in this file are the WIRE's
29
+ // (strings, snake_case, straight off the JSON). Keeping both names visible and distinct is
30
+ // the point — converting between them is this seam's entire job.
31
+ import { MIN_NONCE_SIZE as SCHEME_MIN_NONCE_SIZE, deriveAudience, proveLogin as schemeProveLogin, } from "@bitspark/archon-sdk";
32
+ import { resolveSeed, wantsHelp } from "../io.js";
33
+ import * as store from "./key_store.js";
34
+ const USAGE = "usage: archon login <url> [--key <name> | --seed <hex> | --key-file <pkcs8.pem> | --seed-file <file>] " +
35
+ "[--authority-file <file>] [--yes]\n" +
36
+ " archon login --audience <base> [--scope <entry>]... --valid-for <seconds> " +
37
+ "[--key <name> | --seed <hex> | --key-file <pkcs8.pem> | --seed-file <file>] [--authority-file <file>]\n " +
38
+ "proves possession of your key to the service at <url> so the browser key it names may act for you. " +
39
+ "<url> is the invocation URL <audience>/login/<id>; the audience is derived from it, never taken from the server. " +
40
+ "--key names a key in the store and is the default (archon key default); --key-file is a PKCS#8 file. " +
41
+ "Store password: interactive prompt, or ARCHON_KEY_PASSWORD / --password-fd <n>, never argv.\n " +
42
+ "with no URL, the CLI OFFERS what you typed and the page finishes: the audience is --audience or ARCHON_AUDIENCE, " +
43
+ "never a page's word; the code and the page address go to stderr, the ledger to stdout after the service answers; " +
44
+ "no confirmation is asked — what you typed is what you sign.";
45
+ const realIo = {
46
+ writeErr: (text) => {
47
+ process.stderr.write(text);
48
+ },
49
+ sleep: (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000)),
50
+ now: () => Math.floor(Date.now() / 1000),
51
+ };
52
+ // The signing domain (archon-login/1) is deliberately NOT declared here. It is the
53
+ // scheme's, applied inside sdk/ts/login, and a copy in the CLI would be a second place it
54
+ // could drift from.
55
+ /** The SCHEME's floor, cited rather than restated: the sdk's MIN_NONCE_SIZE is where it is
56
+ * decided, and a local 16 would be a second place it could move from. It is checked HERE,
57
+ * and not only at signing time, because a short nonce must be refused before the person is
58
+ * asked to confirm — the check's POSITION is this lane's, its VALUE is not. */
59
+ const MIN_NONCE_SIZE = SCHEME_MIN_NONCE_SIZE;
60
+ /** Stands in the scope position when the request delegates nothing. */
61
+ const NO_SCOPE_LINE = "(no scope entries — the service asks only for proof of your key)";
62
+ /** The fields this lane understands. A service sending anything else — an `audience` above
63
+ * all — is speaking a protocol we do not, and is refused rather than half-read. */
64
+ const KNOWN_REQUEST_FIELDS = new Set(["id", "nonce", "browser", "scope", "valid_for", "expires"]);
65
+ /** Settles the source: exactly one flag, or none and the store's default. It never guesses
66
+ * a seed file — the only fallback is the pointer the person set with `archon key default`.
67
+ * A named store key is checked to EXIST here (a stat, opening nothing), so a typo is
68
+ * refused before a pointless fetch; the password and the unlock still wait for consent. */
69
+ export function decideLoginSource(src) {
70
+ const given = [src.seedHex, src.keyFile, src.seedFile, src.storeKey].filter((v) => v !== undefined).length;
71
+ if (given > 1)
72
+ throw new Error(`--key, --seed, --key-file and --seed-file are mutually exclusive\n${USAGE}`);
73
+ if (given === 0) {
74
+ const name = store.readDefaultKeyName();
75
+ if (name === undefined) {
76
+ throw new Error("no default key is set\n pass --key <name>, --seed <hex>, --key-file <file> or --seed-file <file>, " +
77
+ "or choose one with: archon key default <name>");
78
+ }
79
+ src.storeKey = name;
80
+ }
81
+ if (src.storeKey !== undefined)
82
+ store.requireNamedKey(src.storeKey);
83
+ }
84
+ /** Entry point. The order is the security order and is not an accident: derive the
85
+ * audience, fetch, validate, SHOW, confirm, only then unlock and sign.
86
+ *
87
+ * Everything decidable WITHOUT the network — which custody signs, whether the flags agree,
88
+ * whether a named store key exists, whether the authority file is readable — is decided
89
+ * first, so those refusals land before a request is made and before the person reads a
90
+ * statement they could not have signed.
91
+ *
92
+ * `write` is where the statement and the outcome go — stdout by default, and injectable so
93
+ * a test can pin what a person would have seen WITHOUT patching process.stdout.write. Under
94
+ * `node --test` the test file is a child that reports to the runner over its own stdout, so
95
+ * a patch there swallows the report and silently drops tests from the run. */
96
+ export async function run(args, write = (text) => {
97
+ process.stdout.write(text);
98
+ }, io = realIo) {
99
+ if (wantsHelp(args)) {
100
+ write(`${USAGE}\n`);
101
+ return;
102
+ }
103
+ const rawUrl = args[0];
104
+ if (rawUrl === undefined)
105
+ throw new Error(USAGE);
106
+ // No URL: the offers form (docs/login.md §4.1). The CLI starts, the page finishes.
107
+ if (rawUrl.startsWith("--"))
108
+ return runOffer(args, write, io);
109
+ // The password descriptor is the STORE's flag, taken out first exactly as `key add` does,
110
+ // so login sources a password the one way the store does.
111
+ const { rest, fd } = store.takePasswordFd(args.slice(1));
112
+ const src = {};
113
+ let authorityFile;
114
+ let assumeYes = false;
115
+ for (let i = 0; i < rest.length; i++) {
116
+ const flag = rest[i];
117
+ if (flag === "--yes") {
118
+ assumeYes = true;
119
+ continue;
120
+ }
121
+ const value = rest[i + 1];
122
+ if (value === undefined || value === "")
123
+ throw new Error(`flag ${JSON.stringify(flag)} needs a value\n${USAGE}`);
124
+ i++;
125
+ switch (flag) {
126
+ case "--key":
127
+ src.storeKey = value;
128
+ break;
129
+ case "--seed":
130
+ src.seedHex = value;
131
+ break;
132
+ case "--key-file":
133
+ src.keyFile = value;
134
+ break;
135
+ case "--seed-file":
136
+ src.seedFile = value;
137
+ break;
138
+ case "--authority-file":
139
+ authorityFile = value;
140
+ break;
141
+ default: throw new Error(`unknown flag ${JSON.stringify(flag)}\n${USAGE}`);
142
+ }
143
+ }
144
+ decideLoginSource(src);
145
+ // --password-fd belongs to the store. Beside a seed file it would be silently ignored, and
146
+ // a flag that does nothing is a flag someone will come to rely on.
147
+ if (fd !== undefined && src.storeKey === undefined) {
148
+ throw new Error(`--password-fd applies only to a store key (--key, or the default)\n${USAGE}`);
149
+ }
150
+ // `confirm` reads stdin, so a password on fd 0 would be read by the prompt first. The
151
+ // store's own commands never confirm; this is the one place the two meet.
152
+ if (fd === 0 && !assumeYes) {
153
+ throw new Error("--password-fd 0 puts the password on stdin, which the sign? prompt reads first; pass --yes with it");
154
+ }
155
+ // Read here, not after consent: a missing authority file is refused before the person has
156
+ // read a statement and said yes to it. The payload stays opaque (see readAuthority).
157
+ const authority = readAuthority(authorityFile);
158
+ // THE DERIVATION IS THE SCHEME'S (docs/login.md §2.1, sdk/ts/login). It used to live in
159
+ // this file, in three lanes, and the three disagreed: net/url decoded the path and kept a
160
+ // default port, this lane's WHATWG URL dropped the port, a hand-rolled split kept
161
+ // userinfo. The audience is the FIRST FIELD OF THE BINDING, so one URL must yield one
162
+ // audience everywhere — which makes it the scheme's job and not a CLI's. The WHATWG URL is
163
+ // gone from this file with it.
164
+ const { audience, id: idBytes } = deriveAudience(rawUrl);
165
+ // The id crosses the wire as hex (§4) and is bound as bytes. Re-encoding what the scheme
166
+ // handed back is exact rather than convenient: the grammar admits only lowercase hex, so
167
+ // this round-trips the URL's own segment and is the value the service will echo.
168
+ const id = toHex(idBytes);
169
+ const request = await fetchLoginRequest(audience, id);
170
+ validateLoginRequest(request, id);
171
+ // SHOW BEFORE SIGN. The person confirms the statement, not the URL.
172
+ const keySource = describeKeySource(src);
173
+ write(renderStatement(audience, request, io.now(), keySource));
174
+ if (!assumeYes && !(await confirm())) {
175
+ write("refused. nothing was signed.\n");
176
+ return;
177
+ }
178
+ // ONLY NOW is the key touched. For a store key this is where the password is asked for.
179
+ const seed = resolveLoginSeed(src, fd);
180
+ const { proof, principal } = proveLogin(seed, audience, request);
181
+ await postLoginAnswer(audience, id, {
182
+ principal,
183
+ possession: toHex(proof),
184
+ authority: toHex(authority),
185
+ });
186
+ write(`signed as ${principal}. the browser is in.\n`);
187
+ }
188
+ /** Refuse a malformed request BEFORE anything is displayed, so the person is never shown a
189
+ * statement built from junk. Every refusal names the field. */
190
+ export function validateLoginRequest(r, wantId) {
191
+ if (r.id !== wantId)
192
+ throw new Error(`login: the service answered for request ${JSON.stringify(r.id)}, not ${JSON.stringify(wantId)}`);
193
+ if (!/^[0-9a-fA-F]*$/.test(r.nonce) || r.nonce.length % 2 !== 0)
194
+ throw new Error("login: nonce is not hex");
195
+ if (r.nonce.length / 2 < MIN_NONCE_SIZE) {
196
+ throw new Error(`login: nonce is ${r.nonce.length / 2} bytes, min ${MIN_NONCE_SIZE} — refusing a guessable challenge`);
197
+ }
198
+ try {
199
+ decodeKey(r.browser);
200
+ }
201
+ catch (err) {
202
+ throw new Error(`login: browser key ${JSON.stringify(r.browser)} is not canonical key text: ${err instanceof Error ? err.message : String(err)}`);
203
+ }
204
+ if (!Array.isArray(r.scope))
205
+ throw new Error("login: scope is not a list");
206
+ r.scope.forEach((entry, i) => {
207
+ if (typeof entry !== "string" || entry === "")
208
+ throw new Error(`login: scope entry ${i} is empty`);
209
+ refuseUndisplayable(`scope entry ${i}`, entry);
210
+ });
211
+ if (!Number.isInteger(r.valid_for) || r.valid_for <= 0) {
212
+ throw new Error("login: valid_for is 0 — a delegation dead on arrival");
213
+ }
214
+ }
215
+ /** Reject C0/DEL control characters in anything the person will be shown. A scope entry
216
+ * carrying an escape sequence can repaint the terminal and hide what is really being
217
+ * signed, so display safety is a validation concern, not a cosmetic one. */
218
+ function refuseUndisplayable(field, s) {
219
+ // Lone surrogates survive JSON.parse and are NOT valid UTF-8; the scheme's check_text
220
+ // refuses them at binding time, which is after the person has already agreed. Refusing
221
+ // here means a request that could lie on screen never reaches the confirm prompt.
222
+ if (/\p{Surrogate}/u.test(s)) {
223
+ throw new Error(`login: ${field} is not valid UTF-8 — refusing`);
224
+ }
225
+ for (const ch of s) {
226
+ const code = ch.codePointAt(0);
227
+ if (code < 0x20 || code === 0x7f) {
228
+ throw new Error(`login: ${field} contains a control character — refusing`);
229
+ }
230
+ }
231
+ }
232
+ /** EXACTLY what the person is asked to approve, and the text all three lanes must print
233
+ * byte-identically. nowSeconds is a parameter so the wall-clock end is testable. */
234
+ export function renderStatement(audience, r, nowSeconds, keySource) {
235
+ const lines = [`${audience} asks you to let browser key ${r.browser} act as you:`, ...scopeAndValidityLines(r, nowSeconds)];
236
+ lines.push(`signing with ${keySource}`);
237
+ return `${lines.join("\n")}\n`;
238
+ }
239
+ /** The offers form's counterpart (docs/login.md §4.1 rule 4): the same fields as the
240
+ * statement, printed AFTER answering rather than before signing, because in that form nobody
241
+ * confirmed — the person typed the scope and the audience is the CLI's own. It names K as the
242
+ * request delivered it, the source that signed, and the service's verdict (`undefined` for an
243
+ * accepted answer, else the code). Never the code: the ledger goes to stdout, and stdout may
244
+ * be a log. */
245
+ export function renderLedger(audience, r, nowSeconds, keySource, refused) {
246
+ const lines = [`you offered ${audience} to let browser key ${r.browser} act as you:`, ...scopeAndValidityLines(r, nowSeconds)];
247
+ lines.push(`signed with ${keySource}`);
248
+ lines.push(refused === undefined
249
+ ? "the service accepted the login. the browser is in."
250
+ : `the service refused the login (${refused}). the browser is not in.`);
251
+ return `${lines.join("\n")}\n`;
252
+ }
253
+ /** The middle of both renderings — every scope entry verbatim, in order, then the validity as
254
+ * a duration and as a wall-clock end — written once so the two forms cannot drift from each
255
+ * other in the lines they share. */
256
+ function scopeAndValidityLines(r, nowSeconds) {
257
+ const end = Math.floor(nowSeconds) + r.valid_for;
258
+ const lines = [];
259
+ // An EMPTY scope is valid (docs/login.md §3.1: 0..=65535 entries) — a proof-only service
260
+ // asks for possession and delegates nothing. It still gets a line, because a statement
261
+ // that silently showed nothing where the scope goes would read as a rendering bug at
262
+ // exactly the moment the person is deciding what to sign.
263
+ if (r.scope.length === 0)
264
+ lines.push(` ${NO_SCOPE_LINE}`);
265
+ for (const entry of r.scope)
266
+ lines.push(` ${entry}`);
267
+ lines.push(`for ${formatDuration(r.valid_for)}, until ${formatRfc3339Utc(end)}`);
268
+ return lines;
269
+ }
270
+ /** Name the custody the signature will come from, for the last line of the statement
271
+ * (seat:cca ruling, 2026-09-10).
272
+ *
273
+ * It is the SOURCE and not the principal, on purpose: naming the principal would mean
274
+ * unlocking the key before the person has agreed to sign — which, for a password-protected
275
+ * store, means demanding a password in order to show someone what they are being asked to
276
+ * approve. The source is known without touching the key at all.
277
+ *
278
+ * A store key is named by its NAME, whether --key chose it or the default pointer did: the
279
+ * statement says what will sign, and how the name was chosen is not part of what is being
280
+ * approved. The pinned wording is cca's (2026-09-10 20:47Z). */
281
+ export function describeKeySource(src) {
282
+ if (src.storeKey !== undefined)
283
+ return `the store key ${src.storeKey}`;
284
+ if (src.seedFile !== undefined)
285
+ return `the seed file ${src.seedFile}`;
286
+ if (src.keyFile !== undefined)
287
+ return `the key file ${src.keyFile}`;
288
+ if (src.seedHex !== undefined)
289
+ return "the seed given on the command line";
290
+ return "an unspecified key";
291
+ }
292
+ /** Render seconds the same way in every lane. Written out explicitly so Go and Rust
293
+ * reproduce it exactly — a shared format nobody has to reverse-engineer. */
294
+ export function formatDuration(seconds) {
295
+ const h = Math.floor(seconds / 3600);
296
+ const m = Math.floor((seconds % 3600) / 60);
297
+ const s = seconds % 60;
298
+ if (h > 0)
299
+ return `${h}h${m}m${s}s`;
300
+ if (m > 0)
301
+ return `${m}m${s}s`;
302
+ return `${s}s`;
303
+ }
304
+ /** Format a Unix timestamp as YYYY-MM-DDTHH:MM:SSZ, matching the other two lanes. */
305
+ export function formatRfc3339Utc(unixSeconds) {
306
+ return new Date(unixSeconds * 1000).toISOString().replace(/\.\d{3}Z$/, "Z");
307
+ }
308
+ /** Read a y/N answer. Default is NO: anything that is not an explicit yes refuses,
309
+ * including EOF, so a login cannot be completed by a closed stdin. */
310
+ export async function confirm() {
311
+ process.stdout.write("sign? [y/N] ");
312
+ const rl = createInterface({ input: process.stdin });
313
+ const line = await new Promise((resolve) => {
314
+ rl.once("line", (value) => resolve(value));
315
+ rl.once("close", () => resolve(""));
316
+ });
317
+ rl.close();
318
+ const answer = line.trim().toLowerCase();
319
+ return answer === "y" || answer === "yes";
320
+ }
321
+ /** Obtains the seed for the source decided at flag-parse time. Runs ONLY after the person
322
+ * has confirmed the statement: for a store key that is the moment the password is asked
323
+ * for, and never before. Seed files stay beside the store permanently, so an agent's
324
+ * non-interactive run and a person's login are the same code with two custody sources
325
+ * (ADR 0007 §A; archon#16, answer 2). */
326
+ function resolveLoginSeed(src, fd) {
327
+ if (src.storeKey !== undefined)
328
+ return store.unlockNamedKey(src.storeKey, fd);
329
+ if (src.seedFile !== undefined)
330
+ return seedFromHexFile(readFileSync(src.seedFile, "utf8"));
331
+ return resolveSeed(src.seedHex, src.keyFile, USAGE);
332
+ }
333
+ /** Accept the two hex seed-file shapes in use: 64 hex characters (a raw 32-byte seed) and
334
+ * 128 (seed followed by public key), the shape the first consumer's `key` files carry (answer 4). */
335
+ export function seedFromHexFile(text) {
336
+ const trimmed = text.trim();
337
+ if (!/^[0-9a-fA-F]*$/.test(trimmed) || trimmed.length % 2 !== 0)
338
+ throw new Error("seed file is not hex");
339
+ const raw = fromHex(trimmed);
340
+ if (raw.length !== 32 && raw.length !== 64) {
341
+ throw new Error(`seed file holds ${raw.length} bytes; want 32 (seed) or 64 (seed and public key)`);
342
+ }
343
+ return raw.slice(0, 32);
344
+ }
345
+ /** Read the authority payload. It is OPAQUE to archon — the delegation's meaning is the
346
+ * law's (thesmos), and this command must never parse it. Absent is empty. */
347
+ function readAuthority(path) {
348
+ if (path === undefined)
349
+ return new Uint8Array();
350
+ return new Uint8Array(readFileSync(path));
351
+ }
352
+ function toHex(bytes) {
353
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
354
+ }
355
+ function fromHex(text) {
356
+ const out = new Uint8Array(text.length / 2);
357
+ for (let i = 0; i < out.length; i++)
358
+ out[i] = Number.parseInt(text.slice(i * 2, i * 2 + 2), 16);
359
+ return out;
360
+ }
361
+ /** GET the request. The CLI owns HTTP; the SDK never opens a socket (archon#16). */
362
+ export async function fetchLoginRequest(audience, id) {
363
+ const endpoint = `${audience}/login/${encodeURIComponent(id)}`;
364
+ let response;
365
+ try {
366
+ response = await fetch(endpoint);
367
+ }
368
+ catch (err) {
369
+ throw new Error(`login: could not reach ${endpoint}: ${err instanceof Error ? err.message : String(err)}`);
370
+ }
371
+ const body = await response.text();
372
+ if (!response.ok)
373
+ throw new Error(loginHttpError(response.status, body));
374
+ let parsed;
375
+ try {
376
+ parsed = JSON.parse(body);
377
+ }
378
+ catch (err) {
379
+ throw new Error(`login: the service's request is not the expected JSON: ${err instanceof Error ? err.message : String(err)}`);
380
+ }
381
+ if (parsed === null || typeof parsed !== "object")
382
+ throw new Error("login: the service's request is not a JSON object");
383
+ for (const field of Object.keys(parsed)) {
384
+ if (!KNOWN_REQUEST_FIELDS.has(field)) {
385
+ throw new Error(`login: the service's request carries an unknown field ${JSON.stringify(field)} — refusing`);
386
+ }
387
+ }
388
+ return parsed;
389
+ }
390
+ /** Deliver the answer and report the service's verdict as an error, which is what the
391
+ * confirmed form wants: a refusal ends the command. */
392
+ export async function postLoginAnswer(audience, id, answer) {
393
+ const { status, body } = await postAnswer(audience, id, answer);
394
+ if (status !== 204 && status !== 200)
395
+ throw new Error(loginHttpError(status, body));
396
+ }
397
+ /** The POST itself, returning the service's status and body so the offers form can record a
398
+ * refusal in its ledger rather than stop on it (§4.1 rule 4: the ledger is printed whether the
399
+ * service accepted or refused). Only a failure to reach the service throws — then nothing was
400
+ * answered, and there is nothing to record. */
401
+ async function postAnswer(audience, id, answer) {
402
+ const endpoint = `${audience}/login/${encodeURIComponent(id)}/answer`;
403
+ let response;
404
+ try {
405
+ response = await fetch(endpoint, {
406
+ method: "POST",
407
+ headers: { "content-type": "application/json" },
408
+ body: JSON.stringify(answer),
409
+ });
410
+ }
411
+ catch (err) {
412
+ throw new Error(`login: could not reach ${endpoint}: ${err instanceof Error ? err.message : String(err)}`);
413
+ }
414
+ return { status: response.status, body: await response.text() };
415
+ }
416
+ // ---------------------------------------------------------------------------
417
+ // THE OFFERS FORM (docs/login.md §4.1)
418
+ // ---------------------------------------------------------------------------
419
+ //
420
+ // `archon login` with no URL. The prover starts: it mints a code from its own entropy,
421
+ // registers what it is willing to delegate, prints the code where only the person can see it,
422
+ // waits for the page to take the offer, and answers ONLY the request that took it — after
423
+ // checking for itself that the request is what was offered. No confirmation is asked: the
424
+ // person typed the scope, the audience is the CLI's own, and the only request it will answer
425
+ // carries the code it minted a moment ago. Afterwards it prints the ledger of that decision,
426
+ // whether the service accepted or refused.
427
+ /** How much of the CLI's own entropy a code carries: 16 bytes, spelled as 32 lowercase hex
428
+ * characters, the floor §4.1 sets. The code is confidential until the offer is taken. */
429
+ const CODE_BYTES = 16;
430
+ const KNOWN_OFFER_FIELDS = new Set(["code", "scope", "valid_for", "expires_in", "interval", "page"]);
431
+ const KNOWN_OFFER_READ_FIELDS = new Set(["code", "scope", "valid_for", "request", "expires"]);
432
+ /** The offers form. The order is §4.1's, rule by rule, and the same before-any-network
433
+ * discipline as the confirmed form: everything decidable without the service — the custody,
434
+ * the authority file, the scope, the validity, the audience — is decided first, so those
435
+ * refusals land before an offer exists. */
436
+ export async function runOffer(args, write, io) {
437
+ // The password descriptor is the STORE's flag, taken out first exactly as `key add` does.
438
+ // `--password-fd 0` needs no `--yes` here: nothing in this form reads stdin.
439
+ const { rest, fd } = store.takePasswordFd(args);
440
+ const src = {};
441
+ let authorityFile;
442
+ let audienceFlag;
443
+ let validForText;
444
+ const scope = [];
445
+ for (let i = 0; i < rest.length; i++) {
446
+ const flag = rest[i];
447
+ if (flag === "--yes") {
448
+ // A flag that does nothing is a flag someone will come to rely on — and this one would
449
+ // suggest a confirmation exists to skip.
450
+ throw new Error(`no confirmation is asked in this form — what you typed is what you sign; drop --yes\n${USAGE}`);
451
+ }
452
+ const value = rest[i + 1];
453
+ if (value === undefined || value === "")
454
+ throw new Error(`flag ${JSON.stringify(flag)} needs a value\n${USAGE}`);
455
+ i++;
456
+ switch (flag) {
457
+ case "--audience":
458
+ audienceFlag = value;
459
+ break;
460
+ case "--scope":
461
+ scope.push(value);
462
+ break;
463
+ case "--valid-for":
464
+ validForText = value;
465
+ break;
466
+ case "--key":
467
+ src.storeKey = value;
468
+ break;
469
+ case "--seed":
470
+ src.seedHex = value;
471
+ break;
472
+ case "--key-file":
473
+ src.keyFile = value;
474
+ break;
475
+ case "--seed-file":
476
+ src.seedFile = value;
477
+ break;
478
+ case "--authority-file":
479
+ authorityFile = value;
480
+ break;
481
+ default: throw new Error(`unknown flag ${JSON.stringify(flag)}\n${USAGE}`);
482
+ }
483
+ }
484
+ decideLoginSource(src);
485
+ if (fd !== undefined && src.storeKey === undefined) {
486
+ throw new Error(`--password-fd applies only to a store key (--key, or the default)\n${USAGE}`);
487
+ }
488
+ const authority = readAuthority(authorityFile);
489
+ // WHAT YOU TYPED IS WHAT YOU SIGN — so what was typed is checked the way the service will
490
+ // check it, here, before an offer nobody could begin on is registered.
491
+ scope.forEach((entry, i) => {
492
+ if (entry === "")
493
+ throw new Error(`login: --scope entry ${i} is empty`);
494
+ refuseUndisplayable(`--scope entry ${i}`, entry);
495
+ });
496
+ const validFor = parseValidFor(validForText);
497
+ // RULE 1: the audience is the CLI's own configuration, a fixed point of §2.1's grammar,
498
+ // refused before anything is fetched.
499
+ const audience = configuredAudience(audienceFlag);
500
+ // The code is the CLI's own entropy, registered under it. The service echoes the offer back;
501
+ // an echo that differs is a service that altered what was offered, and nothing of it is
502
+ // trusted from here on.
503
+ const code = mintCode();
504
+ const offered = await postOffer(audience, { code, scope, valid_for: validFor });
505
+ checkOfferEcho(offered, code, scope, validFor);
506
+ // STDERR, deliberately: the interactive channel, where the password prompt already lives.
507
+ // `archon login … > file` must never write the code into a log (§4.1 rule 1).
508
+ io.writeErr(`offer registered at ${audience}\n`);
509
+ io.writeErr(`code: ${code}\n`);
510
+ if (offered.page !== undefined)
511
+ io.writeErr(`${describePage(audience, offered.page)}\n`);
512
+ io.writeErr(`waiting for the page to take the offer, up to ${offered.expires_in}s\n`);
513
+ // The prover paces ITSELF (ADR 0007 §C.7, #39): the route is unpaced because two parties
514
+ // poll it, so the discipline is here — one interval before the first poll, so the page
515
+ // always has the first window, and one between polls.
516
+ const id = await pollOffer(audience, code, offered, io);
517
+ // RULE 2: answer only the request the offer names, and only after re-checking it against
518
+ // what was offered — never trusting that the service's refusal happened. K is RECORDED from
519
+ // the request; it was never offered, so it is not checked, and it is what the ledger names.
520
+ const request = await fetchLoginRequest(audience, id);
521
+ validateLoginRequest(request, id);
522
+ checkAgainstOffer(request, scope, validFor);
523
+ // RULE 3: no confirmation. The key is unlocked now — for a store key this is where the
524
+ // password is asked for, and a person who never finishes never types it — and the proof
525
+ // made and posted.
526
+ const seed = resolveLoginSeed(src, fd);
527
+ const { proof, principal } = proveLogin(seed, audience, request);
528
+ const { status, body } = await postAnswer(audience, id, {
529
+ principal,
530
+ possession: toHex(proof),
531
+ authority: toHex(authority),
532
+ });
533
+ // RULE 4: the ledger, accepted or refused, on stdout — field by field, never the code.
534
+ const refused = errorCodeOf(status, body);
535
+ write(renderLedger(audience, request, io.now(), describeKeySource(src), refused));
536
+ if (refused !== undefined)
537
+ throw new Error(`login: the service refused the login (${refused})`);
538
+ }
539
+ /** Reads --valid-for. Required: a delegation's lifetime is typed, never assumed — a default
540
+ * here would be a number nobody chose, signed anyway. */
541
+ function parseValidFor(text) {
542
+ if (text === undefined) {
543
+ throw new Error(`--valid-for <seconds> is required — a delegation's lifetime is typed, never assumed\n${USAGE}`);
544
+ }
545
+ if (!/^\d+$/.test(text))
546
+ throw new Error(`--valid-for must be a whole number of seconds, 1 or more; got ${JSON.stringify(text)}`);
547
+ const n = Number(text);
548
+ if (!Number.isSafeInteger(n) || n <= 0 || n > 0xffffffff) {
549
+ throw new Error(`--valid-for must be a whole number of seconds, 1 or more; got ${JSON.stringify(text)}`);
550
+ }
551
+ return n;
552
+ }
553
+ /** §4.1 rule 1. The audience is --audience, or ARCHON_AUDIENCE as the configured default,
554
+ * checked exactly the same way: it must be a fixed point of §2.1's grammar — the very check
555
+ * the server applies to its own configuration — and the CLI refuses to start otherwise. The
556
+ * `/login/00` is the shortest invocation URL the grammar admits, there only to make the
557
+ * audience parseable as one; feeding the audience through the scheme's derivation asks the
558
+ * one question that matters: is this the string the service binds? */
559
+ export function configuredAudience(flag) {
560
+ const audience = flag !== undefined && flag !== "" ? flag : (process.env["ARCHON_AUDIENCE"] ?? "");
561
+ if (audience === "") {
562
+ throw new Error("login: no audience — pass --audience <base> or set ARCHON_AUDIENCE; " +
563
+ "in this form the audience is your configuration, never a page's word (docs/login.md §4.1)");
564
+ }
565
+ let derived;
566
+ try {
567
+ derived = deriveAudience(`${audience}/login/00`).audience;
568
+ }
569
+ catch (err) {
570
+ throw new Error(`login: audience ${JSON.stringify(audience)} is not valid: ${err instanceof Error ? err.message : String(err)} (docs/login.md §2.1)`);
571
+ }
572
+ if (derived !== audience) {
573
+ throw new Error(`login: audience ${JSON.stringify(audience)} is not canonical — the service binds ${JSON.stringify(derived)}; pass that (docs/login.md §2.1)`);
574
+ }
575
+ return audience;
576
+ }
577
+ /** The code, from the OS CSPRNG. The randomness lives HERE, in the command, as every other
578
+ * randomness of the pinned tiers does (ADR 0006). */
579
+ function mintCode() {
580
+ return toHex(new Uint8Array(randomBytes(CODE_BYTES)));
581
+ }
582
+ /** Registers the offer. Unknown fields in the response are refused, as everywhere in this
583
+ * command: a service adding fields is speaking a protocol this lane does not. */
584
+ async function postOffer(audience, offer) {
585
+ const endpoint = `${audience}/login/offers`;
586
+ let response;
587
+ try {
588
+ response = await fetch(endpoint, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(offer) });
589
+ }
590
+ catch (err) {
591
+ throw new Error(`login: could not reach ${endpoint}: ${err instanceof Error ? err.message : String(err)}`);
592
+ }
593
+ const body = await response.text();
594
+ if (response.status !== 201)
595
+ throw new Error(loginHttpError(response.status, body));
596
+ return parseKnown(body, KNOWN_OFFER_FIELDS, "offer");
597
+ }
598
+ /** Parses a service response as an object carrying only known fields — a service adding
599
+ * fields is speaking a protocol this lane does not, and is refused rather than half-read. */
600
+ function parseKnown(body, known, what) {
601
+ let parsed;
602
+ try {
603
+ parsed = JSON.parse(body);
604
+ }
605
+ catch (err) {
606
+ throw new Error(`login: the service's ${what} is not the expected JSON: ${err instanceof Error ? err.message : String(err)}`);
607
+ }
608
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
609
+ throw new Error(`login: the service's ${what} is not a JSON object`);
610
+ for (const field of Object.keys(parsed)) {
611
+ if (!known.has(field))
612
+ throw new Error(`login: the service's ${what} carries an unknown field ${JSON.stringify(field)} — refusing`);
613
+ }
614
+ return parsed;
615
+ }
616
+ /** Requires the service to have registered EXACTLY what was offered. The offer is what the
617
+ * person typed; a service that echoes something else has altered it, and a prover that went
618
+ * on would be waiting to sign a delegation nobody typed. */
619
+ function checkOfferEcho(offered, code, scope, validFor) {
620
+ if (offered.code !== code)
621
+ throw new Error("login: the service altered the offer: the code it registered is not the one sent");
622
+ const differs = scopeAndValidityDiffer(offered.scope ?? [], offered.valid_for, scope, validFor);
623
+ if (differs !== undefined)
624
+ throw new Error(`login: the service altered the offer: ${differs}`);
625
+ if (!Number.isInteger(offered.expires_in) || offered.expires_in <= 0)
626
+ throw new Error("login: the service's offer has no lifetime (expires_in)");
627
+ }
628
+ /** The one line about the page address, printed and MARKED, never opened (§4.1 rule 1; ADR
629
+ * 0007 §C.7 (6)). "On the service's own origin" is a byte-exact comparison of scheme and host
630
+ * with the audience's — a differently spelled origin fails closed, the right direction for an
631
+ * address a person is about to click — and launching a browser is the person's action, never
632
+ * this command's: spawning a platform opener by name is the PATH surface §C.5 refuses. */
633
+ export function describePage(audience, page) {
634
+ const origin = originOf(audience);
635
+ const onOrigin = page === origin || page.startsWith(`${origin}/`) || page.startsWith(`${origin}#`) || page.startsWith(`${origin}?`);
636
+ return onOrigin ? `page: ${page} (on the service's own origin)` : `page: ${page} (NOT on the service's origin — do not open it)`;
637
+ }
638
+ /** The audience up to its path: scheme, host and port, as the audience spells them (canonical
639
+ * by construction — configuredAudience made sure). */
640
+ function originOf(audience) {
641
+ const afterScheme = audience.includes("://") ? audience.indexOf("://") + 3 : 0;
642
+ const slash = audience.indexOf("/", afterScheme);
643
+ return slash < 0 ? audience : audience.slice(0, slash);
644
+ }
645
+ /** Waits for the page to take the offer and returns the id of the request that did.
646
+ *
647
+ * The pacing is the prover's own (ADR 0007 §C.7, #39): one advertised interval BEFORE the
648
+ * first poll — the page always gets the first window — and one between polls; a 429 from a
649
+ * server that paces anyway is sleep-and-retry, never an error. The wait is bounded by the
650
+ * offer's own lifetime, and a 404 before then is the offer gone — expired, or taken and already
651
+ * finished — which for a prover still waiting means the page never took it. */
652
+ async function pollOffer(audience, code, offered, io) {
653
+ const interval = Math.max(1, Math.floor(offered.interval));
654
+ const deadline = io.now() + offered.expires_in;
655
+ const endpoint = `${audience}/login/offers/${encodeURIComponent(code)}`;
656
+ for (;;) {
657
+ await io.sleep(interval);
658
+ if (io.now() > deadline)
659
+ throw new Error("login: the offer expired before the page took it");
660
+ let response;
661
+ try {
662
+ response = await fetch(endpoint);
663
+ }
664
+ catch (err) {
665
+ throw new Error(`login: could not reach ${endpoint}: ${err instanceof Error ? err.message : String(err)}`);
666
+ }
667
+ const body = await response.text();
668
+ if (response.status === 429)
669
+ continue;
670
+ if (response.status === 404)
671
+ throw new Error("login: the offer expired before the page took it");
672
+ if (response.status !== 200)
673
+ throw new Error(loginHttpError(response.status, body));
674
+ const read = parseKnown(body, KNOWN_OFFER_READ_FIELDS, "offer");
675
+ if (typeof read.request === "string" && read.request !== "")
676
+ return read.request;
677
+ }
678
+ }
679
+ /** §4.1 rule 2, done by the prover for itself: the request's scope must be what was offered,
680
+ * entry for entry, in order, and its validity equal. The service refuses a mismatched begin
681
+ * before storing anything — but a prover that relied on that would be trusting the service
682
+ * about the one thing it is about to sign. */
683
+ export function checkAgainstOffer(r, scope, validFor) {
684
+ const differs = scopeAndValidityDiffer(r.scope, r.valid_for, scope, validFor);
685
+ if (differs !== undefined)
686
+ throw new Error(`login: the service's request differs from the offer — ${differs} — refusing to sign`);
687
+ }
688
+ /** "Differs in any way", stated once for the echo and for the request: the same number of
689
+ * entries, each equal to its counterpart IN ORDER, the same validity. */
690
+ function scopeAndValidityDiffer(gotScope, gotValidFor, scope, validFor) {
691
+ if (gotValidFor !== validFor)
692
+ return `valid_for is ${gotValidFor}, offered ${validFor}`;
693
+ if (gotScope.length !== scope.length)
694
+ return `${gotScope.length} scope entries, offered ${scope.length}`;
695
+ for (let i = 0; i < scope.length; i++) {
696
+ if (gotScope[i] !== scope[i])
697
+ return `scope entry ${i} is ${JSON.stringify(gotScope[i])}, offered ${JSON.stringify(scope[i])}`;
698
+ }
699
+ return undefined;
700
+ }
701
+ /** The service's verdict for the ledger: undefined for an accepted answer; otherwise the RFC
702
+ * 8628 code from the error body, or the bare status when the body carries none. */
703
+ function errorCodeOf(status, body) {
704
+ if (status === 204 || status === 200)
705
+ return undefined;
706
+ try {
707
+ const payload = JSON.parse(body);
708
+ if (payload !== null && typeof payload === "object" && typeof payload.error === "string" && payload.error !== "")
709
+ return payload.error;
710
+ }
711
+ catch {
712
+ // not JSON; fall through to the status
713
+ }
714
+ return `HTTP ${status}`;
715
+ }
716
+ /** Turn a non-success response into a diagnosis. RFC 8628's error vocabulary is used where
717
+ * the service speaks it, since this protocol adopts that shape. */
718
+ export function loginHttpError(status, body) {
719
+ try {
720
+ const payload = JSON.parse(body);
721
+ if (payload !== null && typeof payload === "object" && typeof payload.error === "string" && payload.error !== "") {
722
+ if (payload.error === "expired_token")
723
+ return "login: this request has expired — reload the page and run the new command";
724
+ if (payload.error === "access_denied")
725
+ return "login: the service refused the login";
726
+ if (payload.error_description)
727
+ return `login: ${payload.error_description} (${payload.error})`;
728
+ return `login: the service answered ${JSON.stringify(payload.error)}`;
729
+ }
730
+ }
731
+ catch {
732
+ // not JSON; fall through to the status line
733
+ }
734
+ return `login: the service answered HTTP ${status}`;
735
+ }
736
+ // ---------------------------------------------------------------------------
737
+ // THE SCHEME SEAM
738
+ // ---------------------------------------------------------------------------
739
+ //
740
+ // The one place this lane reaches the login scheme, and the only part of this file that
741
+ // changes when sdk/ts/login lands. The scheme — binding layout, role tags, proof — is
742
+ // seat:cca's (archon#16). Pinned by their authorship comment, for whoever wires this up:
743
+ //
744
+ // binding = role ‖ u16 len ‖ audience ‖ K[32] ‖ u16 len ‖ id ‖ scope ‖ u32 valid_for
745
+ // scope = u16 count ‖ (u16 len ‖ bytes)*
746
+ // role = 0x01 person's login proof (signed by P), 0x02 browser's collect proof (by K)
747
+ // proof = possession over the server's nonce and that binding, domain archon-login/1
748
+ /** The possession proof over this request, plus the person's principal as canonical key
749
+ * text. The principal is derived here — a property of the seed, not of the scheme — so
750
+ * only the proof itself waits on sdk/ts/login.
751
+ *
752
+ * LOUD ON PURPOSE until then: a locally computed binding that merely looks right would
753
+ * produce proofs that verify nowhere and take a day to explain. */
754
+ export function proveLogin(seed, audience, request) {
755
+ const principal = encodeKey(getPublicKey(seed));
756
+ // Re-decoded rather than assumed: validateLoginRequest has already checked these, but it
757
+ // runs on the flow's path and this function is reachable from any future caller. A silent
758
+ // mis-decode would produce a proof bound to bytes nobody displayed.
759
+ const nonce = fromHex(request.nonce);
760
+ const browser = decodeKey(request.browser);
761
+ // The id crosses as its bytes: the scheme binds it as an opaque field, so the CLI must
762
+ // not normalise, case-fold or re-encode it on the way in.
763
+ // THE ID IS HEX-DECODED, NOT HANDED OVER AS TEXT. docs/login.md §3.1 makes the id BYTES
764
+ // carried as "hex in URLs and JSON"; the scheme binds the bytes. Encoding the hex string
765
+ // as UTF-8 would bind the ASCII of the hex — 0x38 0x66 0x33 0x63 for "8f3c" instead of
766
+ // 0x8f 0x3c — and the resulting proof verifies NOWHERE. A stub test that builds its
767
+ // expected request the same wrong way still passes, which is how it survived.
768
+ const scheme = {
769
+ id: fromHex(request.id),
770
+ nonce,
771
+ browser,
772
+ scope: request.scope,
773
+ validFor: request.valid_for,
774
+ };
775
+ return { proof: schemeProveLogin(seed, audience, scheme), principal };
776
+ }
777
+ //# sourceMappingURL=login.js.map