@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.
- package/dist/src/cmd/key.d.ts +1 -0
- package/dist/src/cmd/key.js +138 -0
- package/dist/src/cmd/key.js.map +1 -0
- package/dist/src/cmd/key_store.d.ts +84 -0
- package/dist/src/cmd/key_store.js +482 -0
- package/dist/src/cmd/key_store.js.map +1 -0
- package/dist/src/cmd/keygen.d.ts +1 -0
- package/dist/src/cmd/keygen.js +114 -0
- package/dist/src/cmd/keygen.js.map +1 -0
- package/dist/src/cmd/login.d.ts +148 -0
- package/dist/src/cmd/login.js +777 -0
- package/dist/src/cmd/login.js.map +1 -0
- package/dist/src/cmd/sign.d.ts +1 -0
- package/dist/src/cmd/sign.js +51 -0
- package/dist/src/cmd/sign.js.map +1 -0
- package/dist/src/cmd/verify.d.ts +1 -0
- package/dist/src/cmd/verify.js +68 -0
- package/dist/src/cmd/verify.js.map +1 -0
- package/dist/src/cmd/version.d.ts +2 -0
- package/dist/src/cmd/version.js +16 -0
- package/dist/src/cmd/version.js.map +1 -0
- package/dist/src/io.d.ts +19 -0
- package/dist/src/io.js +65 -0
- package/dist/src/io.js.map +1 -0
- package/dist/src/keystore.d.ts +55 -0
- package/dist/src/keystore.js +204 -0
- package/dist/src/keystore.js.map +1 -0
- package/dist/src/main.d.ts +2 -0
- package/dist/src/main.js +51 -0
- package/dist/src/main.js.map +1 -0
- package/dist/src/pubrender.d.ts +5 -0
- package/dist/src/pubrender.js +30 -0
- package/dist/src/pubrender.js.map +1 -0
- package/package.json +34 -0
|
@@ -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
|