rootcard 0.0.0 → 0.2.0
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/CHANGELOG.md +19 -1
- package/LICENSE-APACHE +202 -0
- package/README.md +36 -3
- package/keytool.html +3123 -0
- package/package.json +31 -7
- package/src/account.js +89 -0
- package/src/argon2.js +239 -0
- package/src/binding.js +49 -0
- package/src/blake2b.js +120 -0
- package/src/crypto.js +186 -0
- package/src/encoding.js +145 -0
- package/src/errors.js +38 -0
- package/src/file.js +118 -0
- package/src/id.js +107 -0
- package/src/index.js +29 -17
- package/src/json.js +280 -0
- package/src/keys.js +134 -0
- package/src/labels.js +17 -0
- package/src/log.js +316 -0
- package/src/messages.js +82 -0
- package/src/profile.js +89 -0
- package/src/proof.js +66 -0
- package/src/recovery.js +49 -0
- package/src/testing.js +34 -0
- package/src/vault-index.js +9 -0
- package/src/vault.js +294 -0
- package/src/vaultcheck.js +180 -0
- /package/{LICENSE → LICENSE-MIT} +0 -0
package/keytool.html
ADDED
|
@@ -0,0 +1,3123 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src https: http://127.0.0.1:* http://localhost:*; img-src data:; base-uri 'none'; form-action 'none'">
|
|
7
|
+
<meta name="referrer" content="no-referrer">
|
|
8
|
+
<title>rootcard key tool</title>
|
|
9
|
+
<style>
|
|
10
|
+
:root { --bg: #fbfaf7; --fg: #1d1d1b; --muted: #6b6a66; --line: #dcd9d2; --accent: #2d5b8a; --warn: #8a3b2d; --panel: #fff; }
|
|
11
|
+
@media (prefers-color-scheme: dark) { :root { --bg: #161615; --fg: #ecebe7; --muted: #a3a19b; --line: #3a3936; --accent: #8db6e0; --warn: #e09a8d; --panel: #1f1f1d; } }
|
|
12
|
+
* { box-sizing: border-box; }
|
|
13
|
+
body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.5 system-ui, sans-serif; }
|
|
14
|
+
main { max-width: 880px; margin: 0 auto; padding: 24px 16px 64px; }
|
|
15
|
+
h1 { font-size: 22px; margin: 0 0 4px; }
|
|
16
|
+
h2 { font-size: 16px; margin: 0 0 12px; }
|
|
17
|
+
p.lead { color: var(--muted); margin: 0 0 20px; }
|
|
18
|
+
.warn { border-left: 3px solid var(--warn); padding: 8px 12px; margin: 0 0 20px; background: var(--panel); }
|
|
19
|
+
section { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 16px; margin: 0 0 16px; }
|
|
20
|
+
label { display: block; font-size: 13px; color: var(--muted); margin: 8px 0 2px; }
|
|
21
|
+
input, textarea, select { width: 100%; font: inherit; padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--fg); }
|
|
22
|
+
textarea { min-height: 64px; font-family: ui-monospace, monospace; font-size: 13px; }
|
|
23
|
+
.row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 0 12px; }
|
|
24
|
+
button { font: inherit; padding: 6px 14px; margin: 12px 8px 0 0; border: 1px solid var(--accent); border-radius: 6px; background: transparent; color: var(--accent); cursor: pointer; }
|
|
25
|
+
button.danger { border-color: var(--warn); color: var(--warn); }
|
|
26
|
+
button:disabled { opacity: .4; cursor: default; }
|
|
27
|
+
pre { white-space: pre-wrap; word-break: break-all; font: 13px/1.45 ui-monospace, monospace; margin: 0; }
|
|
28
|
+
#status { min-height: 1.5em; }
|
|
29
|
+
.ok { color: var(--accent); } .err { color: var(--warn); }
|
|
30
|
+
details > summary { cursor: pointer; font-weight: 600; }
|
|
31
|
+
</style>
|
|
32
|
+
</head>
|
|
33
|
+
<body>
|
|
34
|
+
<main>
|
|
35
|
+
<h1>rootcard key tool</h1>
|
|
36
|
+
<p class="lead">The tool for everything that needs your recovery card: moving an identity, resetting or adding a password, rotating the recovery key and closing the identity. It runs entirely in this page and talks only to the hosts you enter.</p>
|
|
37
|
+
<div class="warn">Only enter your recovery key in the rootcard key tool, never in an application. Save this page and open it from your own disk.</div>
|
|
38
|
+
|
|
39
|
+
<section>
|
|
40
|
+
<h2>Your identity</h2>
|
|
41
|
+
<label for="cardFile">Recovery card (file)</label>
|
|
42
|
+
<input id="cardFile" type="file" accept=".json,application/json">
|
|
43
|
+
<details><summary>or type the card</summary>
|
|
44
|
+
<div class="row">
|
|
45
|
+
<div><label for="realm">Realm</label><input id="realm" value="global" autocomplete="off"></div>
|
|
46
|
+
<div><label for="username">Username</label><input id="username" autocomplete="off"></div>
|
|
47
|
+
</div>
|
|
48
|
+
<label for="rkey">Recovery key (RCR-…)</label><input id="rkey" autocomplete="off" spellcheck="false">
|
|
49
|
+
</details>
|
|
50
|
+
<label for="accountFile">Account file (optional)</label>
|
|
51
|
+
<input id="accountFile" type="file" accept=".json,application/json">
|
|
52
|
+
<label for="hosts">Hosts to ask for the newest version (comma separated base URLs, optional)</label>
|
|
53
|
+
<input id="hosts" placeholder="https://id.example.com" autocomplete="off">
|
|
54
|
+
<button id="load">Load identity</button>
|
|
55
|
+
<pre id="identity"></pre>
|
|
56
|
+
</section>
|
|
57
|
+
|
|
58
|
+
<section id="actions" hidden>
|
|
59
|
+
<h2>Actions</h2>
|
|
60
|
+
<div class="row">
|
|
61
|
+
<div><label for="newPassword">New password (for restore with a new host, fresh, reset, add)</label><input id="newPassword" type="password" autocomplete="new-password"></div>
|
|
62
|
+
<div><label for="label">Label of the new password</label><input id="label" value="Password"></div>
|
|
63
|
+
</div>
|
|
64
|
+
<label for="targetHost">Host for a new instance (restore, fresh)</label>
|
|
65
|
+
<input id="targetHost" placeholder="https://id.example.com" autocomplete="off">
|
|
66
|
+
<button data-op="restore">Restore at host</button>
|
|
67
|
+
<button data-op="fresh">Fresh instance at host</button>
|
|
68
|
+
<button data-op="reset">Reset forgotten password</button>
|
|
69
|
+
<button data-op="add">Add password</button>
|
|
70
|
+
<label for="slot">Password to revoke</label><select id="slot"></select>
|
|
71
|
+
<button data-op="revoke" class="danger">Revoke password</button>
|
|
72
|
+
<button data-op="rotate">Rotate recovery key</button>
|
|
73
|
+
<button data-op="card">Print card</button>
|
|
74
|
+
<label for="retireConfirm">To close the identity forever, type its username</label>
|
|
75
|
+
<input id="retireConfirm" autocomplete="off">
|
|
76
|
+
<button data-op="retire" class="danger">Retire identity</button>
|
|
77
|
+
<details><summary>Root proof</summary>
|
|
78
|
+
<label for="proofStatement">Statement (JSON) or challenge (64 hex)</label><input id="proofStatement" value='"I own this identity"'>
|
|
79
|
+
<label for="proofRecipient">Recipient</label><input id="proofRecipient">
|
|
80
|
+
<button data-op="prove">Make root proof</button>
|
|
81
|
+
</details>
|
|
82
|
+
</section>
|
|
83
|
+
|
|
84
|
+
<section>
|
|
85
|
+
<details><summary>Create a new identity</summary>
|
|
86
|
+
<div class="row">
|
|
87
|
+
<div><label for="nRealm">Realm</label><input id="nRealm" value="global"></div>
|
|
88
|
+
<div><label for="nUser">Username</label><input id="nUser"></div>
|
|
89
|
+
</div>
|
|
90
|
+
<label for="nHost">Host (optional: without one, the identity has no instance until you restore or start fresh)</label>
|
|
91
|
+
<input id="nHost" placeholder="https://id.example.com">
|
|
92
|
+
<label for="nPassword">Password</label><input id="nPassword" type="password" autocomplete="new-password">
|
|
93
|
+
<button id="create">Create</button>
|
|
94
|
+
</details>
|
|
95
|
+
</section>
|
|
96
|
+
|
|
97
|
+
<section>
|
|
98
|
+
<details><summary>Verify a proof</summary>
|
|
99
|
+
<label for="vLog">Account file or log (JSON)</label><textarea id="vLog"></textarea>
|
|
100
|
+
<label for="vProof">Proof (JSON)</label><textarea id="vProof"></textarea>
|
|
101
|
+
<label for="vStatement">Statement (JSON) or challenge (64 hex)</label><input id="vStatement">
|
|
102
|
+
<label for="vRecipient">Recipient</label><input id="vRecipient">
|
|
103
|
+
<button id="verify">Verify</button>
|
|
104
|
+
</details>
|
|
105
|
+
</section>
|
|
106
|
+
|
|
107
|
+
<section><h2>Result</h2><div id="status"></div><pre id="out"></pre></section>
|
|
108
|
+
<p class="lead">rootcard <span id="version"></span>. The format is specified in spec/SPEC.md of the rootcard repository.</p>
|
|
109
|
+
</main>
|
|
110
|
+
<script>
|
|
111
|
+
"use strict";
|
|
112
|
+
(() => {
|
|
113
|
+
const ROOTCARD_VERSION = "0.2.0";
|
|
114
|
+
const __modules = {};
|
|
115
|
+
__modules["errors.js"] = (() => {
|
|
116
|
+
// Error codes (SPEC §20).
|
|
117
|
+
|
|
118
|
+
/** Every error code of SPEC §20, in its order. */
|
|
119
|
+
const ERROR_CODES = Object.freeze([
|
|
120
|
+
"bad_json", "bad_field", "unsupported_version", "too_large", "bad_base64", "bad_base32", "bad_checksum", "bad_key",
|
|
121
|
+
"bad_signature", "bad_realm", "realm_not_accepted", "bad_username", "username_policy", "bad_handle", "handle_taken",
|
|
122
|
+
"ambiguous_handle", "account_mismatch", "wrong_recovery_key", "too_many_events", "bad_kind", "bad_seq", "bad_prev",
|
|
123
|
+
"time_backwards", "future_time", "bad_time", "wrong_signer", "key_reuse", "bad_receipt", "bad_reason",
|
|
124
|
+
"account_retired", "no_instance", "stale_version", "fork", "wrong_instance", "stale_rev", "stale_locks", "bad_locks",
|
|
125
|
+
"kdf_params", "weak_password", "unlock_failed", "wrong_account_key", "bad_data_key", "wrong_challenge",
|
|
126
|
+
"wrong_recipient", "challenge_unknown", "challenge_expired", "not_home", "wrong_host", "unknown_account",
|
|
127
|
+
"unauthorized", "rate_limited", "unsupported_browser",
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
/** A rootcard error. `code` is one of the stable codes of SPEC §20. */
|
|
131
|
+
class RootcardError extends Error {
|
|
132
|
+
/** @param {string} code */
|
|
133
|
+
constructor(code) {
|
|
134
|
+
super(code);
|
|
135
|
+
this.name = "RootcardError";
|
|
136
|
+
this.code = code;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Throw the error `code`. @param {string} code @returns {never} */
|
|
141
|
+
function fail(code) {
|
|
142
|
+
throw new RootcardError(code);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Run `f`, replacing any RootcardError by `code`. */
|
|
146
|
+
function remap(code, f) {
|
|
147
|
+
try {
|
|
148
|
+
return f();
|
|
149
|
+
} catch (e) {
|
|
150
|
+
if (e instanceof RootcardError) fail(code);
|
|
151
|
+
throw e;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { ERROR_CODES, RootcardError, fail, remap };
|
|
156
|
+
})();
|
|
157
|
+
__modules["json.js"] = (() => {
|
|
158
|
+
// Canonical JSON (SPEC §2): a strict parser and the RFC 8785 serialization of the rootcard subset of JSON.
|
|
159
|
+
// Values are plain objects, arrays, strings, safe integers and booleans. Parsed objects have no prototype.
|
|
160
|
+
|
|
161
|
+
const { fail } = __modules["errors.js"];
|
|
162
|
+
|
|
163
|
+
const MAX_SAFE = Number.MAX_SAFE_INTEGER;
|
|
164
|
+
const MAX_DEPTH = 32;
|
|
165
|
+
|
|
166
|
+
const enc = new TextEncoder();
|
|
167
|
+
const dec = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
168
|
+
|
|
169
|
+
/** Whether `v` is a plain object (not an array). */
|
|
170
|
+
function isObject(v) {
|
|
171
|
+
return typeof v === "object" && v !== null && !Array.isArray(v) && !(v instanceof Uint8Array);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** A new empty object without prototype. */
|
|
175
|
+
function obj(entries) {
|
|
176
|
+
const o = Object.create(null);
|
|
177
|
+
if (entries) for (const [k, v] of Object.entries(entries)) o[k] = v;
|
|
178
|
+
return o;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Whether `name` sorts before `other` by UTF-16 code units (RFC 8785 §3.2.3). JS string order is exactly that. */
|
|
182
|
+
function sortedKeys(o) {
|
|
183
|
+
return Object.keys(o).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Check that a value built in code is a rootcard JSON value ([CJ-1]–[CJ-5]). → bad_json */
|
|
187
|
+
function validate(v, depth = 0) {
|
|
188
|
+
if (typeof v === "string") {
|
|
189
|
+
if (!v.isWellFormed()) fail("bad_json");
|
|
190
|
+
} else if (typeof v === "number") {
|
|
191
|
+
if (!Number.isSafeInteger(v) || Object.is(v, -0)) fail("bad_json");
|
|
192
|
+
} else if (typeof v === "boolean") {
|
|
193
|
+
// ok
|
|
194
|
+
} else if (Array.isArray(v)) {
|
|
195
|
+
if (depth >= MAX_DEPTH) fail("bad_json");
|
|
196
|
+
for (const x of v) validate(x, depth + 1);
|
|
197
|
+
} else if (isObject(v)) {
|
|
198
|
+
if (depth >= MAX_DEPTH) fail("bad_json");
|
|
199
|
+
for (const k of Object.keys(v)) {
|
|
200
|
+
if (!k.isWellFormed()) fail("bad_json");
|
|
201
|
+
if (v[k] === undefined) fail("bad_json");
|
|
202
|
+
validate(v[k], depth + 1);
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
fail("bad_json");
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function writeString(s, out) {
|
|
210
|
+
out.push('"');
|
|
211
|
+
for (const ch of s) {
|
|
212
|
+
const c = ch.codePointAt(0);
|
|
213
|
+
if (ch === '"') out.push('\\"');
|
|
214
|
+
else if (ch === "\\") out.push("\\\\");
|
|
215
|
+
else if (c === 0x08) out.push("\\b");
|
|
216
|
+
else if (c === 0x09) out.push("\\t");
|
|
217
|
+
else if (c === 0x0a) out.push("\\n");
|
|
218
|
+
else if (c === 0x0c) out.push("\\f");
|
|
219
|
+
else if (c === 0x0d) out.push("\\r");
|
|
220
|
+
else if (c < 0x20) out.push("\\u00" + c.toString(16).padStart(2, "0"));
|
|
221
|
+
else out.push(ch);
|
|
222
|
+
}
|
|
223
|
+
out.push('"');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function write(v, out) {
|
|
227
|
+
if (typeof v === "string") writeString(v, out);
|
|
228
|
+
else if (typeof v === "number") out.push(String(v));
|
|
229
|
+
else if (typeof v === "boolean") out.push(v ? "true" : "false");
|
|
230
|
+
else if (Array.isArray(v)) {
|
|
231
|
+
out.push("[");
|
|
232
|
+
v.forEach((x, i) => {
|
|
233
|
+
if (i) out.push(",");
|
|
234
|
+
write(x, out);
|
|
235
|
+
});
|
|
236
|
+
out.push("]");
|
|
237
|
+
} else {
|
|
238
|
+
out.push("{");
|
|
239
|
+
sortedKeys(v).forEach((k, i) => {
|
|
240
|
+
if (i) out.push(",");
|
|
241
|
+
writeString(k, out);
|
|
242
|
+
out.push(":");
|
|
243
|
+
write(v[k], out);
|
|
244
|
+
});
|
|
245
|
+
out.push("}");
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** `canonical(x)` as a string ([CJ-9]). The value must be valid ([CJ-1]–[CJ-5]). */
|
|
250
|
+
function canonicalString(v) {
|
|
251
|
+
validate(v);
|
|
252
|
+
const out = [];
|
|
253
|
+
write(v, out);
|
|
254
|
+
return out.join("");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** `canonical(x)` as UTF-8 bytes ([CJ-9]). */
|
|
258
|
+
function canonical(v) {
|
|
259
|
+
return enc.encode(canonicalString(v));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** The object `o` without the member `name` (SPEC §0.1). */
|
|
263
|
+
function without(o, ...names) {
|
|
264
|
+
const r = obj();
|
|
265
|
+
for (const k of Object.keys(o)) if (!names.includes(k)) r[k] = o[k];
|
|
266
|
+
return r;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Parse a JSON text strictly ([CJ-6]). → bad_json */
|
|
270
|
+
function parseJson(bytes) {
|
|
271
|
+
if (typeof bytes === "string") bytes = enc.encode(bytes);
|
|
272
|
+
if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) fail("bad_json");
|
|
273
|
+
let text;
|
|
274
|
+
try {
|
|
275
|
+
text = dec.decode(bytes);
|
|
276
|
+
} catch {
|
|
277
|
+
fail("bad_json");
|
|
278
|
+
}
|
|
279
|
+
const p = { s: text, i: 0 };
|
|
280
|
+
ws(p);
|
|
281
|
+
const v = value(p, 1);
|
|
282
|
+
ws(p);
|
|
283
|
+
if (p.i !== p.s.length) fail("bad_json");
|
|
284
|
+
return v;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Parse with a size limit ([CJ-8]). → too_large, bad_json */
|
|
288
|
+
function parseLimited(bytes, limit) {
|
|
289
|
+
if (typeof bytes === "string") bytes = enc.encode(bytes);
|
|
290
|
+
if (bytes.length > limit + Math.floor(limit / 4)) fail("too_large");
|
|
291
|
+
const v = parseJson(bytes);
|
|
292
|
+
if (canonical(v).length > limit) fail("too_large");
|
|
293
|
+
return v;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function ws(p) {
|
|
297
|
+
while (p.i < p.s.length) {
|
|
298
|
+
const c = p.s.charCodeAt(p.i);
|
|
299
|
+
if (c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d) p.i++;
|
|
300
|
+
else break;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function eat(p, lit) {
|
|
305
|
+
if (p.s.startsWith(lit, p.i)) p.i += lit.length;
|
|
306
|
+
else fail("bad_json");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function value(p, depth) {
|
|
310
|
+
const c = p.s[p.i];
|
|
311
|
+
if (c === "{") {
|
|
312
|
+
if (depth > MAX_DEPTH) fail("bad_json");
|
|
313
|
+
p.i++;
|
|
314
|
+
const o = obj();
|
|
315
|
+
ws(p);
|
|
316
|
+
if (p.s[p.i] === "}") {
|
|
317
|
+
p.i++;
|
|
318
|
+
return o;
|
|
319
|
+
}
|
|
320
|
+
for (;;) {
|
|
321
|
+
ws(p);
|
|
322
|
+
if (p.s[p.i] !== '"') fail("bad_json");
|
|
323
|
+
const k = string(p);
|
|
324
|
+
ws(p);
|
|
325
|
+
eat(p, ":");
|
|
326
|
+
ws(p);
|
|
327
|
+
const v = value(p, depth + 1);
|
|
328
|
+
if (Object.prototype.hasOwnProperty.call(o, k)) fail("bad_json");
|
|
329
|
+
o[k] = v;
|
|
330
|
+
ws(p);
|
|
331
|
+
if (p.s[p.i] === ",") p.i++;
|
|
332
|
+
else if (p.s[p.i] === "}") {
|
|
333
|
+
p.i++;
|
|
334
|
+
return o;
|
|
335
|
+
} else fail("bad_json");
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (c === "[") {
|
|
339
|
+
if (depth > MAX_DEPTH) fail("bad_json");
|
|
340
|
+
p.i++;
|
|
341
|
+
const a = [];
|
|
342
|
+
ws(p);
|
|
343
|
+
if (p.s[p.i] === "]") {
|
|
344
|
+
p.i++;
|
|
345
|
+
return a;
|
|
346
|
+
}
|
|
347
|
+
for (;;) {
|
|
348
|
+
ws(p);
|
|
349
|
+
a.push(value(p, depth + 1));
|
|
350
|
+
ws(p);
|
|
351
|
+
if (p.s[p.i] === ",") p.i++;
|
|
352
|
+
else if (p.s[p.i] === "]") {
|
|
353
|
+
p.i++;
|
|
354
|
+
return a;
|
|
355
|
+
} else fail("bad_json");
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (c === '"') return string(p);
|
|
359
|
+
if (c === "t") {
|
|
360
|
+
eat(p, "true");
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
if (c === "f") {
|
|
364
|
+
eat(p, "false");
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
if (c === "-" || (c >= "0" && c <= "9")) return number(p);
|
|
368
|
+
fail("bad_json");
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function number(p) {
|
|
372
|
+
const m = /^-?(0|[1-9][0-9]*)/.exec(p.s.slice(p.i, p.i + 20));
|
|
373
|
+
if (!m) fail("bad_json");
|
|
374
|
+
const text = m[0];
|
|
375
|
+
p.i += text.length;
|
|
376
|
+
const next = p.s[p.i];
|
|
377
|
+
if (next === "." || next === "e" || next === "E" || (next >= "0" && next <= "9")) fail("bad_json");
|
|
378
|
+
if (text === "-0" || text.length > 17) fail("bad_json");
|
|
379
|
+
const n = Number(text);
|
|
380
|
+
if (!Number.isSafeInteger(n)) fail("bad_json");
|
|
381
|
+
return n;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function hex4(p) {
|
|
385
|
+
const h = p.s.slice(p.i, p.i + 4);
|
|
386
|
+
if (!/^[0-9a-fA-F]{4}$/.test(h)) fail("bad_json");
|
|
387
|
+
p.i += 4;
|
|
388
|
+
return parseInt(h, 16);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function string(p) {
|
|
392
|
+
p.i++;
|
|
393
|
+
let out = "";
|
|
394
|
+
for (;;) {
|
|
395
|
+
if (p.i >= p.s.length) fail("bad_json");
|
|
396
|
+
const c = p.s.charCodeAt(p.i);
|
|
397
|
+
if (c === 0x22) {
|
|
398
|
+
p.i++;
|
|
399
|
+
return out;
|
|
400
|
+
}
|
|
401
|
+
if (c === 0x5c) {
|
|
402
|
+
p.i++;
|
|
403
|
+
const e = p.s[p.i++];
|
|
404
|
+
if (e === '"') out += '"';
|
|
405
|
+
else if (e === "\\") out += "\\";
|
|
406
|
+
else if (e === "/") out += "/";
|
|
407
|
+
else if (e === "b") out += "\b";
|
|
408
|
+
else if (e === "f") out += "\f";
|
|
409
|
+
else if (e === "n") out += "\n";
|
|
410
|
+
else if (e === "r") out += "\r";
|
|
411
|
+
else if (e === "t") out += "\t";
|
|
412
|
+
else if (e === "u") {
|
|
413
|
+
const u = hex4(p);
|
|
414
|
+
if (u >= 0xd800 && u < 0xdc00) {
|
|
415
|
+
eat(p, "\\u");
|
|
416
|
+
const lo = hex4(p);
|
|
417
|
+
if (!(lo >= 0xdc00 && lo < 0xe000)) fail("bad_json");
|
|
418
|
+
out += String.fromCharCode(u, lo);
|
|
419
|
+
} else if (u >= 0xdc00 && u < 0xe000) {
|
|
420
|
+
fail("bad_json");
|
|
421
|
+
} else {
|
|
422
|
+
out += String.fromCharCode(u);
|
|
423
|
+
}
|
|
424
|
+
} else fail("bad_json");
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
if (c < 0x20) fail("bad_json");
|
|
428
|
+
// A surrogate pair in the decoded text is one scalar value; copy both halves.
|
|
429
|
+
if (c >= 0xd800 && c < 0xdc00) {
|
|
430
|
+
out += p.s.slice(p.i, p.i + 2);
|
|
431
|
+
p.i += 2;
|
|
432
|
+
} else {
|
|
433
|
+
out += p.s[p.i];
|
|
434
|
+
p.i++;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return { MAX_SAFE, MAX_DEPTH, isObject, obj, validate, canonicalString, canonical, without, parseJson, parseLimited };
|
|
440
|
+
})();
|
|
441
|
+
__modules["encoding.js"] = (() => {
|
|
442
|
+
// Byte encodings (SPEC §3): unpadded base64url, Crockford base32, hex and UTF-8.
|
|
443
|
+
|
|
444
|
+
const { fail } = __modules["errors.js"];
|
|
445
|
+
|
|
446
|
+
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
447
|
+
const B64_VALUE = new Map([...B64].map((c, i) => [c, i]));
|
|
448
|
+
|
|
449
|
+
/** Unpadded base64url ([B64-1]). @param {Uint8Array} bytes */
|
|
450
|
+
function base64urlEncode(bytes) {
|
|
451
|
+
let out = "";
|
|
452
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
453
|
+
const n = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0);
|
|
454
|
+
const chars = Math.min(3, bytes.length - i) + 1;
|
|
455
|
+
for (let j = 0; j < chars; j++) out += B64[(n >>> (18 - 6 * j)) & 63];
|
|
456
|
+
}
|
|
457
|
+
return out;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Strict unpadded base64url decoding ([B64-2]). → bad_base64 */
|
|
461
|
+
function base64urlDecode(text) {
|
|
462
|
+
if (typeof text !== "string" || text.length % 4 === 1) fail("bad_base64");
|
|
463
|
+
const out = new Uint8Array(Math.floor((text.length * 3) / 4));
|
|
464
|
+
let o = 0;
|
|
465
|
+
for (let i = 0; i < text.length; i += 4) {
|
|
466
|
+
const chunk = text.slice(i, i + 4);
|
|
467
|
+
let n = 0;
|
|
468
|
+
for (let j = 0; j < chunk.length; j++) {
|
|
469
|
+
const v = B64_VALUE.get(chunk[j]);
|
|
470
|
+
if (v === undefined) fail("bad_base64");
|
|
471
|
+
n |= v << (18 - 6 * j);
|
|
472
|
+
}
|
|
473
|
+
const len = chunk.length - 1;
|
|
474
|
+
const unused = len === 1 ? n & 0xffff : len === 2 ? n & 0xff : 0;
|
|
475
|
+
if (unused !== 0) fail("bad_base64");
|
|
476
|
+
const b = [(n >>> 16) & 255, (n >>> 8) & 255, n & 255];
|
|
477
|
+
for (let j = 0; j < len; j++) out[o++] = b[j];
|
|
478
|
+
}
|
|
479
|
+
return out;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** The Crockford base32 alphabet ([B32-1]). */
|
|
483
|
+
const B32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
484
|
+
|
|
485
|
+
/** Crockford base32 ([B32-2]). */
|
|
486
|
+
function base32Encode(bytes) {
|
|
487
|
+
let out = "";
|
|
488
|
+
let acc = 0;
|
|
489
|
+
let bits = 0;
|
|
490
|
+
for (const b of bytes) {
|
|
491
|
+
acc = ((acc << 8) | b) >>> 0;
|
|
492
|
+
bits += 8;
|
|
493
|
+
while (bits >= 5) {
|
|
494
|
+
bits -= 5;
|
|
495
|
+
out += B32[(acc >>> bits) & 31];
|
|
496
|
+
}
|
|
497
|
+
acc &= (1 << bits) - 1;
|
|
498
|
+
}
|
|
499
|
+
if (bits > 0) out += B32[(acc << (5 - bits)) & 31];
|
|
500
|
+
return out;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Strict Crockford base32 decoding of exactly `len` bytes ([B32-3]). → bad_base32 */
|
|
504
|
+
function base32Decode(text, len) {
|
|
505
|
+
if (typeof text !== "string" || text.length !== Math.ceil((len * 8) / 5)) fail("bad_base32");
|
|
506
|
+
const out = new Uint8Array(len);
|
|
507
|
+
let o = 0;
|
|
508
|
+
let acc = 0;
|
|
509
|
+
let bits = 0;
|
|
510
|
+
for (const c of text) {
|
|
511
|
+
const v = B32.indexOf(c);
|
|
512
|
+
if (v < 0 || c.length !== 1) fail("bad_base32");
|
|
513
|
+
acc = (acc << 5) | v;
|
|
514
|
+
bits += 5;
|
|
515
|
+
if (bits >= 8) {
|
|
516
|
+
bits -= 8;
|
|
517
|
+
out[o++] = (acc >>> bits) & 255;
|
|
518
|
+
acc &= (1 << bits) - 1;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (acc !== 0) fail("bad_base32");
|
|
522
|
+
return out;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Lenient normalization of typed base32 ([B32-4]). */
|
|
526
|
+
function base32Normalize(text) {
|
|
527
|
+
let out = "";
|
|
528
|
+
for (const c of text) {
|
|
529
|
+
if (c === "-" || c === " ") continue;
|
|
530
|
+
const u = c.length === 1 && c >= "a" && c <= "z" ? c.toUpperCase() : c;
|
|
531
|
+
out += u === "I" || u === "L" ? "1" : u === "O" ? "0" : u;
|
|
532
|
+
}
|
|
533
|
+
return out;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** Lenient decoding for human input ([B32-4]). → bad_base32 */
|
|
537
|
+
function base32DecodeLenient(text, len) {
|
|
538
|
+
return base32Decode(base32Normalize(text), len);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Lower-case hexadecimal. */
|
|
542
|
+
function hexEncode(bytes) {
|
|
543
|
+
let s = "";
|
|
544
|
+
for (const b of bytes) s += b.toString(16).padStart(2, "0");
|
|
545
|
+
return s;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Hexadecimal to bytes. → bad_field */
|
|
549
|
+
function hexDecode(text) {
|
|
550
|
+
if (text.length % 2 || !/^[0-9a-fA-F]*$/.test(text)) fail("bad_field");
|
|
551
|
+
const out = new Uint8Array(text.length / 2);
|
|
552
|
+
for (let i = 0; i < out.length; i++) out[i] = parseInt(text.slice(2 * i, 2 * i + 2), 16);
|
|
553
|
+
return out;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const te = new TextEncoder();
|
|
557
|
+
/** UTF-8 bytes of a string. */
|
|
558
|
+
function utf8(s) {
|
|
559
|
+
return te.encode(s);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** Concatenate byte arrays. */
|
|
563
|
+
function concat(...parts) {
|
|
564
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
565
|
+
let o = 0;
|
|
566
|
+
for (const p of parts) {
|
|
567
|
+
out.set(p, o);
|
|
568
|
+
o += p.length;
|
|
569
|
+
}
|
|
570
|
+
return out;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** Byte equality (not constant time; use ctEqual for secrets). */
|
|
574
|
+
function bytesEqual(a, b) {
|
|
575
|
+
if (a.length !== b.length) return false;
|
|
576
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
577
|
+
return true;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** Constant-time equality of two byte arrays ([IMP-4]). */
|
|
581
|
+
function ctEqual(a, b) {
|
|
582
|
+
if (a.length !== b.length) return false;
|
|
583
|
+
let d = 0;
|
|
584
|
+
for (let i = 0; i < a.length; i++) d |= a[i] ^ b[i];
|
|
585
|
+
return d === 0;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
return { base64urlEncode, base64urlDecode, B32, base32Encode, base32Decode, base32Normalize, base32DecodeLenient, hexEncode, hexDecode, utf8, concat, bytesEqual, ctEqual };
|
|
589
|
+
})();
|
|
590
|
+
__modules["labels.js"] = (() => {
|
|
591
|
+
// Domain separation labels (SPEC §5).
|
|
592
|
+
|
|
593
|
+
/** Version of the wire and storage format described by the specification. */
|
|
594
|
+
const FORMAT_VERSION = 1;
|
|
595
|
+
|
|
596
|
+
/** Prefix of every domain-separation label (`rootcard/<purpose>/v<n>`). */
|
|
597
|
+
const LABEL_PREFIX = "rootcard/";
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Build a domain-separation label, e.g. `label("id", 1) === "rootcard/id/v1"`.
|
|
601
|
+
* @param {string} purpose
|
|
602
|
+
* @param {number} [version]
|
|
603
|
+
* @returns {string}
|
|
604
|
+
*/
|
|
605
|
+
function label(purpose, version = FORMAT_VERSION) {
|
|
606
|
+
return `${LABEL_PREFIX}${purpose}/v${version}`;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
return { FORMAT_VERSION, LABEL_PREFIX, label };
|
|
610
|
+
})();
|
|
611
|
+
__modules["crypto.js"] = (() => {
|
|
612
|
+
// Cryptographic primitives (SPEC §4) over WebCrypto, and domain separation (SPEC §5).
|
|
613
|
+
// Private keys are imported non-extractable for signing and agreement; raw secret buffers are overwritten by the
|
|
614
|
+
// callers that own them ([IMP-2]).
|
|
615
|
+
|
|
616
|
+
const { fail } = __modules["errors.js"];
|
|
617
|
+
const { canonical } = __modules["json.js"];
|
|
618
|
+
const { concat, utf8 } = __modules["encoding.js"];
|
|
619
|
+
|
|
620
|
+
const subtle = globalThis.crypto?.subtle;
|
|
621
|
+
|
|
622
|
+
let support;
|
|
623
|
+
/** Check that WebCrypto has Ed25519 and X25519 ([IMP-3]). → unsupported_browser */
|
|
624
|
+
async function checkSupport() {
|
|
625
|
+
if (support === undefined) {
|
|
626
|
+
support = (async () => {
|
|
627
|
+
if (!subtle) return false;
|
|
628
|
+
try {
|
|
629
|
+
await subtle.importKey("raw", new Uint8Array(32).fill(9), { name: "X25519" }, true, []);
|
|
630
|
+
const k = await subtle.importKey("raw", ED_TEST_KEY, { name: "Ed25519" }, true, ["verify"]);
|
|
631
|
+
return !!k;
|
|
632
|
+
} catch {
|
|
633
|
+
return false;
|
|
634
|
+
}
|
|
635
|
+
})();
|
|
636
|
+
}
|
|
637
|
+
if (!(await support)) fail("unsupported_browser");
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// The Ed25519 public key of RFC 8032 test 1, used only to probe for support.
|
|
641
|
+
const ED_TEST_KEY = new Uint8Array([
|
|
642
|
+
0xd7, 0x5a, 0x98, 0x01, 0x82, 0xb1, 0x0a, 0xb7, 0xd5, 0x4b, 0xfe, 0xd3, 0xc9, 0x64, 0x07, 0x3a, 0x0e, 0xe1, 0x72,
|
|
643
|
+
0xf3, 0xda, 0xa6, 0x23, 0x25, 0xaf, 0x02, 0x1a, 0x68, 0xf7, 0x07, 0x51, 0x1a,
|
|
644
|
+
]);
|
|
645
|
+
|
|
646
|
+
/** `frame(label, m) = utf8(label) ‖ 0x00 ‖ m` ([DS-1]). */
|
|
647
|
+
function frame(label, m) {
|
|
648
|
+
return concat(utf8(label), new Uint8Array([0]), m);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** `frame(label, canonical(x))`. */
|
|
652
|
+
function frameObject(label, x) {
|
|
653
|
+
return frame(label, canonical(x));
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** SHA-256. */
|
|
657
|
+
async function sha256(m) {
|
|
658
|
+
return new Uint8Array(await subtle.digest("SHA-256", m));
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** HKDF-SHA256 with a 32-byte output ([CRY-5]). */
|
|
662
|
+
async function hkdf(ikm, salt, info) {
|
|
663
|
+
const key = await subtle.importKey("raw", ikm, "HKDF", false, ["deriveBits"]);
|
|
664
|
+
return new Uint8Array(await subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt, info }, key, 256));
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const ED_PKCS8 = new Uint8Array([0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20]);
|
|
668
|
+
const X_PKCS8 = new Uint8Array([0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x6e, 0x04, 0x22, 0x04, 0x20]);
|
|
669
|
+
|
|
670
|
+
function b64uToBytes(s) {
|
|
671
|
+
const b = atob(s.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (s.length % 4)) % 4));
|
|
672
|
+
return Uint8Array.from(b, (c) => c.charCodeAt(0));
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
async function importPrivate(prefix, alg, secret, extractable, usages) {
|
|
676
|
+
const der = concat(prefix, secret);
|
|
677
|
+
try {
|
|
678
|
+
return await subtle.importKey("pkcs8", der, { name: alg }, extractable, usages);
|
|
679
|
+
} finally {
|
|
680
|
+
der.fill(0);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
async function publicOf(prefix, alg, secret, usages) {
|
|
685
|
+
const k = await importPrivate(prefix, alg, secret, true, usages);
|
|
686
|
+
const jwk = await subtle.exportKey("jwk", k);
|
|
687
|
+
const x = b64uToBytes(jwk.x);
|
|
688
|
+
jwk.d = "";
|
|
689
|
+
return x;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** The Ed25519 public key of a 32-byte seed ([CRY-2]). */
|
|
693
|
+
async function ed25519Public(seed) {
|
|
694
|
+
return publicOf(ED_PKCS8, "Ed25519", seed, ["sign"]);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** Import an Ed25519 seed as a non-extractable signing key ([IMP-2]). */
|
|
698
|
+
async function ed25519SigningKey(seed) {
|
|
699
|
+
return importPrivate(ED_PKCS8, "Ed25519", seed, false, ["sign"]);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** A deterministic Ed25519 signature ([CRY-8]). `key` is a seed or a key from ed25519SigningKey. */
|
|
703
|
+
async function ed25519Sign(key, m) {
|
|
704
|
+
const k = key instanceof Uint8Array ? await ed25519SigningKey(key) : key;
|
|
705
|
+
return new Uint8Array(await subtle.sign("Ed25519", k, m));
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// L, p and the small-order encodings of [CRY-3].
|
|
709
|
+
const L = 2n ** 252n + 27742317777372353535851937790883648493n;
|
|
710
|
+
const P = 2n ** 255n - 19n;
|
|
711
|
+
const SMALL_ORDER = [
|
|
712
|
+
"0100000000000000000000000000000000000000000000000000000000000000",
|
|
713
|
+
"ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f",
|
|
714
|
+
"0000000000000000000000000000000000000000000000000000000000000000",
|
|
715
|
+
"0000000000000000000000000000000000000000000000000000000000000080",
|
|
716
|
+
"26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05",
|
|
717
|
+
"26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc85",
|
|
718
|
+
"c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a",
|
|
719
|
+
"c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa",
|
|
720
|
+
];
|
|
721
|
+
const NEG_ZERO = [
|
|
722
|
+
"0100000000000000000000000000000000000000000000000000000000000080",
|
|
723
|
+
"ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
|
|
724
|
+
];
|
|
725
|
+
|
|
726
|
+
function le(bytes) {
|
|
727
|
+
let n = 0n;
|
|
728
|
+
for (let i = bytes.length - 1; i >= 0; i--) n = (n << 8n) | BigInt(bytes[i]);
|
|
729
|
+
return n;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function hex(b) {
|
|
733
|
+
let s = "";
|
|
734
|
+
for (const x of b) s += x.toString(16).padStart(2, "0");
|
|
735
|
+
return s;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** [CRY-3] steps 3 and 4 for an encoded point: canonical and not of small order (also [KEY-3]). */
|
|
739
|
+
function ed25519PointOk(e) {
|
|
740
|
+
if (e.length !== 32) return false;
|
|
741
|
+
const y = e.slice();
|
|
742
|
+
y[31] &= 0x7f;
|
|
743
|
+
if (le(y) >= P) return false;
|
|
744
|
+
const h = hex(e);
|
|
745
|
+
return !NEG_ZERO.includes(h) && !SMALL_ORDER.includes(h);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** Strict Ed25519 verification ([CRY-3]). → bad_signature */
|
|
749
|
+
async function ed25519Verify(publicKey, m, sig) {
|
|
750
|
+
if (publicKey.length !== 32 || sig.length !== 64) fail("bad_signature");
|
|
751
|
+
const r = sig.subarray(0, 32);
|
|
752
|
+
const s = sig.subarray(32);
|
|
753
|
+
if (le(s) >= L || !ed25519PointOk(publicKey) || !ed25519PointOk(r)) fail("bad_signature");
|
|
754
|
+
let ok = false;
|
|
755
|
+
try {
|
|
756
|
+
const k = await subtle.importKey("raw", publicKey, { name: "Ed25519" }, false, ["verify"]);
|
|
757
|
+
ok = await subtle.verify("Ed25519", k, sig, m);
|
|
758
|
+
} catch {
|
|
759
|
+
ok = false;
|
|
760
|
+
}
|
|
761
|
+
if (!ok) fail("bad_signature");
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/** The X25519 public key of a 32-byte secret ([CRY-2]). */
|
|
765
|
+
async function x25519Public(secret) {
|
|
766
|
+
return publicOf(X_PKCS8, "X25519", secret, ["deriveBits"]);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/** X25519 key agreement; an all-zero result fails ([CRY-4]). → bad_key */
|
|
770
|
+
async function x25519(secret, publicKey) {
|
|
771
|
+
let bits;
|
|
772
|
+
try {
|
|
773
|
+
const priv = await importPrivate(X_PKCS8, "X25519", secret, false, ["deriveBits"]);
|
|
774
|
+
const pub = await subtle.importKey("raw", publicKey, { name: "X25519" }, false, []);
|
|
775
|
+
bits = new Uint8Array(await subtle.deriveBits({ name: "X25519", public: pub }, priv, 256));
|
|
776
|
+
} catch {
|
|
777
|
+
fail("bad_key");
|
|
778
|
+
}
|
|
779
|
+
if (bits.every((b) => b === 0)) fail("bad_key");
|
|
780
|
+
return bits;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** AES-256-GCM encryption: ciphertext ‖ tag ([CRY-7]). */
|
|
784
|
+
async function aesGcmEncrypt(key, nonce, plaintext, aad) {
|
|
785
|
+
const k = await subtle.importKey("raw", key, "AES-GCM", false, ["encrypt"]);
|
|
786
|
+
return new Uint8Array(await subtle.encrypt({ name: "AES-GCM", iv: nonce, additionalData: aad, tagLength: 128 }, k, plaintext));
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/** AES-256-GCM decryption. → unlock_failed */
|
|
790
|
+
async function aesGcmDecrypt(key, nonce, ct, aad) {
|
|
791
|
+
const k = await subtle.importKey("raw", key, "AES-GCM", false, ["decrypt"]);
|
|
792
|
+
try {
|
|
793
|
+
return new Uint8Array(await subtle.decrypt({ name: "AES-GCM", iv: nonce, additionalData: aad, tagLength: 128 }, k, ct));
|
|
794
|
+
} catch {
|
|
795
|
+
fail("unlock_failed");
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
return { checkSupport, frame, frameObject, sha256, hkdf, ed25519Public, ed25519SigningKey, ed25519Sign, ed25519PointOk, ed25519Verify, x25519Public, x25519, aesGcmEncrypt, aesGcmDecrypt };
|
|
800
|
+
})();
|
|
801
|
+
__modules["keys.js"] = (() => {
|
|
802
|
+
// Keys in JSON (SPEC §3.3) and the schema helpers every structure uses ([CJ-11]–[CJ-13]).
|
|
803
|
+
|
|
804
|
+
const { fail } = __modules["errors.js"];
|
|
805
|
+
const { isObject } = __modules["json.js"];
|
|
806
|
+
const { base64urlDecode, base64urlEncode, base32Decode } = __modules["encoding.js"];
|
|
807
|
+
const { ed25519PointOk } = __modules["crypto.js"];
|
|
808
|
+
|
|
809
|
+
/** `"<type>:" + b64u(raw)` ([KEY-1]). */
|
|
810
|
+
function formatKey(type, raw) {
|
|
811
|
+
return `${type}:${base64urlEncode(raw)}`;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** Parse a key string of the required type ([KEY-1]–[KEY-3]). → bad_key */
|
|
815
|
+
function parseKey(s, type) {
|
|
816
|
+
if (typeof s !== "string") fail("bad_key");
|
|
817
|
+
const i = s.indexOf(":");
|
|
818
|
+
if (i < 0 || s.slice(0, i) !== type) fail("bad_key");
|
|
819
|
+
let raw;
|
|
820
|
+
try {
|
|
821
|
+
raw = base64urlDecode(s.slice(i + 1));
|
|
822
|
+
} catch {
|
|
823
|
+
fail("bad_key");
|
|
824
|
+
}
|
|
825
|
+
if (raw.length !== 32) fail("bad_key");
|
|
826
|
+
if (type === "ed25519" && !ed25519PointOk(raw)) fail("bad_key");
|
|
827
|
+
return raw;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// --- schema helpers (each failure is bad_field unless stated otherwise) ---
|
|
831
|
+
|
|
832
|
+
function object(v) {
|
|
833
|
+
if (!isObject(v)) fail("bad_field");
|
|
834
|
+
return v;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/** [CJ-12] */
|
|
838
|
+
function version(m) {
|
|
839
|
+
const v = m.v;
|
|
840
|
+
if (!Object.hasOwn(m, "v") || typeof v !== "number") fail("bad_field");
|
|
841
|
+
if (v !== 1) fail("unsupported_version");
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/** [CJ-11] */
|
|
845
|
+
function members(m, required, optional = []) {
|
|
846
|
+
for (const k of required) if (!Object.hasOwn(m, k)) fail("bad_field");
|
|
847
|
+
for (const k of Object.keys(m)) if (!required.includes(k) && !optional.includes(k)) fail("bad_field");
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function get(m, k) {
|
|
851
|
+
if (!Object.hasOwn(m, k)) fail("bad_field");
|
|
852
|
+
return m[k];
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function string(m, k) {
|
|
856
|
+
const v = get(m, k);
|
|
857
|
+
if (typeof v !== "string") fail("bad_field");
|
|
858
|
+
return v;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function int(m, k) {
|
|
862
|
+
const v = get(m, k);
|
|
863
|
+
if (typeof v !== "number") fail("bad_field");
|
|
864
|
+
return v;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function intIn(m, k, lo, hi) {
|
|
868
|
+
const v = int(m, k);
|
|
869
|
+
if (v < lo || v > hi) fail("bad_field");
|
|
870
|
+
return v;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function bool(m, k) {
|
|
874
|
+
const v = get(m, k);
|
|
875
|
+
if (typeof v !== "boolean") fail("bad_field");
|
|
876
|
+
return v;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function member(m, k) {
|
|
880
|
+
return object(get(m, k));
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function array(m, k) {
|
|
884
|
+
const v = get(m, k);
|
|
885
|
+
if (!Array.isArray(v)) fail("bad_field");
|
|
886
|
+
return v;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/** A base64url string of exactly `n` bytes. */
|
|
890
|
+
function bytes(m, k, n) {
|
|
891
|
+
const s = string(m, k);
|
|
892
|
+
let b;
|
|
893
|
+
try {
|
|
894
|
+
b = base64urlDecode(s);
|
|
895
|
+
} catch {
|
|
896
|
+
fail("bad_field");
|
|
897
|
+
}
|
|
898
|
+
if (b.length !== n) fail("bad_field");
|
|
899
|
+
return b;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/** A key string: not a string → bad_field, a bad key → bad_key ([CJ-13]). */
|
|
903
|
+
function key(m, k, type) {
|
|
904
|
+
return parseKey(string(m, k), type);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/** A 64-byte signature ([KEY-4]). */
|
|
908
|
+
function sig(m, k) {
|
|
909
|
+
return bytes(m, k, 64);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/** An account id in strict form ([ID-3]). */
|
|
913
|
+
function account(m, k) {
|
|
914
|
+
const s = string(m, k);
|
|
915
|
+
checkAccountId(s);
|
|
916
|
+
return s;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/** [ID-3] → bad_field */
|
|
920
|
+
function checkAccountId(s) {
|
|
921
|
+
try {
|
|
922
|
+
base32Decode(s, 32);
|
|
923
|
+
} catch {
|
|
924
|
+
fail("bad_field");
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/** A version `{ seq, hash }` ([VER-1]). */
|
|
929
|
+
function versionRef(v) {
|
|
930
|
+
const m = object(v);
|
|
931
|
+
members(m, ["seq", "hash"]);
|
|
932
|
+
intIn(m, "seq", 0, 999);
|
|
933
|
+
bytes(m, "hash", 32);
|
|
934
|
+
return { seq: m.seq, hash: m.hash };
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
return { formatKey, parseKey, object, version, members, get, string, int, intIn, bool, member, array, bytes, key, sig, account, checkAccountId, versionRef };
|
|
938
|
+
})();
|
|
939
|
+
__modules["recovery.js"] = (() => {
|
|
940
|
+
// The recovery key (SPEC §6).
|
|
941
|
+
|
|
942
|
+
const { fail } = __modules["errors.js"];
|
|
943
|
+
const { B32, base32Decode, base32Encode, base32Normalize, bytesEqual } = __modules["encoding.js"];
|
|
944
|
+
const { ed25519Public, frame, frameObject, hkdf, sha256 } = __modules["crypto.js"];
|
|
945
|
+
const { label } = __modules["labels.js"];
|
|
946
|
+
|
|
947
|
+
async function checksum(key) {
|
|
948
|
+
return base32Encode(await sha256(frame(label("recovery/check"), key))).slice(0, 4);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/** The written form `RCR-XXXX-…-CCCC` ([RK-1]). */
|
|
952
|
+
async function formatRecoveryKey(key) {
|
|
953
|
+
const body = base32Encode(key) + (await checksum(key));
|
|
954
|
+
const groups = [];
|
|
955
|
+
for (let i = 0; i < 14; i++) groups.push(body.slice(4 * i, 4 * i + 4));
|
|
956
|
+
return "RCR-" + groups.join("-");
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/** Parse a typed recovery key ([RK-2]). → bad_base32, bad_checksum */
|
|
960
|
+
async function parseRecoveryKey(text) {
|
|
961
|
+
let s = [...text].filter((c) => c !== "-" && c !== " ").join("");
|
|
962
|
+
if ([...s].length === 59 && s.slice(0, 3).toUpperCase() === "RCR") s = s.slice(3);
|
|
963
|
+
if (s.length !== 56 || !/^[\x00-\x7f]*$/.test(s)) fail("bad_base32");
|
|
964
|
+
const n = base32Normalize(s);
|
|
965
|
+
for (const c of n) if (!B32.includes(c)) fail("bad_base32");
|
|
966
|
+
const key = base32Decode(n.slice(0, 52), 32);
|
|
967
|
+
if (n.slice(52) !== (await checksum(key))) fail("bad_checksum");
|
|
968
|
+
return key;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* Derive an identity's recovery keys ([RK-4]): `signSeed` (Ed25519) and `boxSecret` (X25519).
|
|
973
|
+
* @returns {Promise<{ signSeed: Uint8Array, boxSecret: Uint8Array }>}
|
|
974
|
+
*/
|
|
975
|
+
async function deriveRecoveryKeys(rk, realm, username) {
|
|
976
|
+
const ctx = { realm, username };
|
|
977
|
+
return {
|
|
978
|
+
signSeed: await hkdf(rk, new Uint8Array(0), frameObject(label("recovery/sign"), ctx)),
|
|
979
|
+
boxSecret: await hkdf(rk, new Uint8Array(0), frameObject(label("recovery/box"), ctx)),
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/** The public recovery signing key. */
|
|
984
|
+
async function recoverySignPublic(keys) {
|
|
985
|
+
return ed25519Public(keys.signSeed);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
return { bytesEqual, formatRecoveryKey, parseRecoveryKey, deriveRecoveryKeys, recoverySignPublic };
|
|
991
|
+
})();
|
|
992
|
+
__modules["id.js"] = (() => {
|
|
993
|
+
// Realm, username, inception, account id (SPEC §7), handles and tags (SPEC §8).
|
|
994
|
+
|
|
995
|
+
const { fail } = __modules["errors.js"];
|
|
996
|
+
const { B32, base32Encode, base64urlEncode } = __modules["encoding.js"];
|
|
997
|
+
const { ed25519Public, ed25519Sign, frameObject, sha256 } = __modules["crypto.js"];
|
|
998
|
+
const { without } = __modules["json.js"];
|
|
999
|
+
const { formatKey } = __modules["keys.js"];
|
|
1000
|
+
const { label } = __modules["labels.js"];
|
|
1001
|
+
|
|
1002
|
+
/** [RLM-1] → bad_realm */
|
|
1003
|
+
function checkRealm(realm) {
|
|
1004
|
+
if (typeof realm !== "string" || !/^[a-z0-9]([a-z0-9._-]{0,62}[a-z0-9])?$/.test(realm)) fail("bad_realm");
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/** [USR-1] → bad_username */
|
|
1008
|
+
function checkUsername(username) {
|
|
1009
|
+
if (typeof username !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$/.test(username)) fail("bad_username");
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/** The default username policy ([USR-3]): [USR-1], 3 to 20 characters, beginning with A–Z a–z. */
|
|
1013
|
+
function defaultUsernamePolicy(username) {
|
|
1014
|
+
return /^[A-Za-z][A-Za-z0-9_-]{2,19}$/.test(username);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/** [USR-2] */
|
|
1018
|
+
function similarUsernames(a, b) {
|
|
1019
|
+
return a.toLowerCase() === b.toLowerCase() && /^[\x00-\x7f]*$/.test(a + b);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/** The account id of an inception ([ID-3]). */
|
|
1023
|
+
async function accountId(inception) {
|
|
1024
|
+
return base32Encode(await sha256(frameObject(label("id"), without(inception, "sigs"))));
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/** The deterministic inception of an identity ([ID-1], [ID-2]). → bad_realm, bad_username */
|
|
1028
|
+
async function inception(keys, realm, username) {
|
|
1029
|
+
checkRealm(realm);
|
|
1030
|
+
checkUsername(username);
|
|
1031
|
+
const recovery = formatKey("ed25519", await ed25519Public(keys.signSeed));
|
|
1032
|
+
const unsigned = { v: 1, kind: "incept", seq: 0, body: { realm, username, recovery } };
|
|
1033
|
+
const sig = await ed25519Sign(keys.signSeed, frameObject(label("event"), unsigned));
|
|
1034
|
+
return { ...unsigned, sigs: [{ key: recovery, sig: base64urlEncode(sig) }] };
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/** The tag of an account id ([HDL-1]). */
|
|
1038
|
+
function tag(account) {
|
|
1039
|
+
return account.slice(0, 4);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/** `username + "#" + tag` ([HDL-1]). */
|
|
1043
|
+
function handle(username, account) {
|
|
1044
|
+
return `${username}#${tag(account)}`;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function lenientChars(s) {
|
|
1048
|
+
let out = "";
|
|
1049
|
+
for (const c of s) {
|
|
1050
|
+
const u = c.length === 1 && c >= "a" && c <= "z" ? c.toUpperCase() : c;
|
|
1051
|
+
const m = u === "I" || u === "L" ? "1" : u === "O" ? "0" : u;
|
|
1052
|
+
if (m.length !== 1 || !B32.includes(m)) return null;
|
|
1053
|
+
out += m;
|
|
1054
|
+
}
|
|
1055
|
+
return out;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/** Parse a typed handle ([HDL-3]). → bad_handle */
|
|
1059
|
+
function parseHandle(text) {
|
|
1060
|
+
const at = text.lastIndexOf("#");
|
|
1061
|
+
if (at < 0) fail("bad_handle");
|
|
1062
|
+
const username = text.slice(0, at);
|
|
1063
|
+
try {
|
|
1064
|
+
checkUsername(username);
|
|
1065
|
+
} catch {
|
|
1066
|
+
fail("bad_handle");
|
|
1067
|
+
}
|
|
1068
|
+
const t = text.slice(at + 1);
|
|
1069
|
+
const dash = t.indexOf("-");
|
|
1070
|
+
const t4 = lenientChars(dash < 0 ? t : t.slice(0, dash));
|
|
1071
|
+
if (t4 === null || t4.length !== 4) fail("bad_handle");
|
|
1072
|
+
const result = { username, tag: t4 };
|
|
1073
|
+
if (dash >= 0) {
|
|
1074
|
+
const e = lenientChars(t.slice(dash + 1));
|
|
1075
|
+
if (e === null || e.length < 1 || e.length > 48) fail("bad_handle");
|
|
1076
|
+
result.extension = e;
|
|
1077
|
+
}
|
|
1078
|
+
return result;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/** Whether an identity matches a parsed handle (similar username, [USR-2]). */
|
|
1082
|
+
function handleMatches(parsed, username, account) {
|
|
1083
|
+
return similarUsernames(parsed.username, username) && account.startsWith(parsed.tag + (parsed.extension ?? ""));
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/** The extended tags of colliding account ids ([HDL-6]), in the order given. */
|
|
1087
|
+
function extendedTags(ids) {
|
|
1088
|
+
const distinct = [...new Set(ids)];
|
|
1089
|
+
if (distinct.length < 2) return ids.map(tag);
|
|
1090
|
+
const max = Math.min(...distinct.map((s) => s.length));
|
|
1091
|
+
let n = 5;
|
|
1092
|
+
while (n < max) {
|
|
1093
|
+
const prefixes = distinct.map((s) => s.slice(0, n));
|
|
1094
|
+
if (new Set(prefixes).size === prefixes.length) break;
|
|
1095
|
+
n++;
|
|
1096
|
+
}
|
|
1097
|
+
n = Math.min(n, max);
|
|
1098
|
+
return ids.map((id) => `${id.slice(0, 4)}-${id.slice(4, n)}`);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
return { checkRealm, checkUsername, defaultUsernamePolicy, similarUsernames, accountId, inception, tag, handle, parseHandle, handleMatches, extendedTags };
|
|
1102
|
+
})();
|
|
1103
|
+
__modules["log.js"] = (() => {
|
|
1104
|
+
// The identity log (SPEC §9) and versions (SPEC §10). A log state is plain JSON, identical to the Rust crate's
|
|
1105
|
+
// `LogState::to_value`, so that hosts can store it ([LOG-16]).
|
|
1106
|
+
|
|
1107
|
+
const { fail } = __modules["errors.js"];
|
|
1108
|
+
const { base64urlEncode, base64urlDecode, bytesEqual } = __modules["encoding.js"];
|
|
1109
|
+
const { ed25519Public, ed25519Sign, ed25519Verify, frameObject, sha256 } = __modules["crypto.js"];
|
|
1110
|
+
const { canonical, without, isObject, MAX_SAFE } = __modules["json.js"];
|
|
1111
|
+
const s = __modules["keys.js"];
|
|
1112
|
+
const { formatKey } = __modules["keys.js"];
|
|
1113
|
+
const { accountId, checkRealm, checkUsername, handle } = __modules["id.js"];
|
|
1114
|
+
const { label } = __modules["labels.js"];
|
|
1115
|
+
|
|
1116
|
+
const MAX_EVENTS = 1000;
|
|
1117
|
+
const EVENT_LIMIT = 4096;
|
|
1118
|
+
const FUTURE_WINDOW = 600;
|
|
1119
|
+
const REASONS = ["register", "revoke", "restore", "fresh"];
|
|
1120
|
+
|
|
1121
|
+
/** The message owners sign on an event ([LOG-6]). */
|
|
1122
|
+
function eventMessage(e) {
|
|
1123
|
+
return frameObject(label("event"), without(e, "sigs"));
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
/** The message the primary host signs as its receipt ([LOG-8]). */
|
|
1127
|
+
function receiptMessage(e) {
|
|
1128
|
+
return frameObject(label("receipt"), without(e, "sigs"));
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/** The event hash ([LOG-5]). */
|
|
1132
|
+
async function eventHash(e) {
|
|
1133
|
+
return sha256(eventMessage(e));
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/** `home.url` ([LOG-4]). → bad_field */
|
|
1137
|
+
function checkUrl(url) {
|
|
1138
|
+
if (typeof url !== "string" || url.length < 1 || url.length > 256 || !/^[\x21-\x7e]+$/.test(url)) fail("bad_field");
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function parseHome(m) {
|
|
1142
|
+
s.members(m, ["key", "url"]);
|
|
1143
|
+
const key = s.key(m, "key", "ed25519");
|
|
1144
|
+
checkUrl(s.string(m, "url"));
|
|
1145
|
+
return { key: m.key, keyRaw: key, url: m.url };
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/** [LOG-12] step 2 for one event. */
|
|
1149
|
+
function parseEvent(v, index) {
|
|
1150
|
+
if (isObject(v) && canonical(v).length > EVENT_LIMIT) fail("too_large");
|
|
1151
|
+
const m = s.object(v);
|
|
1152
|
+
s.version(m);
|
|
1153
|
+
const kind = s.string(m, "kind");
|
|
1154
|
+
if (!["incept", "instance", "rotate_recovery", "retire"].includes(kind)) fail("bad_kind");
|
|
1155
|
+
if ((index === 0) !== (kind === "incept")) fail("bad_kind");
|
|
1156
|
+
if (kind === "incept") s.members(m, ["v", "kind", "seq", "body", "sigs"]);
|
|
1157
|
+
else s.members(m, ["v", "account", "seq", "prev", "kind", "time", "body", "sigs"]);
|
|
1158
|
+
const e = { map: m, kind, seq: s.intIn(m, "seq", 0, MAX_SAFE) };
|
|
1159
|
+
if (kind !== "incept") {
|
|
1160
|
+
e.account = s.account(m, "account");
|
|
1161
|
+
e.prev = s.bytes(m, "prev", 32);
|
|
1162
|
+
e.time = s.intIn(m, "time", 0, MAX_SAFE);
|
|
1163
|
+
} else {
|
|
1164
|
+
e.time = 0;
|
|
1165
|
+
}
|
|
1166
|
+
e.sigs = [];
|
|
1167
|
+
for (const x of s.array(m, "sigs")) {
|
|
1168
|
+
const o = s.object(x);
|
|
1169
|
+
s.members(o, ["key", "sig"]);
|
|
1170
|
+
s.key(o, "key", "ed25519");
|
|
1171
|
+
e.sigs.push({ key: o.key, sig: s.sig(o, "sig") });
|
|
1172
|
+
}
|
|
1173
|
+
const body = s.member(m, "body");
|
|
1174
|
+
if (kind === "incept") {
|
|
1175
|
+
s.members(body, ["realm", "username", "recovery"]);
|
|
1176
|
+
e.realm = s.string(body, "realm");
|
|
1177
|
+
e.username = s.string(body, "username");
|
|
1178
|
+
s.key(body, "recovery", "ed25519");
|
|
1179
|
+
e.recovery = body.recovery;
|
|
1180
|
+
} else if (kind === "instance") {
|
|
1181
|
+
s.members(body, ["account_key", "home", "reason"]);
|
|
1182
|
+
s.key(body, "account_key", "ed25519");
|
|
1183
|
+
e.accountKey = body.account_key;
|
|
1184
|
+
e.home = parseHome(s.member(body, "home"));
|
|
1185
|
+
const reason = s.string(body, "reason");
|
|
1186
|
+
if (!REASONS.includes(reason)) fail("bad_reason");
|
|
1187
|
+
e.reason = reason;
|
|
1188
|
+
} else if (kind === "rotate_recovery") {
|
|
1189
|
+
s.members(body, ["recovery"]);
|
|
1190
|
+
s.key(body, "recovery", "ed25519");
|
|
1191
|
+
e.recovery = body.recovery;
|
|
1192
|
+
} else {
|
|
1193
|
+
s.members(body, []);
|
|
1194
|
+
}
|
|
1195
|
+
return e;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function checkLength(n) {
|
|
1199
|
+
if (n === 0) fail("bad_field");
|
|
1200
|
+
if (n > MAX_EVENTS) fail("too_many_events");
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function rawKey(k) {
|
|
1204
|
+
return s.parseKey(k, "ed25519");
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/** [LOG-12] step 4 for one event, updating the state `st` (a copy) in place. */
|
|
1208
|
+
async function apply(st, e, index, now, receipt) {
|
|
1209
|
+
if (st.retired) fail("account_retired");
|
|
1210
|
+
if (e.account !== st.account) fail("account_mismatch");
|
|
1211
|
+
if (e.seq !== index) fail("bad_seq");
|
|
1212
|
+
if (base64urlEncode(e.prev) !== st.version.hash) fail("bad_prev");
|
|
1213
|
+
if (e.time < st.time) fail("time_backwards");
|
|
1214
|
+
if (now !== undefined && now !== null && e.time > now + FUTURE_WINDOW) fail("future_time");
|
|
1215
|
+
let expected;
|
|
1216
|
+
if (e.kind === "instance") {
|
|
1217
|
+
expected = [st.recovery, e.accountKey];
|
|
1218
|
+
if (receipt) expected.push(e.home.key);
|
|
1219
|
+
} else if (e.kind === "rotate_recovery") expected = [st.recovery, e.recovery];
|
|
1220
|
+
else expected = [st.recovery];
|
|
1221
|
+
if (e.sigs.length !== expected.length || e.sigs.some((x, i) => x.key !== expected[i])) fail("wrong_signer");
|
|
1222
|
+
if (e.kind === "instance") {
|
|
1223
|
+
const ak = e.accountKey;
|
|
1224
|
+
const hk = e.home.key;
|
|
1225
|
+
if (ak === hk || st.keys.includes(ak) || st.homes.includes(ak) || st.keys.includes(hk)) fail("key_reuse");
|
|
1226
|
+
} else if (e.kind === "rotate_recovery") {
|
|
1227
|
+
if (st.keys.includes(e.recovery) || st.homes.includes(e.recovery)) fail("key_reuse");
|
|
1228
|
+
}
|
|
1229
|
+
if (e.reason !== undefined) {
|
|
1230
|
+
const has = st.instance !== undefined;
|
|
1231
|
+
const ok = e.reason === "register" ? !has : e.reason === "fresh" ? true : has;
|
|
1232
|
+
if (!ok) fail("bad_reason");
|
|
1233
|
+
}
|
|
1234
|
+
const msg = eventMessage(e.map);
|
|
1235
|
+
const owners = e.kind === "instance" ? 2 : e.sigs.length;
|
|
1236
|
+
for (let i = 0; i < owners; i++) await ed25519Verify(rawKey(e.sigs[i].key), msg, e.sigs[i].sig);
|
|
1237
|
+
if (e.kind === "instance" && receipt) {
|
|
1238
|
+
try {
|
|
1239
|
+
await ed25519Verify(rawKey(e.sigs[2].key), receiptMessage(e.map), e.sigs[2].sig);
|
|
1240
|
+
} catch {
|
|
1241
|
+
fail("bad_receipt");
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
const hash = base64urlEncode(await sha256(msg));
|
|
1245
|
+
if (e.kind === "instance") {
|
|
1246
|
+
st.keys.push(e.accountKey);
|
|
1247
|
+
if (!st.homes.includes(e.home.key)) st.homes.push(e.home.key);
|
|
1248
|
+
st.instance = {
|
|
1249
|
+
hash,
|
|
1250
|
+
seq: index,
|
|
1251
|
+
account_key: e.accountKey,
|
|
1252
|
+
home: { key: e.home.key, url: e.home.url },
|
|
1253
|
+
reason: e.reason,
|
|
1254
|
+
};
|
|
1255
|
+
} else if (e.kind === "rotate_recovery") {
|
|
1256
|
+
st.recovery = e.recovery;
|
|
1257
|
+
st.keys.push(e.recovery);
|
|
1258
|
+
} else if (e.kind === "retire") {
|
|
1259
|
+
st.retired = true;
|
|
1260
|
+
delete st.instance;
|
|
1261
|
+
}
|
|
1262
|
+
st.time = e.time;
|
|
1263
|
+
st.version = { seq: index, hash };
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
function copyState(st) {
|
|
1267
|
+
const c = {
|
|
1268
|
+
account: st.account,
|
|
1269
|
+
realm: st.realm,
|
|
1270
|
+
username: st.username,
|
|
1271
|
+
handle: st.handle,
|
|
1272
|
+
recovery: st.recovery,
|
|
1273
|
+
retired: st.retired,
|
|
1274
|
+
version: { ...st.version },
|
|
1275
|
+
time: st.time,
|
|
1276
|
+
keys: [...st.keys],
|
|
1277
|
+
homes: [...st.homes],
|
|
1278
|
+
};
|
|
1279
|
+
if (st.instance) c.instance = { ...st.instance, home: { ...st.instance.home } };
|
|
1280
|
+
return c;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/**
|
|
1284
|
+
* Verify a whole log ([LOG-12]) and return its state.
|
|
1285
|
+
* @param {any[]} events
|
|
1286
|
+
* @param {{ realms: string[], now?: number }} options
|
|
1287
|
+
*/
|
|
1288
|
+
async function verifyLog(events, options) {
|
|
1289
|
+
if (!Array.isArray(events)) fail("bad_field");
|
|
1290
|
+
checkLength(events.length);
|
|
1291
|
+
const parsed = events.map((v, i) => parseEvent(v, i));
|
|
1292
|
+
const inc = parsed[0];
|
|
1293
|
+
if (inc.seq !== 0) fail("bad_seq");
|
|
1294
|
+
checkRealm(inc.realm);
|
|
1295
|
+
if (!options.realms.includes(inc.realm)) fail("realm_not_accepted");
|
|
1296
|
+
checkUsername(inc.username);
|
|
1297
|
+
if (inc.sigs.length !== 1 || inc.sigs[0].key !== inc.recovery) fail("wrong_signer");
|
|
1298
|
+
const msg = eventMessage(inc.map);
|
|
1299
|
+
await ed25519Verify(rawKey(inc.recovery), msg, inc.sigs[0].sig);
|
|
1300
|
+
const account = await accountId(inc.map);
|
|
1301
|
+
const st = {
|
|
1302
|
+
account,
|
|
1303
|
+
realm: inc.realm,
|
|
1304
|
+
username: inc.username,
|
|
1305
|
+
handle: handle(inc.username, account),
|
|
1306
|
+
recovery: inc.recovery,
|
|
1307
|
+
retired: false,
|
|
1308
|
+
version: { seq: 0, hash: base64urlEncode(await sha256(msg)) },
|
|
1309
|
+
time: 0,
|
|
1310
|
+
keys: [inc.recovery],
|
|
1311
|
+
homes: [],
|
|
1312
|
+
};
|
|
1313
|
+
for (let i = 1; i < parsed.length; i++) await apply(st, parsed[i], i, options.now, true);
|
|
1314
|
+
return st;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
/** Verify events that extend a verified log state ([LOG-15]). */
|
|
1318
|
+
async function extendLog(state, events, options) {
|
|
1319
|
+
const start = state.version.seq + 1;
|
|
1320
|
+
if (start + events.length > MAX_EVENTS) fail("too_many_events");
|
|
1321
|
+
const parsed = events.map((v, i) => parseEvent(v, start + i));
|
|
1322
|
+
const st = copyState(state);
|
|
1323
|
+
for (let i = 0; i < parsed.length; i++) await apply(st, parsed[i], start + i, options.now, true);
|
|
1324
|
+
return st;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/** Check a pending instance ([LOG-9]). */
|
|
1328
|
+
async function verifyPending(state, event, now) {
|
|
1329
|
+
const index = state.version.seq + 1;
|
|
1330
|
+
if (index >= MAX_EVENTS) fail("too_many_events");
|
|
1331
|
+
const e = parseEvent(event, index);
|
|
1332
|
+
if (e.kind !== "instance") fail("bad_kind");
|
|
1333
|
+
await apply(copyState(state), e, index, now, false);
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/** Compare two valid logs of one account ([VER-2]): `b` relative to `a`. → account_mismatch */
|
|
1337
|
+
async function compareLogs(a, b) {
|
|
1338
|
+
if (!a.length || !b.length || !isObject(a[0]) || !isObject(b[0])) fail("bad_field");
|
|
1339
|
+
if ((await accountId(a[0])) !== (await accountId(b[0]))) fail("account_mismatch");
|
|
1340
|
+
const h = async (v) => {
|
|
1341
|
+
if (!isObject(v)) fail("bad_field");
|
|
1342
|
+
return eventHash(v);
|
|
1343
|
+
};
|
|
1344
|
+
if (a.length === b.length) {
|
|
1345
|
+
return bytesEqual(await h(a[a.length - 1]), await h(b[b.length - 1])) ? "same" : "fork";
|
|
1346
|
+
}
|
|
1347
|
+
const [short, long, order] = a.length < b.length ? [a, b, "newer"] : [b, a, "older"];
|
|
1348
|
+
return bytesEqual(await h(short[short.length - 1]), await h(long[short.length - 1])) ? order : "fork";
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
/** An unsigned event after `state` (`sigs` empty). */
|
|
1352
|
+
function unsignedEvent(state, kind, body, time) {
|
|
1353
|
+
return {
|
|
1354
|
+
v: 1,
|
|
1355
|
+
account: state.account,
|
|
1356
|
+
seq: state.version.seq + 1,
|
|
1357
|
+
prev: state.version.hash,
|
|
1358
|
+
kind,
|
|
1359
|
+
time,
|
|
1360
|
+
body,
|
|
1361
|
+
sigs: [],
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
/** Append an owner signature ([LOG-6]). */
|
|
1366
|
+
async function signEvent(e, seed) {
|
|
1367
|
+
const sig = await ed25519Sign(seed, eventMessage(e));
|
|
1368
|
+
e.sigs = [...e.sigs, { key: formatKey("ed25519", await ed25519Public(seed)), sig: base64urlEncode(sig) }];
|
|
1369
|
+
return e;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
/** Append the primary host's receipt ([LOG-8]). */
|
|
1373
|
+
async function addReceipt(e, hostSeed) {
|
|
1374
|
+
const sig = await ed25519Sign(hostSeed, receiptMessage(e));
|
|
1375
|
+
e.sigs = [...e.sigs, { key: formatKey("ed25519", await ed25519Public(hostSeed)), sig: base64urlEncode(sig) }];
|
|
1376
|
+
return e;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
/** [RK-5] → wrong_recovery_key */
|
|
1380
|
+
async function checkRecoveryKey(state, seed) {
|
|
1381
|
+
if (formatKey("ed25519", await ed25519Public(seed)) !== state.recovery) fail("wrong_recovery_key");
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
/** A pending instance ([LOG-9]). `home` is `{ key: "ed25519:…", url }`. */
|
|
1385
|
+
async function pendingInstance(state, recoverySeed, accountSeed, home, reason, time) {
|
|
1386
|
+
await checkRecoveryKey(state, recoverySeed);
|
|
1387
|
+
checkUrl(home.url);
|
|
1388
|
+
const body = {
|
|
1389
|
+
account_key: formatKey("ed25519", await ed25519Public(accountSeed)),
|
|
1390
|
+
home: { key: home.key, url: home.url },
|
|
1391
|
+
reason,
|
|
1392
|
+
};
|
|
1393
|
+
const e = unsignedEvent(state, "instance", body, time);
|
|
1394
|
+
await signEvent(e, recoverySeed);
|
|
1395
|
+
await signEvent(e, accountSeed);
|
|
1396
|
+
return e;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
/** A `rotate_recovery` event ([KT-5]). */
|
|
1400
|
+
async function rotateRecoveryEvent(state, oldSeed, newSeed, time) {
|
|
1401
|
+
await checkRecoveryKey(state, oldSeed);
|
|
1402
|
+
const e = unsignedEvent(state, "rotate_recovery", { recovery: formatKey("ed25519", await ed25519Public(newSeed)) }, time);
|
|
1403
|
+
await signEvent(e, oldSeed);
|
|
1404
|
+
await signEvent(e, newSeed);
|
|
1405
|
+
return e;
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
/** A `retire` event ([KT-6]). */
|
|
1409
|
+
async function retireEvent(state, recoverySeed, time) {
|
|
1410
|
+
await checkRecoveryKey(state, recoverySeed);
|
|
1411
|
+
const e = unsignedEvent(state, "retire", {}, time);
|
|
1412
|
+
await signEvent(e, recoverySeed);
|
|
1413
|
+
return e;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
/** The raw bytes of a b64u hash string. */
|
|
1417
|
+
function hashBytes(h) {
|
|
1418
|
+
return base64urlDecode(h);
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
return { MAX_EVENTS, EVENT_LIMIT, FUTURE_WINDOW, eventMessage, receiptMessage, eventHash, checkUrl, verifyLog, extendLog, verifyPending, compareLogs, unsignedEvent, signEvent, addReceipt, checkRecoveryKey, pendingInstance, rotateRecoveryEvent, retireEvent, hashBytes };
|
|
1422
|
+
})();
|
|
1423
|
+
__modules["vaultcheck.js"] = (() => {
|
|
1424
|
+
// The vault's structure and signatures (SPEC §11), without any decryption: what hosts need ([VLT-3], [VLT-4]).
|
|
1425
|
+
|
|
1426
|
+
const { fail } = __modules["errors.js"];
|
|
1427
|
+
const { base64urlDecode, bytesEqual } = __modules["encoding.js"];
|
|
1428
|
+
const { ed25519Verify, frameObject, sha256 } = __modules["crypto.js"];
|
|
1429
|
+
const { canonical, isObject, without } = __modules["json.js"];
|
|
1430
|
+
const s = __modules["keys.js"];
|
|
1431
|
+
const { label } = __modules["labels.js"];
|
|
1432
|
+
|
|
1433
|
+
const VAULT_LIMIT = 96 * 1024;
|
|
1434
|
+
const CONTENTS_LIMIT = 64 * 1024;
|
|
1435
|
+
const DATA_LIMIT = 48 * 1024;
|
|
1436
|
+
const MAX_SLOTS = 15;
|
|
1437
|
+
const MAX_REV = 2 ** 32;
|
|
1438
|
+
|
|
1439
|
+
/** Argon2id defaults and bounds ([LCK-6]). */
|
|
1440
|
+
const KDF_DEFAULT = Object.freeze({ m: 65536, t: 3, p: 1 });
|
|
1441
|
+
const KDF_MIN = Object.freeze({ m: 47104, t: 2, p: 1 });
|
|
1442
|
+
|
|
1443
|
+
/** [LCK-6] → kdf_params */
|
|
1444
|
+
function checkKdf(k) {
|
|
1445
|
+
if (!(k.p === 1 && k.m >= 47104 && k.m <= 1048576 && k.t >= 2 && k.t <= 16)) fail("kdf_params");
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
/** [LCK-2] → bad_field */
|
|
1449
|
+
function checkLabel(l) {
|
|
1450
|
+
const chars = [...l];
|
|
1451
|
+
if (chars.length < 1 || chars.length > 64 || chars.some((c) => c < " " || c === "\x7f")) fail("bad_field");
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
/** The grant message signed by the recovery key ([LCK-3]). */
|
|
1455
|
+
function grantMessage(grant, account, instance) {
|
|
1456
|
+
return frameObject(label("locks"), { ...without(grant, "sig"), account, instance });
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
/** The message the account key signs on a vault ([VLT-2]). */
|
|
1460
|
+
function vaultMessage(vault) {
|
|
1461
|
+
return frameObject(label("vault"), without(vault, "sig"));
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
/** The authenticated data of the contents encryption ([VLT-6]). */
|
|
1465
|
+
function contentsAad(vault) {
|
|
1466
|
+
return frameObject(label("contents"), without(vault, "nonce", "ct", "sig"));
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
function parseWrap(m) {
|
|
1470
|
+
s.members(m, ["epk", "nonce", "ct"]);
|
|
1471
|
+
return { epk: s.key(m, "epk", "x25519"), nonce: s.bytes(m, "nonce", 12), ct: s.bytes(m, "ct", 48) };
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
function parseLock(v) {
|
|
1475
|
+
const m = s.object(v);
|
|
1476
|
+
const kind = s.string(m, "kind");
|
|
1477
|
+
if (kind === "password") {
|
|
1478
|
+
s.members(m, ["id", "kind", "kdf", "auth", "box", "wrap"]);
|
|
1479
|
+
const id = s.string(m, "id");
|
|
1480
|
+
s.bytes(m, "id", 8);
|
|
1481
|
+
const k = s.member(m, "kdf");
|
|
1482
|
+
s.members(k, ["alg", "m", "t", "p", "salt"]);
|
|
1483
|
+
const kdf = { alg: s.string(k, "alg"), m: s.int(k, "m"), t: s.int(k, "t"), p: s.int(k, "p"), salt: s.bytes(k, "salt", 16) };
|
|
1484
|
+
const auth = s.key(m, "auth", "ed25519");
|
|
1485
|
+
const box = s.key(m, "box", "x25519");
|
|
1486
|
+
const wrap = parseWrap(s.member(m, "wrap"));
|
|
1487
|
+
return { kind, id, kdf, auth, box, wrap, value: m };
|
|
1488
|
+
}
|
|
1489
|
+
if (kind === "recovery") {
|
|
1490
|
+
s.members(m, ["id", "kind", "box", "wrap"]);
|
|
1491
|
+
const id = s.string(m, "id");
|
|
1492
|
+
const box = s.key(m, "box", "x25519");
|
|
1493
|
+
return { kind, id, box, boxString: m.box, wrap: parseWrap(s.member(m, "wrap")), value: m };
|
|
1494
|
+
}
|
|
1495
|
+
fail("bad_field");
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
function parseVault(v) {
|
|
1499
|
+
if (isObject(v) && canonical(v).length > VAULT_LIMIT) fail("too_large");
|
|
1500
|
+
const m = s.object(v);
|
|
1501
|
+
s.version(m);
|
|
1502
|
+
s.members(m, ["v", "account", "instance", "rev", "locks", "nonce", "ct", "sig"]);
|
|
1503
|
+
s.account(m, "account");
|
|
1504
|
+
s.bytes(m, "instance", 32);
|
|
1505
|
+
const rev = s.intIn(m, "rev", 1, MAX_REV);
|
|
1506
|
+
const locks = s.member(m, "locks");
|
|
1507
|
+
s.members(locks, ["grant", "list"]);
|
|
1508
|
+
const grant = s.member(locks, "grant");
|
|
1509
|
+
s.members(grant, ["gen", "slots", "recovery", "sig"]);
|
|
1510
|
+
const gen = s.intIn(grant, "gen", 1, MAX_REV);
|
|
1511
|
+
const slots = [];
|
|
1512
|
+
for (const x of s.array(grant, "slots")) {
|
|
1513
|
+
const o = s.object(x);
|
|
1514
|
+
s.members(o, ["id", "label"]);
|
|
1515
|
+
s.bytes(o, "id", 8);
|
|
1516
|
+
slots.push({ id: s.string(o, "id"), label: s.string(o, "label") });
|
|
1517
|
+
}
|
|
1518
|
+
s.key(grant, "recovery", "x25519");
|
|
1519
|
+
const grantSig = s.sig(grant, "sig");
|
|
1520
|
+
const list = s.array(locks, "list").map(parseLock);
|
|
1521
|
+
s.bytes(m, "nonce", 12);
|
|
1522
|
+
let ct;
|
|
1523
|
+
try {
|
|
1524
|
+
ct = base64urlDecode(s.string(m, "ct"));
|
|
1525
|
+
} catch {
|
|
1526
|
+
fail("bad_field");
|
|
1527
|
+
}
|
|
1528
|
+
if (ct.length < 16) fail("bad_field");
|
|
1529
|
+
const sig = s.sig(m, "sig");
|
|
1530
|
+
return { map: m, grant, rev, gen, slots, list, grantSig, sig };
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
function checkLockSet(p) {
|
|
1534
|
+
const { slots, list } = p;
|
|
1535
|
+
const unique = new Set(slots.map((x) => x.id)).size === slots.length;
|
|
1536
|
+
const rec = list.filter((l) => l.kind === "recovery");
|
|
1537
|
+
const recoveryOk = rec.length === 1 && rec[0].id === "recovery" && rec[0].boxString === p.grant.recovery;
|
|
1538
|
+
const pw = list.filter((l) => l.kind === "password");
|
|
1539
|
+
const perSlot = pw.length === slots.length && slots.every((x) => pw.filter((l) => l.id === x.id).length === 1);
|
|
1540
|
+
if (slots.length > MAX_SLOTS || !unique || !recoveryOk || !perSlot) fail("bad_locks");
|
|
1541
|
+
for (const x of slots) checkLabel(x.label);
|
|
1542
|
+
for (const l of pw) {
|
|
1543
|
+
const ok32 = (n) => Number.isInteger(n) && n >= 0 && n <= 0xffffffff;
|
|
1544
|
+
if (l.kdf.alg !== "argon2id" || ![l.kdf.m, l.kdf.t, l.kdf.p].every(ok32)) fail("kdf_params");
|
|
1545
|
+
checkKdf(l.kdf);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
/**
|
|
1550
|
+
* Verify a vault against a log state ([VLT-3]), without decrypting it. Returns `{ rev, gen, slots, locks, hints }`.
|
|
1551
|
+
*/
|
|
1552
|
+
async function verifyVault(vault, state) {
|
|
1553
|
+
const p = parseVault(vault);
|
|
1554
|
+
if (p.map.account !== state.account) fail("account_mismatch");
|
|
1555
|
+
if (state.retired) fail("account_retired");
|
|
1556
|
+
if (!state.instance) fail("no_instance");
|
|
1557
|
+
if (p.map.instance !== state.instance.hash) fail("wrong_instance");
|
|
1558
|
+
checkLockSet(p);
|
|
1559
|
+
await ed25519Verify(s.parseKey(state.recovery, "ed25519"), grantMessage(p.grant, state.account, p.map.instance), p.grantSig);
|
|
1560
|
+
await ed25519Verify(s.parseKey(state.instance.account_key, "ed25519"), vaultMessage(p.map), p.sig);
|
|
1561
|
+
return info(p);
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
function info(p) {
|
|
1565
|
+
const hints = [];
|
|
1566
|
+
for (const x of p.slots) {
|
|
1567
|
+
const l = p.list.find((y) => y.kind === "password" && y.id === x.id);
|
|
1568
|
+
if (l) hints.push({ id: x.id, label: x.label, kdf: l.value.kdf });
|
|
1569
|
+
}
|
|
1570
|
+
return { rev: p.rev, gen: p.gen, slots: p.slots, locks: p.list, hints };
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
/** Classify a replacement of `old` by `new` ([VLT-4], [HST-12]). → stale_rev, stale_locks */
|
|
1574
|
+
async function vaultChange(oldVault, newVault) {
|
|
1575
|
+
const o = s.object(oldVault);
|
|
1576
|
+
const n = s.object(newVault);
|
|
1577
|
+
if (s.int(n, "rev") <= s.int(o, "rev")) fail("stale_rev");
|
|
1578
|
+
const ol = s.member(o, "locks");
|
|
1579
|
+
const nl = s.member(n, "locks");
|
|
1580
|
+
const og = s.int(s.member(ol, "grant"), "gen");
|
|
1581
|
+
const ng = s.int(s.member(nl, "grant"), "gen");
|
|
1582
|
+
if (ng < og) fail("stale_locks");
|
|
1583
|
+
if (ng > og) return { change: "grant" };
|
|
1584
|
+
const same = (a, b) => {
|
|
1585
|
+
try {
|
|
1586
|
+
return bytesEqual(canonical(a), canonical(b));
|
|
1587
|
+
} catch {
|
|
1588
|
+
return false;
|
|
1589
|
+
}
|
|
1590
|
+
};
|
|
1591
|
+
if (!same(ol.grant, nl.grant)) fail("stale_locks");
|
|
1592
|
+
const olist = s.array(ol, "list");
|
|
1593
|
+
const nlist = s.array(nl, "list");
|
|
1594
|
+
if (olist.length !== nlist.length) fail("stale_locks");
|
|
1595
|
+
const changed = [];
|
|
1596
|
+
for (let i = 0; i < olist.length; i++) if (!same(olist[i], nlist[i])) changed.push(i);
|
|
1597
|
+
if (changed.length === 0) return { change: "data" };
|
|
1598
|
+
if (changed.length > 1) fail("stale_locks");
|
|
1599
|
+
const a = s.object(olist[changed[0]]);
|
|
1600
|
+
const b = s.object(nlist[changed[0]]);
|
|
1601
|
+
if (a.kind !== "password" || b.kind !== "password" || a.id !== b.id) fail("stale_locks");
|
|
1602
|
+
return { change: "password", slot: s.string(b, "id"), new: await sha256(canonical(b)) };
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
return { VAULT_LIMIT, CONTENTS_LIMIT, DATA_LIMIT, MAX_SLOTS, MAX_REV, KDF_DEFAULT, KDF_MIN, checkKdf, checkLabel, grantMessage, vaultMessage, contentsAad, verifyVault, vaultChange };
|
|
1606
|
+
})();
|
|
1607
|
+
__modules["profile.js"] = (() => {
|
|
1608
|
+
// Arbitrary data: the public profile and data keys (SPEC §12).
|
|
1609
|
+
|
|
1610
|
+
const { fail } = __modules["errors.js"];
|
|
1611
|
+
const { base64urlEncode } = __modules["encoding.js"];
|
|
1612
|
+
const { ed25519Sign, ed25519Verify, frameObject } = __modules["crypto.js"];
|
|
1613
|
+
const { canonical, validate, without } = __modules["json.js"];
|
|
1614
|
+
const s = __modules["keys.js"];
|
|
1615
|
+
const { label } = __modules["labels.js"];
|
|
1616
|
+
|
|
1617
|
+
const PROFILE_LIMIT = 16 * 1024;
|
|
1618
|
+
const MAX_REV = 2 ** 32;
|
|
1619
|
+
|
|
1620
|
+
/** [DAT-4] → bad_data_key */
|
|
1621
|
+
function checkDataKey(key) {
|
|
1622
|
+
if (typeof key !== "string" || key.length < 1 || key.length > 64 || !/^[a-z0-9._-]+$/.test(key)) fail("bad_data_key");
|
|
1623
|
+
let rest;
|
|
1624
|
+
if (key.startsWith("user.")) rest = key.slice(5);
|
|
1625
|
+
else if (key.startsWith("app.")) {
|
|
1626
|
+
const r = key.slice(4);
|
|
1627
|
+
const dot = r.indexOf(".");
|
|
1628
|
+
if (dot < 0) fail("bad_data_key");
|
|
1629
|
+
checkAppName(r.slice(0, dot));
|
|
1630
|
+
rest = r.slice(dot + 1);
|
|
1631
|
+
} else fail("bad_data_key");
|
|
1632
|
+
if (rest.length === 0) fail("bad_data_key");
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
/** An application namespace name ([DAT-4]). → bad_data_key */
|
|
1636
|
+
function checkAppName(name) {
|
|
1637
|
+
if (!/^[a-z0-9][a-z0-9-]{0,31}$/.test(name)) fail("bad_data_key");
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
/** Every key of `data` is valid and its canonical form is at most `limit` bytes. → bad_data_key, too_large */
|
|
1641
|
+
function checkData(data, limit) {
|
|
1642
|
+
for (const k of Object.keys(data)) checkDataKey(k);
|
|
1643
|
+
if (canonical(data).length > limit) fail("too_large");
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
/** Set `key` on behalf of application `app` ([DAT-7]). Returns a new object. → bad_data_key, bad_json */
|
|
1647
|
+
function setData(data, app, key, value) {
|
|
1648
|
+
checkDataKey(key);
|
|
1649
|
+
checkAppName(app);
|
|
1650
|
+
if (!key.startsWith("user.") && !key.startsWith(`app.${app}.`)) fail("bad_data_key");
|
|
1651
|
+
validate(value);
|
|
1652
|
+
return { ...data, [key]: value };
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
/** Remove `key` on behalf of application `app` ([DAT-7]). Returns a new object. → bad_data_key */
|
|
1656
|
+
function removeData(data, app, key) {
|
|
1657
|
+
checkDataKey(key);
|
|
1658
|
+
if (!key.startsWith("user.") && !key.startsWith(`app.${app}.`)) fail("bad_data_key");
|
|
1659
|
+
const d = { ...data };
|
|
1660
|
+
delete d[key];
|
|
1661
|
+
return d;
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
/** The message the account key signs on a profile ([DAT-2]). */
|
|
1665
|
+
function publicMessage(profile) {
|
|
1666
|
+
return frameObject(label("public"), without(profile, "sig"));
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
/** Sign a public profile with the account key. → bad_data_key, too_large */
|
|
1670
|
+
async function signPublic(account, instance, rev, data, accountSeed) {
|
|
1671
|
+
checkData(data, PROFILE_LIMIT);
|
|
1672
|
+
const p = { v: 1, account, instance, rev, data };
|
|
1673
|
+
p.sig = base64urlEncode(await ed25519Sign(accountSeed, publicMessage(p)));
|
|
1674
|
+
if (canonical(p).length > PROFILE_LIMIT) fail("too_large");
|
|
1675
|
+
return p;
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
/** Verify a public profile ([DAT-2]). Returns `{ rev, data }`. */
|
|
1679
|
+
async function verifyPublic(profile, state) {
|
|
1680
|
+
if (canonical(profile).length > PROFILE_LIMIT) fail("too_large");
|
|
1681
|
+
const m = s.object(profile);
|
|
1682
|
+
s.version(m);
|
|
1683
|
+
s.members(m, ["v", "account", "instance", "rev", "data", "sig"]);
|
|
1684
|
+
const account = s.account(m, "account");
|
|
1685
|
+
s.bytes(m, "instance", 32);
|
|
1686
|
+
const rev = s.intIn(m, "rev", 1, MAX_REV);
|
|
1687
|
+
const data = s.member(m, "data");
|
|
1688
|
+
const sig = s.sig(m, "sig");
|
|
1689
|
+
if (account !== state.account) fail("account_mismatch");
|
|
1690
|
+
if (state.retired) fail("account_retired");
|
|
1691
|
+
if (!state.instance) fail("no_instance");
|
|
1692
|
+
if (m.instance !== state.instance.hash) fail("wrong_instance");
|
|
1693
|
+
for (const k of Object.keys(data)) checkDataKey(k);
|
|
1694
|
+
await ed25519Verify(s.parseKey(state.instance.account_key, "ed25519"), publicMessage(m), sig);
|
|
1695
|
+
return { rev, data };
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
return { PROFILE_LIMIT, checkDataKey, checkAppName, checkData, setData, removeData, publicMessage, signPublic, verifyPublic };
|
|
1699
|
+
})();
|
|
1700
|
+
__modules["messages.js"] = (() => {
|
|
1701
|
+
// Challenges and the signed messages of vault fetch, login and password change (SPEC §13).
|
|
1702
|
+
|
|
1703
|
+
const { fail } = __modules["errors.js"];
|
|
1704
|
+
const { base64urlEncode } = __modules["encoding.js"];
|
|
1705
|
+
const { ed25519Sign, ed25519Verify, frameObject } = __modules["crypto.js"];
|
|
1706
|
+
const s = __modules["keys.js"];
|
|
1707
|
+
const { label } = __modules["labels.js"];
|
|
1708
|
+
const { checkUrl } = __modules["log.js"];
|
|
1709
|
+
|
|
1710
|
+
const CHALLENGE_LIFETIME = 60;
|
|
1711
|
+
|
|
1712
|
+
/** Parse a challenge ([CHL-1]). → bad_field, bad_key */
|
|
1713
|
+
function parseChallenge(v) {
|
|
1714
|
+
const m = s.object(v);
|
|
1715
|
+
s.members(m, ["nonce", "host", "realm", "version", "expires"]);
|
|
1716
|
+
s.bytes(m, "nonce", 32);
|
|
1717
|
+
s.key(m, "host", "ed25519");
|
|
1718
|
+
s.string(m, "realm");
|
|
1719
|
+
s.versionRef(s.get(m, "version"));
|
|
1720
|
+
s.int(m, "expires");
|
|
1721
|
+
return m;
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
/** The login message ([LGN-1] step 5). `host` is a key string, `nonce` b64u. */
|
|
1725
|
+
function loginMessage(account, host, lock, nonce, realm, url, version) {
|
|
1726
|
+
return frameObject(label("login"), { account, host, lock, nonce, realm, url, version: { seq: version.seq, hash: version.hash } });
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
/** The vault fetch message ([FET-1]). */
|
|
1730
|
+
function fetchMessage(account, host, lock, nonce, url) {
|
|
1731
|
+
return frameObject(label("fetch"), { account, host, lock, nonce, url });
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
/** The password change proof message ([CHG-1] step 2). `newLockHash` is b64u. */
|
|
1735
|
+
function changeMessage(account, host, lock, newLockHash, nonce, url) {
|
|
1736
|
+
return frameObject(label("change"), { account, host, lock, new: newLockHash, nonce, url });
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
/**
|
|
1740
|
+
* Sign a login for `challenge` with the account key and the lock's key ([LGN-1] steps 4, 5). → wrong_host, stale_version
|
|
1741
|
+
* @returns {Promise<{account, nonce, version, url, lock, sig, lock_sig}>}
|
|
1742
|
+
*/
|
|
1743
|
+
async function signLogin(state, challenge, url, lock, accountSeed, lockSeed) {
|
|
1744
|
+
const ch = parseChallenge(challenge);
|
|
1745
|
+
if (!state.instance) fail("no_instance");
|
|
1746
|
+
if (ch.host !== state.instance.home.key || ch.realm !== state.realm) fail("wrong_host");
|
|
1747
|
+
if (ch.version.seq !== state.version.seq || ch.version.hash !== state.version.hash) fail("stale_version");
|
|
1748
|
+
checkUrl(url);
|
|
1749
|
+
const msg = loginMessage(state.account, ch.host, lock, ch.nonce, state.realm, url, state.version);
|
|
1750
|
+
return {
|
|
1751
|
+
account: state.account,
|
|
1752
|
+
nonce: ch.nonce,
|
|
1753
|
+
version: { seq: state.version.seq, hash: state.version.hash },
|
|
1754
|
+
url,
|
|
1755
|
+
lock,
|
|
1756
|
+
sig: base64urlEncode(await ed25519Sign(accountSeed, msg)),
|
|
1757
|
+
lock_sig: base64urlEncode(await ed25519Sign(lockSeed, msg)),
|
|
1758
|
+
};
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
/** Parse a login request. → bad_field */
|
|
1762
|
+
function parseLoginRequest(v) {
|
|
1763
|
+
const m = s.object(v);
|
|
1764
|
+
s.members(m, ["account", "nonce", "version", "url", "lock", "sig", "lock_sig"]);
|
|
1765
|
+
checkUrl(s.string(m, "url"));
|
|
1766
|
+
const lock = s.string(m, "lock");
|
|
1767
|
+
if (lock.length < 1 || lock.length > 16) fail("bad_field");
|
|
1768
|
+
s.account(m, "account");
|
|
1769
|
+
s.bytes(m, "nonce", 32);
|
|
1770
|
+
s.versionRef(s.get(m, "version"));
|
|
1771
|
+
s.sig(m, "sig");
|
|
1772
|
+
s.sig(m, "lock_sig");
|
|
1773
|
+
return m;
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
/** Verify both signatures of a login request ([LGN-1] step 6). Keys are key strings. → bad_signature */
|
|
1777
|
+
async function verifyLogin(request, realm, homeKey, accountKey, lockKey) {
|
|
1778
|
+
const r = parseLoginRequest(request);
|
|
1779
|
+
const msg = loginMessage(r.account, homeKey, r.lock, r.nonce, realm, r.url, r.version);
|
|
1780
|
+
await ed25519Verify(s.parseKey(accountKey, "ed25519"), msg, s.sig(r, "sig"));
|
|
1781
|
+
await ed25519Verify(s.parseKey(lockKey, "ed25519"), msg, s.sig(r, "lock_sig"));
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
return { CHALLENGE_LIFETIME, parseChallenge, loginMessage, fetchMessage, changeMessage, signLogin, parseLoginRequest, verifyLogin };
|
|
1785
|
+
})();
|
|
1786
|
+
__modules["proof.js"] = (() => {
|
|
1787
|
+
// Proofs of identity (SPEC §14).
|
|
1788
|
+
|
|
1789
|
+
const { fail } = __modules["errors.js"];
|
|
1790
|
+
const { base64urlEncode } = __modules["encoding.js"];
|
|
1791
|
+
const { ed25519Public, ed25519Sign, ed25519Verify, frameObject, sha256 } = __modules["crypto.js"];
|
|
1792
|
+
const { utf8 } = __modules["encoding.js"];
|
|
1793
|
+
const { validate, without } = __modules["json.js"];
|
|
1794
|
+
const s = __modules["keys.js"];
|
|
1795
|
+
const { formatKey } = __modules["keys.js"];
|
|
1796
|
+
const { label } = __modules["labels.js"];
|
|
1797
|
+
const { checkRecoveryKey, verifyLog } = __modules["log.js"];
|
|
1798
|
+
|
|
1799
|
+
/** `SHA-256(frame("rootcard/statement/v1", { statement }))` ([PRF-5]). → bad_json */
|
|
1800
|
+
async function statementChallenge(statement) {
|
|
1801
|
+
validate(statement);
|
|
1802
|
+
return sha256(frameObject(label("statement"), { statement }));
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
function checkRecipient(r) {
|
|
1806
|
+
const n = utf8(r).length;
|
|
1807
|
+
if (n < 1 || n > 256) fail("bad_field");
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
/** Make a proof ([PRF-1], [PRF-2]). `by` is "account" or "recovery"; `challenge` is 32 bytes. */
|
|
1811
|
+
async function prove(state, by, seed, challenge, recipient) {
|
|
1812
|
+
checkRecipient(recipient);
|
|
1813
|
+
const pub = formatKey("ed25519", await ed25519Public(seed));
|
|
1814
|
+
const p = { v: 1, by, account: state.account, challenge: base64urlEncode(challenge), recipient };
|
|
1815
|
+
if (by === "account") {
|
|
1816
|
+
if (!state.instance) fail("no_instance");
|
|
1817
|
+
if (state.instance.account_key !== pub) fail("wrong_account_key");
|
|
1818
|
+
p.instance = state.instance.hash;
|
|
1819
|
+
} else {
|
|
1820
|
+
await checkRecoveryKey(state, seed);
|
|
1821
|
+
}
|
|
1822
|
+
p.sig = base64urlEncode(await ed25519Sign(seed, frameObject(label("proof"), p)));
|
|
1823
|
+
return p;
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
/** Verify a proof against the newest log the verifier knows ([PRF-4]). Returns the log state. */
|
|
1827
|
+
async function verifyProof(proof, log, challenge, recipient, options) {
|
|
1828
|
+
const m = s.object(proof);
|
|
1829
|
+
s.version(m);
|
|
1830
|
+
const by = s.string(m, "by");
|
|
1831
|
+
if (by !== "account" && by !== "recovery") fail("bad_field");
|
|
1832
|
+
if (by === "account") s.members(m, ["v", "by", "account", "instance", "challenge", "recipient", "sig"]);
|
|
1833
|
+
else s.members(m, ["v", "by", "account", "challenge", "recipient", "sig"]);
|
|
1834
|
+
const account = s.account(m, "account");
|
|
1835
|
+
if (by === "account") s.bytes(m, "instance", 32);
|
|
1836
|
+
s.bytes(m, "challenge", 32);
|
|
1837
|
+
checkRecipient(s.string(m, "recipient"));
|
|
1838
|
+
const sig = s.sig(m, "sig");
|
|
1839
|
+
const state = await verifyLog(log, options);
|
|
1840
|
+
if (account !== state.account) fail("account_mismatch");
|
|
1841
|
+
if (state.retired) fail("account_retired");
|
|
1842
|
+
if (m.challenge !== base64urlEncode(challenge)) fail("wrong_challenge");
|
|
1843
|
+
if (m.recipient !== recipient) fail("wrong_recipient");
|
|
1844
|
+
let key;
|
|
1845
|
+
if (by === "account") {
|
|
1846
|
+
if (!state.instance) fail("no_instance");
|
|
1847
|
+
if (m.instance !== state.instance.hash) fail("stale_version");
|
|
1848
|
+
key = state.instance.account_key;
|
|
1849
|
+
} else key = state.recovery;
|
|
1850
|
+
await ed25519Verify(s.parseKey(key, "ed25519"), frameObject(label("proof"), without(m, "sig")), sig);
|
|
1851
|
+
return state;
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
return { statementChallenge, prove, verifyProof };
|
|
1855
|
+
})();
|
|
1856
|
+
__modules["file.js"] = (() => {
|
|
1857
|
+
// The account file and the recovery card (SPEC §15).
|
|
1858
|
+
|
|
1859
|
+
const { fail } = __modules["errors.js"];
|
|
1860
|
+
const { canonical } = __modules["json.js"];
|
|
1861
|
+
const s = __modules["keys.js"];
|
|
1862
|
+
const { verifyLog } = __modules["log.js"];
|
|
1863
|
+
const { verifyPublic } = __modules["profile.js"];
|
|
1864
|
+
const { verifyVault } = __modules["vaultcheck.js"];
|
|
1865
|
+
const { accountId, inception, tag } = __modules["id.js"];
|
|
1866
|
+
const { deriveRecoveryKeys, formatRecoveryKey, parseRecoveryKey, recoverySignPublic } = __modules["recovery.js"];
|
|
1867
|
+
const { formatKey } = __modules["keys.js"];
|
|
1868
|
+
|
|
1869
|
+
const FILE_LIMIT = 5 * 1024 * 1024;
|
|
1870
|
+
const HOST_DATA_LIMIT = 16 * 1024;
|
|
1871
|
+
|
|
1872
|
+
/** Build an account file ([FIL-2]). */
|
|
1873
|
+
function exportAccount(state, log, publicProfile, vault, hostData) {
|
|
1874
|
+
const f = {
|
|
1875
|
+
format: "rootcard-account",
|
|
1876
|
+
v: 1,
|
|
1877
|
+
realm: state.realm,
|
|
1878
|
+
account: state.account,
|
|
1879
|
+
handle: state.handle,
|
|
1880
|
+
version: { seq: state.version.seq, hash: state.version.hash },
|
|
1881
|
+
log: [...log],
|
|
1882
|
+
};
|
|
1883
|
+
if (publicProfile !== undefined && publicProfile !== null) f.public = publicProfile;
|
|
1884
|
+
if (vault !== undefined && vault !== null) f.vault = vault;
|
|
1885
|
+
if (hostData !== undefined && hostData !== null) f.host_data = hostData;
|
|
1886
|
+
return f;
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
/** The suggested file name ([FIL-1]). */
|
|
1890
|
+
function accountFileName(state) {
|
|
1891
|
+
return `${state.username}-${tag(state.account)}.rootcard.json`;
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
/** Import an account file ([FIL-3]). Returns `{ state, log, public?, vault?, hostData? }`. */
|
|
1895
|
+
async function importAccount(file, options) {
|
|
1896
|
+
if (canonical(file).length > FILE_LIMIT) fail("too_large");
|
|
1897
|
+
const m = s.object(file);
|
|
1898
|
+
if (m.format !== "rootcard-account") fail("bad_field");
|
|
1899
|
+
s.version(m);
|
|
1900
|
+
s.members(m, ["format", "v", "realm", "account", "handle", "version", "log"], ["public", "vault", "host_data"]);
|
|
1901
|
+
const realm = s.string(m, "realm");
|
|
1902
|
+
const account = s.account(m, "account");
|
|
1903
|
+
const handle = s.string(m, "handle");
|
|
1904
|
+
const version = s.versionRef(s.get(m, "version"));
|
|
1905
|
+
const log = s.array(m, "log");
|
|
1906
|
+
let hostData;
|
|
1907
|
+
if (Object.hasOwn(m, "host_data")) {
|
|
1908
|
+
hostData = s.object(m.host_data);
|
|
1909
|
+
if (canonical(hostData).length > HOST_DATA_LIMIT) fail("too_large");
|
|
1910
|
+
}
|
|
1911
|
+
const state = await verifyLog(log, options);
|
|
1912
|
+
if (state.account !== account || state.realm !== realm || state.handle !== handle ||
|
|
1913
|
+
state.version.seq !== version.seq || state.version.hash !== version.hash) {
|
|
1914
|
+
fail("account_mismatch");
|
|
1915
|
+
}
|
|
1916
|
+
const out = { state, log };
|
|
1917
|
+
if (Object.hasOwn(m, "public")) out.public = { value: m.public, info: await verifyPublic(m.public, state) };
|
|
1918
|
+
if (Object.hasOwn(m, "vault")) out.vault = { value: m.vault, info: await verifyVault(m.vault, state) };
|
|
1919
|
+
if (hostData !== undefined) out.hostData = hostData;
|
|
1920
|
+
return out;
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
/** Build a recovery card ([CRD-1], [CRD-2]). */
|
|
1924
|
+
async function recoveryCard(realm, username, account, key, log) {
|
|
1925
|
+
const c = { format: "rootcard-card", v: 1, realm, username, account, key: await formatRecoveryKey(key) };
|
|
1926
|
+
if (log) c.log = [...log];
|
|
1927
|
+
return c;
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
/** Read a recovery card ([CRD-3]). Returns `{ realm, username, account, key, keys, log, state }`. */
|
|
1931
|
+
async function readCard(card) {
|
|
1932
|
+
const m = s.object(card);
|
|
1933
|
+
if (m.format !== "rootcard-card") fail("bad_field");
|
|
1934
|
+
s.version(m);
|
|
1935
|
+
s.members(m, ["format", "v", "realm", "username", "account", "key"], ["log"]);
|
|
1936
|
+
const realm = s.string(m, "realm");
|
|
1937
|
+
const username = s.string(m, "username");
|
|
1938
|
+
const account = s.account(m, "account");
|
|
1939
|
+
const keyText = s.string(m, "key");
|
|
1940
|
+
const logValue = Object.hasOwn(m, "log") ? s.array(m, "log") : undefined;
|
|
1941
|
+
const key = await parseRecoveryKey(keyText);
|
|
1942
|
+
const keys = await deriveRecoveryKeys(key, realm, username);
|
|
1943
|
+
const options = { realms: [realm] };
|
|
1944
|
+
let log;
|
|
1945
|
+
let state;
|
|
1946
|
+
if (logValue === undefined) {
|
|
1947
|
+
const inc = await inception(keys, realm, username);
|
|
1948
|
+
if ((await accountId(inc)) !== account) fail("account_mismatch");
|
|
1949
|
+
log = [inc];
|
|
1950
|
+
state = await verifyLog(log, options);
|
|
1951
|
+
} else {
|
|
1952
|
+
log = logValue;
|
|
1953
|
+
state = await verifyLog(log, options);
|
|
1954
|
+
if (state.account !== account || state.realm !== realm || state.username !== username) fail("account_mismatch");
|
|
1955
|
+
if (formatKey("ed25519", await recoverySignPublic(keys)) !== state.recovery) fail("wrong_recovery_key");
|
|
1956
|
+
}
|
|
1957
|
+
return { realm, username, account, key, keys, log, state };
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
/** The printable text form of a recovery card ([CRD-4]). */
|
|
1961
|
+
async function cardText(card) {
|
|
1962
|
+
const c = await readCard(card);
|
|
1963
|
+
const groups = (str) => str.match(/.{1,4}/g).join(" ");
|
|
1964
|
+
const key = (await formatRecoveryKey(c.key)).slice(4).replace(/-/g, " ");
|
|
1965
|
+
return (
|
|
1966
|
+
"rootcard recovery card\n\n" +
|
|
1967
|
+
`Handle: ${c.state.handle}\n` +
|
|
1968
|
+
`Realm: ${c.realm}\n` +
|
|
1969
|
+
`Account id: ${groups(c.account)}\n\n` +
|
|
1970
|
+
`Recovery key:\n RCR ${key}\n\n` +
|
|
1971
|
+
"Anyone who has this card controls the identity. Keep it offline and private.\n" +
|
|
1972
|
+
"Only enter the recovery key in the rootcard key tool.\n"
|
|
1973
|
+
);
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
return { FILE_LIMIT, HOST_DATA_LIMIT, exportAccount, accountFileName, importAccount, recoveryCard, readCard, cardText };
|
|
1977
|
+
})();
|
|
1978
|
+
__modules["blake2b.js"] = (() => {
|
|
1979
|
+
// BLAKE2b (RFC 7693), needed only inside Argon2id ([CRY-1]): WebCrypto has neither. 64-bit words are pairs of 32-bit
|
|
1980
|
+
// integers (low, high) in a Uint32Array.
|
|
1981
|
+
|
|
1982
|
+
const IV = new Uint32Array([
|
|
1983
|
+
0xf3bcc908, 0x6a09e667, 0x84caa73b, 0xbb67ae85, 0xfe94f82b, 0x3c6ef372, 0x5f1d36f1, 0xa54ff53a, 0xade682d1,
|
|
1984
|
+
0x510e527f, 0x2b3e6c1f, 0x9b05688c, 0xfb41bd6b, 0x1f83d9ab, 0x137e2179, 0x5be0cd19,
|
|
1985
|
+
]);
|
|
1986
|
+
|
|
1987
|
+
const SIGMA = [
|
|
1988
|
+
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
|
1989
|
+
[14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3],
|
|
1990
|
+
[11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4],
|
|
1991
|
+
[7, 9, 3, 1, 13, 12, 11, 14, 2, 6, 5, 10, 4, 0, 15, 8],
|
|
1992
|
+
[9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13],
|
|
1993
|
+
[2, 12, 6, 10, 0, 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9],
|
|
1994
|
+
[12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11],
|
|
1995
|
+
[13, 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10],
|
|
1996
|
+
[6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, 10, 5],
|
|
1997
|
+
[10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13, 0],
|
|
1998
|
+
].map((r) => r.map((x) => x * 2));
|
|
1999
|
+
|
|
2000
|
+
const v = new Uint32Array(32);
|
|
2001
|
+
const m = new Uint32Array(32);
|
|
2002
|
+
|
|
2003
|
+
function add64(a, b) {
|
|
2004
|
+
const lo = v[a] + v[b];
|
|
2005
|
+
let hi = v[a + 1] + v[b + 1];
|
|
2006
|
+
if (lo >= 0x100000000) hi++;
|
|
2007
|
+
v[a] = lo;
|
|
2008
|
+
v[a + 1] = hi;
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
function add64m(a, i) {
|
|
2012
|
+
const lo = v[a] + m[i];
|
|
2013
|
+
let hi = v[a + 1] + m[i + 1];
|
|
2014
|
+
if (lo >= 0x100000000) hi++;
|
|
2015
|
+
v[a] = lo;
|
|
2016
|
+
v[a + 1] = hi;
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
function g(a, b, c, d, x, y) {
|
|
2020
|
+
add64(a, b);
|
|
2021
|
+
add64m(a, x);
|
|
2022
|
+
let xl = v[d] ^ v[a];
|
|
2023
|
+
let xh = v[d + 1] ^ v[a + 1];
|
|
2024
|
+
v[d] = xh;
|
|
2025
|
+
v[d + 1] = xl;
|
|
2026
|
+
add64(c, d);
|
|
2027
|
+
xl = v[b] ^ v[c];
|
|
2028
|
+
xh = v[b + 1] ^ v[c + 1];
|
|
2029
|
+
v[b] = (xl >>> 24) ^ (xh << 8);
|
|
2030
|
+
v[b + 1] = (xh >>> 24) ^ (xl << 8);
|
|
2031
|
+
add64(a, b);
|
|
2032
|
+
add64m(a, y);
|
|
2033
|
+
xl = v[d] ^ v[a];
|
|
2034
|
+
xh = v[d + 1] ^ v[a + 1];
|
|
2035
|
+
v[d] = (xl >>> 16) ^ (xh << 16);
|
|
2036
|
+
v[d + 1] = (xh >>> 16) ^ (xl << 16);
|
|
2037
|
+
add64(c, d);
|
|
2038
|
+
xl = v[b] ^ v[c];
|
|
2039
|
+
xh = v[b + 1] ^ v[c + 1];
|
|
2040
|
+
v[b] = (xh >>> 31) ^ (xl << 1);
|
|
2041
|
+
v[b + 1] = (xl >>> 31) ^ (xh << 1);
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
function compress(h, block, t, last) {
|
|
2045
|
+
for (let i = 0; i < 16; i++) {
|
|
2046
|
+
v[i] = h[i];
|
|
2047
|
+
v[i + 16] = IV[i];
|
|
2048
|
+
}
|
|
2049
|
+
v[24] ^= t;
|
|
2050
|
+
v[25] ^= t / 0x100000000;
|
|
2051
|
+
if (last) {
|
|
2052
|
+
v[28] = ~v[28];
|
|
2053
|
+
v[29] = ~v[29];
|
|
2054
|
+
}
|
|
2055
|
+
for (let i = 0; i < 32; i++) {
|
|
2056
|
+
m[i] = block[4 * i] | (block[4 * i + 1] << 8) | (block[4 * i + 2] << 16) | (block[4 * i + 3] << 24);
|
|
2057
|
+
}
|
|
2058
|
+
for (let r = 0; r < 12; r++) {
|
|
2059
|
+
const s = SIGMA[r % 10];
|
|
2060
|
+
g(0, 8, 16, 24, s[0], s[1]);
|
|
2061
|
+
g(2, 10, 18, 26, s[2], s[3]);
|
|
2062
|
+
g(4, 12, 20, 28, s[4], s[5]);
|
|
2063
|
+
g(6, 14, 22, 30, s[6], s[7]);
|
|
2064
|
+
g(0, 10, 20, 30, s[8], s[9]);
|
|
2065
|
+
g(2, 12, 22, 24, s[10], s[11]);
|
|
2066
|
+
g(4, 14, 16, 26, s[12], s[13]);
|
|
2067
|
+
g(6, 8, 18, 28, s[14], s[15]);
|
|
2068
|
+
}
|
|
2069
|
+
for (let i = 0; i < 16; i++) h[i] ^= v[i] ^ v[i + 16];
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
/** BLAKE2b without a key, with an output of `outlen` bytes (1 to 64), over the concatenation of `parts`. */
|
|
2073
|
+
function blake2b(outlen, ...parts) {
|
|
2074
|
+
const h = new Uint32Array(16);
|
|
2075
|
+
h.set(IV);
|
|
2076
|
+
h[0] ^= 0x01010000 ^ outlen;
|
|
2077
|
+
const block = new Uint8Array(128);
|
|
2078
|
+
let fill = 0;
|
|
2079
|
+
let t = 0;
|
|
2080
|
+
for (const p of parts) {
|
|
2081
|
+
for (let i = 0; i < p.length; i++) {
|
|
2082
|
+
if (fill === 128) {
|
|
2083
|
+
t += 128;
|
|
2084
|
+
compress(h, block, t, false);
|
|
2085
|
+
fill = 0;
|
|
2086
|
+
}
|
|
2087
|
+
block[fill++] = p[i];
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
t += fill;
|
|
2091
|
+
block.fill(0, fill);
|
|
2092
|
+
compress(h, block, t, true);
|
|
2093
|
+
const out = new Uint8Array(outlen);
|
|
2094
|
+
for (let i = 0; i < outlen; i++) out[i] = h[i >> 2] >>> (8 * (i & 3));
|
|
2095
|
+
block.fill(0);
|
|
2096
|
+
h.fill(0);
|
|
2097
|
+
return out;
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
return { blake2b };
|
|
2101
|
+
})();
|
|
2102
|
+
__modules["argon2.js"] = (() => {
|
|
2103
|
+
// Argon2id, version 0x13 (RFC 9106), the one primitive this package implements itself ([CRY-1]). It supports the
|
|
2104
|
+
// general parameters of the RFC (lanes, secret, associated data) so that it can be checked against the RFC vectors;
|
|
2105
|
+
// rootcard uses p = 1, no secret and no associated data ([CRY-6]).
|
|
2106
|
+
|
|
2107
|
+
const { blake2b } = __modules["blake2b.js"];
|
|
2108
|
+
|
|
2109
|
+
function le32(n) {
|
|
2110
|
+
return new Uint8Array([n & 255, (n >>> 8) & 255, (n >>> 16) & 255, (n >>> 24) & 255]);
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
/** H' (RFC 9106 §3.3): variable-length hash of `parts` to `T` bytes. */
|
|
2114
|
+
function hPrime(T, ...parts) {
|
|
2115
|
+
if (T <= 64) return blake2b(T, le32(T), ...parts);
|
|
2116
|
+
const out = new Uint8Array(T);
|
|
2117
|
+
const r = Math.ceil(T / 32) - 2;
|
|
2118
|
+
let v = blake2b(64, le32(T), ...parts);
|
|
2119
|
+
out.set(v.subarray(0, 32), 0);
|
|
2120
|
+
for (let i = 1; i < r; i++) {
|
|
2121
|
+
v = blake2b(64, v);
|
|
2122
|
+
out.set(v.subarray(0, 32), i * 32);
|
|
2123
|
+
}
|
|
2124
|
+
out.set(blake2b(T - 32 * r, v), r * 32);
|
|
2125
|
+
return out;
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
// Scratch space for the compression function: R (256 words) and Q (256 words).
|
|
2129
|
+
const R = new Uint32Array(256);
|
|
2130
|
+
const Q = new Uint32Array(256);
|
|
2131
|
+
|
|
2132
|
+
// a = a + b + 2 * lo(a) * lo(b) on 64-bit words at word offsets a and b of Q (in u32 units: 2a, 2a+1).
|
|
2133
|
+
function fBlaMka(a, b) {
|
|
2134
|
+
const al = Q[a];
|
|
2135
|
+
const bl = Q[b];
|
|
2136
|
+
// 64-bit product of the two low halves.
|
|
2137
|
+
const a1 = al >>> 16;
|
|
2138
|
+
const a0 = al & 0xffff;
|
|
2139
|
+
const b1 = bl >>> 16;
|
|
2140
|
+
const b0 = bl & 0xffff;
|
|
2141
|
+
const p00 = a0 * b0;
|
|
2142
|
+
const mid = a1 * b0 + a0 * b1 + (p00 >>> 16);
|
|
2143
|
+
let ph = a1 * b1 + Math.floor(mid / 65536);
|
|
2144
|
+
let pl = (Math.imul(al, bl) >>> 0);
|
|
2145
|
+
// times two
|
|
2146
|
+
ph = ((ph << 1) | (pl >>> 31)) >>> 0;
|
|
2147
|
+
pl = (pl << 1) >>> 0;
|
|
2148
|
+
// a + b
|
|
2149
|
+
let lo = Q[a] + Q[b];
|
|
2150
|
+
let hi = Q[a + 1] + Q[b + 1] + (lo >= 0x100000000 ? 1 : 0);
|
|
2151
|
+
lo >>>= 0;
|
|
2152
|
+
// + product
|
|
2153
|
+
const lo2 = lo + pl;
|
|
2154
|
+
hi = hi + ph + (lo2 >= 0x100000000 ? 1 : 0);
|
|
2155
|
+
Q[a] = lo2;
|
|
2156
|
+
Q[a + 1] = hi;
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
function rotr(d, n) {
|
|
2160
|
+
const l = Q[d];
|
|
2161
|
+
const h = Q[d + 1];
|
|
2162
|
+
if (n === 32) {
|
|
2163
|
+
Q[d] = h;
|
|
2164
|
+
Q[d + 1] = l;
|
|
2165
|
+
} else if (n < 32) {
|
|
2166
|
+
Q[d] = (l >>> n) | (h << (32 - n));
|
|
2167
|
+
Q[d + 1] = (h >>> n) | (l << (32 - n));
|
|
2168
|
+
} else {
|
|
2169
|
+
const k = n - 32;
|
|
2170
|
+
Q[d] = (h >>> k) | (l << (32 - k));
|
|
2171
|
+
Q[d + 1] = (l >>> k) | (h << (32 - k));
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
function xor(d, a) {
|
|
2176
|
+
Q[d] ^= Q[a];
|
|
2177
|
+
Q[d + 1] ^= Q[a + 1];
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
// GB on 64-bit words with u32 offsets a, b, c, d into Q.
|
|
2181
|
+
function gb(a, b, c, d) {
|
|
2182
|
+
fBlaMka(a, b);
|
|
2183
|
+
xor(d, a);
|
|
2184
|
+
rotr(d, 32);
|
|
2185
|
+
fBlaMka(c, d);
|
|
2186
|
+
xor(b, c);
|
|
2187
|
+
rotr(b, 24);
|
|
2188
|
+
fBlaMka(a, b);
|
|
2189
|
+
xor(d, a);
|
|
2190
|
+
rotr(d, 16);
|
|
2191
|
+
fBlaMka(c, d);
|
|
2192
|
+
xor(b, c);
|
|
2193
|
+
rotr(b, 63);
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
// P on 16 64-bit words whose u32 offsets are in `w`.
|
|
2197
|
+
function p(w) {
|
|
2198
|
+
gb(w[0], w[4], w[8], w[12]);
|
|
2199
|
+
gb(w[1], w[5], w[9], w[13]);
|
|
2200
|
+
gb(w[2], w[6], w[10], w[14]);
|
|
2201
|
+
gb(w[3], w[7], w[11], w[15]);
|
|
2202
|
+
gb(w[0], w[5], w[10], w[15]);
|
|
2203
|
+
gb(w[1], w[6], w[11], w[12]);
|
|
2204
|
+
gb(w[2], w[7], w[8], w[13]);
|
|
2205
|
+
gb(w[3], w[4], w[9], w[14]);
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
const ROWS = [];
|
|
2209
|
+
const COLS = [];
|
|
2210
|
+
for (let i = 0; i < 8; i++) {
|
|
2211
|
+
ROWS.push(Array.from({ length: 16 }, (_, k) => 2 * (16 * i + k)));
|
|
2212
|
+
COLS.push(Array.from({ length: 16 }, (_, k) => 2 * (2 * i + 16 * (k >> 1) + (k & 1))));
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
/**
|
|
2216
|
+
* G(X, Y) of RFC 9106 §3.5 into `out` (all Uint32Array views of 256 words). With `xorOut`, the result is XORed into
|
|
2217
|
+
* the existing contents of `out` (passes after the first, version 0x13).
|
|
2218
|
+
*/
|
|
2219
|
+
function compress(out, outOff, x, xOff, y, yOff, xorOut) {
|
|
2220
|
+
for (let i = 0; i < 256; i++) R[i] = x[xOff + i] ^ y[yOff + i];
|
|
2221
|
+
Q.set(R);
|
|
2222
|
+
for (let i = 0; i < 8; i++) p(ROWS[i]);
|
|
2223
|
+
for (let i = 0; i < 8; i++) p(COLS[i]);
|
|
2224
|
+
if (xorOut) for (let i = 0; i < 256; i++) out[outOff + i] ^= Q[i] ^ R[i];
|
|
2225
|
+
else for (let i = 0; i < 256; i++) out[outOff + i] = Q[i] ^ R[i];
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
function blockToWords(bytes, mem, off) {
|
|
2229
|
+
for (let i = 0; i < 256; i++) {
|
|
2230
|
+
mem[off + i] = bytes[4 * i] | (bytes[4 * i + 1] << 8) | (bytes[4 * i + 2] << 16) | (bytes[4 * i + 3] << 24);
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
/**
|
|
2235
|
+
* Argon2id (RFC 9106) with a `tagLength`-byte tag. `m` is in KiB.
|
|
2236
|
+
* @param {Uint8Array} password
|
|
2237
|
+
* @param {Uint8Array} salt
|
|
2238
|
+
* @param {{ m: number, t: number, p: number, tagLength?: number, secret?: Uint8Array, ad?: Uint8Array }} params
|
|
2239
|
+
*/
|
|
2240
|
+
function argon2id(password, salt, params) {
|
|
2241
|
+
const { m, t, p: lanes } = params;
|
|
2242
|
+
const T = params.tagLength ?? 32;
|
|
2243
|
+
const K = params.secret ?? new Uint8Array(0);
|
|
2244
|
+
const X = params.ad ?? new Uint8Array(0);
|
|
2245
|
+
if (!(lanes >= 1 && t >= 1 && m >= 8 * lanes && T >= 4)) throw new RangeError("argon2id parameters");
|
|
2246
|
+
const h0 = blake2b(
|
|
2247
|
+
64,
|
|
2248
|
+
le32(lanes), le32(T), le32(m), le32(t), le32(0x13), le32(2),
|
|
2249
|
+
le32(password.length), password, le32(salt.length), salt, le32(K.length), K, le32(X.length), X,
|
|
2250
|
+
);
|
|
2251
|
+
const mPrime = 4 * lanes * Math.floor(m / (4 * lanes));
|
|
2252
|
+
const q = mPrime / lanes;
|
|
2253
|
+
const segment = q / 4;
|
|
2254
|
+
const mem = new Uint32Array(mPrime * 256);
|
|
2255
|
+
const at = (lane, col) => (lane * q + col) * 256;
|
|
2256
|
+
for (let l = 0; l < lanes; l++) {
|
|
2257
|
+
blockToWords(hPrime(1024, h0, le32(0), le32(l)), mem, at(l, 0));
|
|
2258
|
+
blockToWords(hPrime(1024, h0, le32(1), le32(l)), mem, at(l, 1));
|
|
2259
|
+
}
|
|
2260
|
+
const zero = new Uint32Array(256);
|
|
2261
|
+
const input = new Uint32Array(256);
|
|
2262
|
+
const address = new Uint32Array(256);
|
|
2263
|
+
for (let pass = 0; pass < t; pass++) {
|
|
2264
|
+
for (let slice = 0; slice < 4; slice++) {
|
|
2265
|
+
for (let lane = 0; lane < lanes; lane++) {
|
|
2266
|
+
const independent = pass === 0 && slice < 2;
|
|
2267
|
+
if (independent) {
|
|
2268
|
+
input.fill(0);
|
|
2269
|
+
input[0] = pass;
|
|
2270
|
+
input[2] = lane;
|
|
2271
|
+
input[4] = slice;
|
|
2272
|
+
input[6] = mPrime;
|
|
2273
|
+
input[8] = t;
|
|
2274
|
+
input[10] = 2;
|
|
2275
|
+
input[12] = 0; // counter
|
|
2276
|
+
}
|
|
2277
|
+
const nextAddresses = () => {
|
|
2278
|
+
input[12]++;
|
|
2279
|
+
address.fill(0);
|
|
2280
|
+
compress(address, 0, zero, 0, input, 0, false);
|
|
2281
|
+
compress(address, 0, zero, 0, address.slice(), 0, false);
|
|
2282
|
+
};
|
|
2283
|
+
let start = 0;
|
|
2284
|
+
if (pass === 0 && slice === 0) {
|
|
2285
|
+
start = 2;
|
|
2286
|
+
if (independent) nextAddresses();
|
|
2287
|
+
}
|
|
2288
|
+
for (let index = start; index < segment; index++) {
|
|
2289
|
+
const col = slice * segment + index;
|
|
2290
|
+
const prevCol = col === 0 ? q - 1 : col - 1;
|
|
2291
|
+
let j1;
|
|
2292
|
+
let j2;
|
|
2293
|
+
if (independent) {
|
|
2294
|
+
if (index % 128 === 0) nextAddresses();
|
|
2295
|
+
const k = 2 * (index % 128);
|
|
2296
|
+
j1 = address[k];
|
|
2297
|
+
j2 = address[k + 1];
|
|
2298
|
+
} else {
|
|
2299
|
+
const o = at(lane, prevCol);
|
|
2300
|
+
j1 = mem[o];
|
|
2301
|
+
j2 = mem[o + 1];
|
|
2302
|
+
}
|
|
2303
|
+
let refLane = j2 % lanes;
|
|
2304
|
+
if (pass === 0 && slice === 0) refLane = lane;
|
|
2305
|
+
const same = refLane === lane;
|
|
2306
|
+
let area;
|
|
2307
|
+
if (pass === 0) {
|
|
2308
|
+
if (slice === 0) area = index - 1;
|
|
2309
|
+
else if (same) area = slice * segment + index - 1;
|
|
2310
|
+
else area = slice * segment + (index === 0 ? -1 : 0);
|
|
2311
|
+
} else if (same) area = q - segment + index - 1;
|
|
2312
|
+
else area = q - segment + (index === 0 ? -1 : 0);
|
|
2313
|
+
// x = j1^2 >> 32; y = area * x >> 32; z = area - 1 - y
|
|
2314
|
+
const x = Number((BigInt(j1) * BigInt(j1)) >> 32n);
|
|
2315
|
+
const y = Number((BigInt(area) * BigInt(x)) >> 32n);
|
|
2316
|
+
const z = area - 1 - y;
|
|
2317
|
+
const startPos = pass === 0 ? 0 : slice === 3 ? 0 : (slice + 1) * segment;
|
|
2318
|
+
const refCol = (startPos + z) % q;
|
|
2319
|
+
compress(mem, at(lane, col), mem, at(lane, prevCol), mem, at(refLane, refCol), pass > 0);
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
const c = new Uint32Array(256);
|
|
2325
|
+
for (let l = 0; l < lanes; l++) {
|
|
2326
|
+
const o = at(l, q - 1);
|
|
2327
|
+
for (let i = 0; i < 256; i++) c[i] ^= mem[o + i];
|
|
2328
|
+
}
|
|
2329
|
+
const cb = new Uint8Array(1024);
|
|
2330
|
+
for (let i = 0; i < 256; i++) {
|
|
2331
|
+
cb[4 * i] = c[i];
|
|
2332
|
+
cb[4 * i + 1] = c[i] >>> 8;
|
|
2333
|
+
cb[4 * i + 2] = c[i] >>> 16;
|
|
2334
|
+
cb[4 * i + 3] = c[i] >>> 24;
|
|
2335
|
+
}
|
|
2336
|
+
const tag = hPrime(T, cb);
|
|
2337
|
+
mem.fill(0);
|
|
2338
|
+
cb.fill(0);
|
|
2339
|
+
c.fill(0);
|
|
2340
|
+
return tag;
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
return { argon2id };
|
|
2344
|
+
})();
|
|
2345
|
+
__modules["vault.js"] = (() => {
|
|
2346
|
+
// The vault cryptography (SPEC §11): password locks, wrapping, contents, unlocking and changes. This module is separate
|
|
2347
|
+
// from the verification core so that a server bundle never loads it (ARCHITECTURE.md §4).
|
|
2348
|
+
//
|
|
2349
|
+
// Functions that need random bytes take `random(n) → Uint8Array` ([IMP-1]). The order of the draws is part of the
|
|
2350
|
+
// contract and identical to the Rust crate, so both produce the same bytes from the same stream.
|
|
2351
|
+
|
|
2352
|
+
const { fail } = __modules["errors.js"];
|
|
2353
|
+
const { base64urlDecode, base64urlEncode, concat, utf8 } = __modules["encoding.js"];
|
|
2354
|
+
const { aesGcmDecrypt, aesGcmEncrypt, ed25519Public, ed25519Sign, frameObject, hkdf, sha256, x25519, x25519Public } = __modules["crypto.js"];
|
|
2355
|
+
const { argon2id } = __modules["argon2.js"];
|
|
2356
|
+
const { canonical, parseLimited } = __modules["json.js"];
|
|
2357
|
+
const s = __modules["keys.js"];
|
|
2358
|
+
const { formatKey } = __modules["keys.js"];
|
|
2359
|
+
const { label } = __modules["labels.js"];
|
|
2360
|
+
const { checkData } = __modules["profile.js"];
|
|
2361
|
+
const { CONTENTS_LIMIT, DATA_LIMIT, MAX_SLOTS, VAULT_LIMIT, checkKdf, checkLabel, contentsAad, grantMessage, vaultMessage, verifyVault } = __modules["vaultcheck.js"];
|
|
2362
|
+
|
|
2363
|
+
const EMPTY = new Uint8Array(0);
|
|
2364
|
+
|
|
2365
|
+
/** The password rules ([LCK-8]). → weak_password */
|
|
2366
|
+
function checkPassword(password, username) {
|
|
2367
|
+
const n = password.normalize("NFC");
|
|
2368
|
+
if ([...n].length < 12 || utf8(n).length > 1024 || (n.toLowerCase() === username.toLowerCase() && /^[\x00-\x7f]*$/.test(n))) {
|
|
2369
|
+
fail("weak_password");
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
/** Derive a password lock's secrets ([LCK-7]): `{ authSeed, boxSecret }`. → kdf_params */
|
|
2374
|
+
async function deriveLockSecrets(account, lock, password, kdf, salt) {
|
|
2375
|
+
checkKdf(kdf);
|
|
2376
|
+
const pw = utf8(password.normalize("NFC"));
|
|
2377
|
+
const a = argon2id(pw, salt, { m: kdf.m, t: kdf.t, p: kdf.p });
|
|
2378
|
+
pw.fill(0);
|
|
2379
|
+
const ctx = { account, lock };
|
|
2380
|
+
try {
|
|
2381
|
+
return {
|
|
2382
|
+
authSeed: await hkdf(a, EMPTY, frameObject(label("password/auth"), ctx)),
|
|
2383
|
+
boxSecret: await hkdf(a, EMPTY, frameObject(label("password/box"), ctx)),
|
|
2384
|
+
};
|
|
2385
|
+
} finally {
|
|
2386
|
+
a.fill(0);
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
async function wrapKey(shared, epk, box, account, instance, lock) {
|
|
2391
|
+
return hkdf(shared, concat(epk, box), frameObject(label("wrap"), { account, instance, lock }));
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
/** Wrap the vault key to a lock's `box` ([LCK-9]). Draws: e (32), nonce (12). → bad_key */
|
|
2395
|
+
async function wrapVaultKey(vk, box, account, instance, lock, random) {
|
|
2396
|
+
const e = random(32);
|
|
2397
|
+
const nonce = random(12);
|
|
2398
|
+
const epk = await x25519Public(e);
|
|
2399
|
+
const shared = await x25519(e, box);
|
|
2400
|
+
const wk = await wrapKey(shared, epk, box, account, instance, lock);
|
|
2401
|
+
const ct = await aesGcmEncrypt(wk, nonce, vk, EMPTY);
|
|
2402
|
+
e.fill(0);
|
|
2403
|
+
shared.fill(0);
|
|
2404
|
+
wk.fill(0);
|
|
2405
|
+
return { epk: formatKey("x25519", epk), nonce: base64urlEncode(nonce), ct: base64urlEncode(ct) };
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
/** Unwrap the vault key with a lock secret ([LCK-9]). → unlock_failed, bad_key */
|
|
2409
|
+
async function unwrapVaultKey(wrap, secret, box, account, instance, lock) {
|
|
2410
|
+
const pub = await x25519Public(secret);
|
|
2411
|
+
if (formatKey("x25519", pub) !== formatKey("x25519", box)) fail("unlock_failed");
|
|
2412
|
+
const epk = s.parseKey(wrap.epk, "x25519");
|
|
2413
|
+
const shared = await x25519(secret, epk);
|
|
2414
|
+
const wk = await wrapKey(shared, epk, box, account, instance, lock);
|
|
2415
|
+
const vk = await aesGcmDecrypt(wk, base64urlDecode(wrap.nonce), base64urlDecode(wrap.ct), EMPTY);
|
|
2416
|
+
shared.fill(0);
|
|
2417
|
+
wk.fill(0);
|
|
2418
|
+
if (vk.length !== 32) fail("unlock_failed");
|
|
2419
|
+
return vk;
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
/** Check vault contents ([VLT-7]). → too_large, bad_field, unsupported_version, bad_data_key */
|
|
2423
|
+
function checkContents(v) {
|
|
2424
|
+
if (canonical(v).length > CONTENTS_LIMIT) fail("too_large");
|
|
2425
|
+
const m = s.object(v);
|
|
2426
|
+
s.version(m);
|
|
2427
|
+
s.members(m, ["v", "account_seed", "data"], ["known"]);
|
|
2428
|
+
const seed = s.bytes(m, "account_seed", 32);
|
|
2429
|
+
const data = s.member(m, "data");
|
|
2430
|
+
checkData(data, DATA_LIMIT);
|
|
2431
|
+
if (Object.hasOwn(m, "known")) {
|
|
2432
|
+
const k = s.object(m.known);
|
|
2433
|
+
for (const id of Object.keys(k)) {
|
|
2434
|
+
s.checkAccountId(id);
|
|
2435
|
+
const e = s.object(k[id]);
|
|
2436
|
+
s.members(e, ["handle", "version"]);
|
|
2437
|
+
s.string(e, "handle");
|
|
2438
|
+
s.versionRef(s.get(e, "version"));
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
return { accountSeed: seed, data, known: m.known };
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
function contentsValue(c) {
|
|
2445
|
+
const v = { v: 1, account_seed: base64urlEncode(c.accountSeed), data: c.data };
|
|
2446
|
+
if (c.known !== undefined) v.known = c.known;
|
|
2447
|
+
return v;
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
/**
|
|
2451
|
+
* Unlock a vault ([VLT-8]). `secret` is `{ lock, password }` or `{ recovery: { signSeed, boxSecret } }`.
|
|
2452
|
+
* Returns `{ vaultKey, contents: { accountSeed, data, known }, lock, lockSeed, info }`.
|
|
2453
|
+
*/
|
|
2454
|
+
async function unlockVault(vault, state, secret) {
|
|
2455
|
+
const info = await verifyVault(vault, state);
|
|
2456
|
+
let lockId;
|
|
2457
|
+
let lockSecret;
|
|
2458
|
+
let lockSeed;
|
|
2459
|
+
if (secret.recovery) {
|
|
2460
|
+
if (!info.locks.find((l) => l.id === "recovery")) fail("bad_locks");
|
|
2461
|
+
lockId = "recovery";
|
|
2462
|
+
lockSecret = secret.recovery.boxSecret;
|
|
2463
|
+
lockSeed = secret.recovery.signSeed;
|
|
2464
|
+
} else {
|
|
2465
|
+
const p = info.locks.find((l) => l.kind === "password" && l.id === secret.lock);
|
|
2466
|
+
if (!p) fail("bad_locks");
|
|
2467
|
+
const ls = await deriveLockSecrets(state.account, secret.lock, secret.password, p.kdf, p.kdf.salt);
|
|
2468
|
+
lockId = secret.lock;
|
|
2469
|
+
lockSecret = ls.boxSecret;
|
|
2470
|
+
lockSeed = ls.authSeed;
|
|
2471
|
+
}
|
|
2472
|
+
const lock = info.locks.find((l) => l.id === lockId);
|
|
2473
|
+
if (!lock) fail("bad_locks");
|
|
2474
|
+
const vk = await unwrapVaultKey(lock.value.wrap, lockSecret, lock.box, state.account, vault.instance, lockId);
|
|
2475
|
+
const pt = await aesGcmDecrypt(vk, base64urlDecode(vault.nonce), base64urlDecode(vault.ct), contentsAad(vault));
|
|
2476
|
+
const contents = checkContents(parseLimited(pt, CONTENTS_LIMIT));
|
|
2477
|
+
pt.fill(0);
|
|
2478
|
+
if (!state.instance) fail("no_instance");
|
|
2479
|
+
if (formatKey("ed25519", await ed25519Public(contents.accountSeed)) !== state.instance.account_key) {
|
|
2480
|
+
fail("wrong_account_key");
|
|
2481
|
+
}
|
|
2482
|
+
return { vaultKey: vk, contents, lock: lockId, lockSeed, info };
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
/** Sign a grant with the recovery key ([LCK-3]). `recoveryBox` is the raw X25519 public key. */
|
|
2486
|
+
async function signGrant(account, instance, gen, slots, recoveryBox, recoverySeed) {
|
|
2487
|
+
const g = { gen, slots: slots.map((x) => ({ id: x.id, label: x.label })), recovery: formatKey("x25519", recoveryBox) };
|
|
2488
|
+
g.sig = base64urlEncode(await ed25519Sign(recoverySeed, grantMessage(g, account, instance)));
|
|
2489
|
+
return g;
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
/** Encrypt the contents and sign a vault ([VLT-2], [VLT-6]). Draws: the contents nonce (12). */
|
|
2493
|
+
async function sealVault(account, instance, rev, grant, list, contents, vaultKey, random) {
|
|
2494
|
+
const cv = contentsValue(contents);
|
|
2495
|
+
checkContents(cv);
|
|
2496
|
+
const plain = canonical(cv);
|
|
2497
|
+
const nonce = random(12);
|
|
2498
|
+
const v = { v: 1, account, instance, rev, locks: { grant, list }, nonce: base64urlEncode(nonce) };
|
|
2499
|
+
v.ct = base64urlEncode(await aesGcmEncrypt(vaultKey, nonce, plain, contentsAad(v)));
|
|
2500
|
+
plain.fill(0);
|
|
2501
|
+
v.sig = base64urlEncode(await ed25519Sign(contents.accountSeed, vaultMessage(v)));
|
|
2502
|
+
if (canonical(v).length > VAULT_LIMIT) fail("too_large");
|
|
2503
|
+
return v;
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
function kdfValue(kdf, salt) {
|
|
2507
|
+
return { alg: "argon2id", m: kdf.m, t: kdf.t, p: kdf.p, salt: base64urlEncode(salt) };
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
/** Create a new password lock's public part. Draws: slot id (8), salt (16). Returns `[slot, lockPublic]`. */
|
|
2511
|
+
async function newPasswordLock(account, slot, random) {
|
|
2512
|
+
checkLabel(slot.label);
|
|
2513
|
+
const id = base64urlEncode(random(8));
|
|
2514
|
+
const salt = random(16);
|
|
2515
|
+
const kdf = { m: slot.kdf.m, t: slot.kdf.t, p: slot.kdf.p ?? 1 };
|
|
2516
|
+
const ls = await deriveLockSecrets(account, id, slot.password, kdf, salt);
|
|
2517
|
+
const pub = {
|
|
2518
|
+
id,
|
|
2519
|
+
kdf: kdfValue(kdf, salt),
|
|
2520
|
+
auth: formatKey("ed25519", await ed25519Public(ls.authSeed)),
|
|
2521
|
+
box: formatKey("x25519", await x25519Public(ls.boxSecret)),
|
|
2522
|
+
};
|
|
2523
|
+
ls.authSeed.fill(0);
|
|
2524
|
+
ls.boxSecret.fill(0);
|
|
2525
|
+
return [{ id, label: slot.label }, pub];
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
function passwordLock(pub, wrap) {
|
|
2529
|
+
return { id: pub.id, kind: "password", kdf: pub.kdf, auth: pub.auth, box: pub.box, wrap };
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
async function buildLocksAndSeal(account, instance, rev, grant, rbox, pairs, contents, vk, random) {
|
|
2533
|
+
const list = [
|
|
2534
|
+
{ id: "recovery", kind: "recovery", box: formatKey("x25519", rbox), wrap: await wrapVaultKey(vk, rbox, account, instance, "recovery", random) },
|
|
2535
|
+
];
|
|
2536
|
+
for (const [, pub] of pairs) {
|
|
2537
|
+
const w = await wrapVaultKey(vk, s.parseKey(pub.box, "x25519"), account, instance, pub.id, random);
|
|
2538
|
+
list.push(passwordLock(pub, w));
|
|
2539
|
+
}
|
|
2540
|
+
return sealVault(account, instance, rev, grant, list, contents, vk, random);
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
/**
|
|
2544
|
+
* Build the vault of a new instance (`rev` 1, `gen` 1). Draws: vault key (32); for a new slot, id (8) and salt (16);
|
|
2545
|
+
* the recovery wrap (32 + 12); each password wrap (32 + 12) in slot order; the contents nonce (12).
|
|
2546
|
+
* `p`: `{ account, instance, recovery: { signSeed, boxSecret }, contents, keep: [[slot, lockPublic]], add?: slot }`.
|
|
2547
|
+
*/
|
|
2548
|
+
async function instanceVault(p, random) {
|
|
2549
|
+
const vk = random(32);
|
|
2550
|
+
const pairs = [...p.keep];
|
|
2551
|
+
if (p.add) pairs.push(await newPasswordLock(p.account, p.add, random));
|
|
2552
|
+
const rbox = await x25519Public(p.recovery.boxSecret);
|
|
2553
|
+
const grant = await signGrant(p.account, p.instance, 1, pairs.map(([x]) => x), rbox, p.recovery.signSeed);
|
|
2554
|
+
try {
|
|
2555
|
+
return await buildLocksAndSeal(p.account, p.instance, 1, grant, rbox, pairs, p.contents, vk, random);
|
|
2556
|
+
} finally {
|
|
2557
|
+
vk.fill(0);
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
/** The password locks of an unlocked vault to carry over ([KT-3]); `drop` names slots to leave out. */
|
|
2562
|
+
function carriedLocks(u, drop = []) {
|
|
2563
|
+
const out = [];
|
|
2564
|
+
for (const x of u.info.slots) {
|
|
2565
|
+
if (drop.includes(x.id)) continue;
|
|
2566
|
+
const l = u.info.locks.find((y) => y.kind === "password" && y.id === x.id);
|
|
2567
|
+
if (l) out.push([{ id: x.id, label: x.label }, { id: l.id, kdf: l.value.kdf, auth: l.value.auth, box: l.value.box }]);
|
|
2568
|
+
}
|
|
2569
|
+
return out;
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
function idsOf(u, vault) {
|
|
2573
|
+
const m = s.object(vault);
|
|
2574
|
+
const grant = s.member(s.member(m, "locks"), "grant");
|
|
2575
|
+
if (s.int(m, "rev") !== u.info.rev || s.int(grant, "gen") !== u.info.gen) fail("stale_rev");
|
|
2576
|
+
return { account: s.string(m, "account"), instance: s.string(m, "instance"), grant };
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
/** A data update ([HST-12] case 1): `rev + 1`. Draws: the contents nonce (12). */
|
|
2580
|
+
async function updateContents(u, vault, contents, random) {
|
|
2581
|
+
const { account, instance, grant } = idsOf(u, vault);
|
|
2582
|
+
const list = u.info.locks.map((l) => l.value);
|
|
2583
|
+
return sealVault(account, instance, u.info.rev + 1, grant, list, contents, u.vaultKey, random);
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
/**
|
|
2587
|
+
* A password change ([CHG-1] step 1). Draws: salt (16), wrap (32 + 12), contents nonce (12).
|
|
2588
|
+
* Returns `{ vault, new }` where `new` is `SHA-256(canonical(new lock))`.
|
|
2589
|
+
*/
|
|
2590
|
+
async function changePassword(u, vault, slot, password, kdf, random) {
|
|
2591
|
+
const { account, instance, grant } = idsOf(u, vault);
|
|
2592
|
+
const index = u.info.locks.findIndex((l) => l.kind === "password" && l.id === slot);
|
|
2593
|
+
if (index < 0) fail("bad_locks");
|
|
2594
|
+
const salt = random(16);
|
|
2595
|
+
const k = { m: kdf.m, t: kdf.t, p: kdf.p ?? 1 };
|
|
2596
|
+
const ls = await deriveLockSecrets(account, slot, password, k, salt);
|
|
2597
|
+
const box = await x25519Public(ls.boxSecret);
|
|
2598
|
+
const pub = { id: slot, kdf: kdfValue(k, salt), auth: formatKey("ed25519", await ed25519Public(ls.authSeed)), box: formatKey("x25519", box) };
|
|
2599
|
+
ls.authSeed.fill(0);
|
|
2600
|
+
ls.boxSecret.fill(0);
|
|
2601
|
+
const lock = passwordLock(pub, await wrapVaultKey(u.vaultKey, box, account, instance, slot, random));
|
|
2602
|
+
const hash = await sha256(canonical(lock));
|
|
2603
|
+
const list = u.info.locks.map((l) => l.value);
|
|
2604
|
+
list[index] = lock;
|
|
2605
|
+
return { vault: await sealVault(account, instance, u.info.rev + 1, grant, list, u.contents, u.vaultKey, random), new: hash };
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
/** Add a password slot ([KT-4]). Draws: id (8), salt (16), wrap (32 + 12), contents nonce (12). */
|
|
2609
|
+
async function addSlot(u, vault, recovery, slot, random) {
|
|
2610
|
+
const { account, instance } = idsOf(u, vault);
|
|
2611
|
+
if (u.info.slots.length >= MAX_SLOTS) fail("bad_locks");
|
|
2612
|
+
const [x, pub] = await newPasswordLock(account, slot, random);
|
|
2613
|
+
const w = await wrapVaultKey(u.vaultKey, s.parseKey(pub.box, "x25519"), account, instance, x.id, random);
|
|
2614
|
+
const slots = [...u.info.slots, x];
|
|
2615
|
+
const list = [...u.info.locks.map((l) => l.value), passwordLock(pub, w)];
|
|
2616
|
+
const grant = await signGrant(account, instance, u.info.gen + 1, slots, await x25519Public(recovery.boxSecret), recovery.signSeed);
|
|
2617
|
+
return sealVault(account, instance, u.info.rev + 1, grant, list, u.contents, u.vaultKey, random);
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
/** The vault after a recovery-key rotation ([KT-5]). Draws: wrap (32 + 12), contents nonce (12). */
|
|
2621
|
+
async function regrant(u, vault, newRecovery, random) {
|
|
2622
|
+
const { account, instance } = idsOf(u, vault);
|
|
2623
|
+
const rbox = await x25519Public(newRecovery.boxSecret);
|
|
2624
|
+
const grant = await signGrant(account, instance, u.info.gen + 1, u.info.slots, rbox, newRecovery.signSeed);
|
|
2625
|
+
const list = [];
|
|
2626
|
+
for (const l of u.info.locks) {
|
|
2627
|
+
if (l.kind === "recovery") {
|
|
2628
|
+
list.push({ id: "recovery", kind: "recovery", box: formatKey("x25519", rbox), wrap: await wrapVaultKey(u.vaultKey, rbox, account, instance, "recovery", random) });
|
|
2629
|
+
} else list.push(l.value);
|
|
2630
|
+
}
|
|
2631
|
+
return sealVault(account, instance, u.info.rev + 1, grant, list, u.contents, u.vaultKey, random);
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
|
|
2635
|
+
|
|
2636
|
+
return { verifyVault, checkPassword, deriveLockSecrets, wrapVaultKey, unwrapVaultKey, checkContents, unlockVault, signGrant, sealVault, newPasswordLock, instanceVault, carriedLocks, updateContents, changePassword, addSlot, regrant };
|
|
2637
|
+
})();
|
|
2638
|
+
__modules["account.js"] = (() => {
|
|
2639
|
+
// High-level flows (SPEC §18): registration ([CLT-1]), new instances ([KT-3], [KT-4], [KT-8]) and recovery-key
|
|
2640
|
+
// rotation ([KT-5]). The random draws are in the same order as the Rust crate's `account` module.
|
|
2641
|
+
|
|
2642
|
+
const { fail } = __modules["errors.js"];
|
|
2643
|
+
const { base64urlEncode } = __modules["encoding.js"];
|
|
2644
|
+
const { inception } = __modules["id.js"];
|
|
2645
|
+
const { checkRecoveryKey, eventHash, pendingInstance, rotateRecoveryEvent, verifyLog } = __modules["log.js"];
|
|
2646
|
+
const { signPublic } = __modules["profile.js"];
|
|
2647
|
+
const { deriveRecoveryKeys } = __modules["recovery.js"];
|
|
2648
|
+
const { recoveryCard } = __modules["file.js"];
|
|
2649
|
+
const { carriedLocks, checkPassword, instanceVault, regrant } = __modules["vault.js"];
|
|
2650
|
+
|
|
2651
|
+
/**
|
|
2652
|
+
* Build a registration ([CLT-1]) from a recovery key the caller generated ([RK-3]). `home` is `{ key, url }` with
|
|
2653
|
+
* a key string. Draws: the account seed (32), then instanceVault with one new slot.
|
|
2654
|
+
* @returns {Promise<{ log, vault, public, card, account }>}
|
|
2655
|
+
*/
|
|
2656
|
+
async function register(recoveryKey, realm, username, password, slotLabel, home, time, kdf, random) {
|
|
2657
|
+
checkPassword(password, username);
|
|
2658
|
+
const keys = await deriveRecoveryKeys(recoveryKey, realm, username);
|
|
2659
|
+
const inc = await inception(keys, realm, username);
|
|
2660
|
+
const state = await verifyLog([inc], { realms: [realm] });
|
|
2661
|
+
const seed = random(32);
|
|
2662
|
+
const pending = await pendingInstance(state, keys.signSeed, seed, home, "register", time);
|
|
2663
|
+
const instance = base64urlEncode(await eventHash(pending));
|
|
2664
|
+
const vault = await instanceVault(
|
|
2665
|
+
{
|
|
2666
|
+
account: state.account,
|
|
2667
|
+
instance,
|
|
2668
|
+
recovery: keys,
|
|
2669
|
+
contents: { accountSeed: seed, data: {} },
|
|
2670
|
+
keep: [],
|
|
2671
|
+
add: { label: slotLabel, password, kdf },
|
|
2672
|
+
},
|
|
2673
|
+
random,
|
|
2674
|
+
);
|
|
2675
|
+
const pub = await signPublic(state.account, instance, 1, {}, seed);
|
|
2676
|
+
const card = await recoveryCard(realm, username, state.account, recoveryKey);
|
|
2677
|
+
seed.fill(0);
|
|
2678
|
+
keys.signSeed.fill(0);
|
|
2679
|
+
keys.boxSecret.fill(0);
|
|
2680
|
+
return { log: [inc, pending], vault, public: pub, card, account: state.account };
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
/**
|
|
2684
|
+
* Build a new instance on top of `state` ([KT-3]). `mode` is `{ restore: true }`, `{ revoke: [slot ids] }`,
|
|
2685
|
+
* `{ reset: slot }` or `{ fresh: slot }`, where a slot is `{ label, password, kdf }`. `old` is the current vault
|
|
2686
|
+
* unlocked with the recovery lock (not needed for fresh). Draws: the account seed (32), then instanceVault.
|
|
2687
|
+
* @returns {Promise<{ event, vault, public }>}
|
|
2688
|
+
*/
|
|
2689
|
+
async function newInstance(state, recovery, old, oldPublicData, mode, home, time, random) {
|
|
2690
|
+
await checkRecoveryKey(state, recovery.signSeed);
|
|
2691
|
+
const seed = random(32);
|
|
2692
|
+
const reason = mode.restore ? "restore" : mode.fresh ? "fresh" : "revoke";
|
|
2693
|
+
const event = await pendingInstance(state, recovery.signSeed, seed, home, reason, time);
|
|
2694
|
+
const instance = base64urlEncode(await eventHash(event));
|
|
2695
|
+
let data = {};
|
|
2696
|
+
let known;
|
|
2697
|
+
let keep = [];
|
|
2698
|
+
let add;
|
|
2699
|
+
let publicData = {};
|
|
2700
|
+
if (!mode.fresh) {
|
|
2701
|
+
if (!old) fail("bad_locks");
|
|
2702
|
+
data = old.contents.data;
|
|
2703
|
+
known = old.contents.known;
|
|
2704
|
+
publicData = oldPublicData ?? {};
|
|
2705
|
+
if (mode.restore) keep = carriedLocks(old, []);
|
|
2706
|
+
else if (mode.revoke) keep = carriedLocks(old, mode.revoke);
|
|
2707
|
+
else add = mode.reset;
|
|
2708
|
+
} else add = mode.fresh;
|
|
2709
|
+
const contents = { accountSeed: seed, data };
|
|
2710
|
+
if (known !== undefined) contents.known = known;
|
|
2711
|
+
const vault = await instanceVault({ account: state.account, instance, recovery, contents, keep, add }, random);
|
|
2712
|
+
const pub = await signPublic(state.account, instance, 1, publicData, seed);
|
|
2713
|
+
seed.fill(0);
|
|
2714
|
+
return { event, vault, public: pub };
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
/**
|
|
2718
|
+
* Rotate the recovery key ([KT-5]) to `newKey`. `unlocked` is the current vault unlocked. Draws: regrant.
|
|
2719
|
+
* @returns {Promise<{ event, vault, card }>}
|
|
2720
|
+
*/
|
|
2721
|
+
async function rotateRecovery(state, log, oldKeys, newKey, oldVault, unlocked, time, random) {
|
|
2722
|
+
const next = await deriveRecoveryKeys(newKey, state.realm, state.username);
|
|
2723
|
+
const event = await rotateRecoveryEvent(state, oldKeys.signSeed, next.signSeed, time);
|
|
2724
|
+
const vault = await regrant(unlocked, oldVault, next, random);
|
|
2725
|
+
const card = await recoveryCard(state.realm, state.username, state.account, newKey, [...log, event]);
|
|
2726
|
+
return { event, vault, card };
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
return { register, newInstance, rotateRecovery };
|
|
2730
|
+
})();
|
|
2731
|
+
__modules["testing.js"] = (() => {
|
|
2732
|
+
// A deterministic byte stream for tests and vectors. Never use it for keys or nonces.
|
|
2733
|
+
// The stream is SHA-256(seed ‖ be32(0)) ‖ SHA-256(seed ‖ be32(1)) ‖ …, consumed byte by byte, the same as the Rust
|
|
2734
|
+
// crate's `testing::TestRandom`, so both implementations draw identical bytes (SPEC §21).
|
|
2735
|
+
|
|
2736
|
+
/**
|
|
2737
|
+
* A deterministic `random(n)` function for tests. It is synchronous; it needs `sha256Sync`, a synchronous SHA-256,
|
|
2738
|
+
* which the tests take from `node:crypto`.
|
|
2739
|
+
* @param {Uint8Array} seed
|
|
2740
|
+
* @param {(m: Uint8Array) => Uint8Array} sha256Sync
|
|
2741
|
+
*/
|
|
2742
|
+
function testRandom(seed, sha256Sync) {
|
|
2743
|
+
let counter = 0;
|
|
2744
|
+
let buf = new Uint8Array(0);
|
|
2745
|
+
let pos = 0;
|
|
2746
|
+
return (n) => {
|
|
2747
|
+
const out = new Uint8Array(n);
|
|
2748
|
+
for (let i = 0; i < n; i++) {
|
|
2749
|
+
if (pos === buf.length) {
|
|
2750
|
+
const m = new Uint8Array(seed.length + 4);
|
|
2751
|
+
m.set(seed);
|
|
2752
|
+
new DataView(m.buffer).setUint32(seed.length, counter++);
|
|
2753
|
+
buf = sha256Sync(m);
|
|
2754
|
+
pos = 0;
|
|
2755
|
+
}
|
|
2756
|
+
out[i] = buf[pos++];
|
|
2757
|
+
}
|
|
2758
|
+
return out;
|
|
2759
|
+
};
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
/** Random bytes from the platform's secure generator, for applications ([CRY-1]). Not used by the core itself. */
|
|
2763
|
+
function systemRandom(n) {
|
|
2764
|
+
return globalThis.crypto.getRandomValues(new Uint8Array(n));
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
return { testRandom, systemRandom };
|
|
2768
|
+
})();
|
|
2769
|
+
// The key tool page (SPEC §18.2), inlined into keytool.html after the library modules. It talks only to hosts the
|
|
2770
|
+
// user enters ([KT-1]) and gathers the newest log before signing anything ([KT-2]).
|
|
2771
|
+
|
|
2772
|
+
const rc = { ...__modules["index.js"] };
|
|
2773
|
+
for (const name of ["errors.js", "json.js", "encoding.js", "labels.js", "crypto.js", "keys.js", "recovery.js", "id.js", "log.js",
|
|
2774
|
+
"vaultcheck.js", "profile.js", "messages.js", "proof.js", "file.js", "vault.js", "account.js", "testing.js"]) {
|
|
2775
|
+
Object.assign(rc, __modules[name]);
|
|
2776
|
+
}
|
|
2777
|
+
const $ = (id) => document.getElementById(id);
|
|
2778
|
+
const random = (n) => crypto.getRandomValues(new Uint8Array(n));
|
|
2779
|
+
const now = () => Math.floor(Date.now() / 1000);
|
|
2780
|
+
let current = null; // { keys, key, log, state, vault, public, realm, username, account }
|
|
2781
|
+
|
|
2782
|
+
function show(text, ok = true) {
|
|
2783
|
+
$("status").className = ok ? "ok" : "err";
|
|
2784
|
+
$("status").textContent = ok ? "Done." : "Failed.";
|
|
2785
|
+
$("out").textContent = text;
|
|
2786
|
+
}
|
|
2787
|
+
|
|
2788
|
+
function explain(e) {
|
|
2789
|
+
if (e instanceof rc.RootcardError) {
|
|
2790
|
+
const why = {
|
|
2791
|
+
wrong_recovery_key: "This recovery key is not the identity's current one.",
|
|
2792
|
+
bad_checksum: "The recovery key has a typo.",
|
|
2793
|
+
unlock_failed: "Wrong password or recovery key.",
|
|
2794
|
+
weak_password: "The password is too weak: at least 12 characters, not the username.",
|
|
2795
|
+
fork: "Two versions of the identity's log fork. Extend the branch the primary host holds.",
|
|
2796
|
+
not_home: "This host is not the identity's primary host.",
|
|
2797
|
+
unsupported_browser: "This browser lacks WebCrypto Ed25519 or X25519.",
|
|
2798
|
+
}[e.code];
|
|
2799
|
+
return `${why ?? "Error"} (${e.code})`;
|
|
2800
|
+
}
|
|
2801
|
+
return String(e?.message ?? e);
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
async function run(f) {
|
|
2805
|
+
for (const b of document.querySelectorAll("button")) b.disabled = true;
|
|
2806
|
+
$("status").className = "";
|
|
2807
|
+
$("status").textContent = "Working…";
|
|
2808
|
+
try {
|
|
2809
|
+
await rc.checkSupport();
|
|
2810
|
+
show(await f());
|
|
2811
|
+
} catch (e) {
|
|
2812
|
+
show(explain(e), false);
|
|
2813
|
+
} finally {
|
|
2814
|
+
for (const b of document.querySelectorAll("button")) b.disabled = false;
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2817
|
+
|
|
2818
|
+
function download(name, value) {
|
|
2819
|
+
const blob = new Blob([rc.canonicalString(value) + "\n"], { type: "application/json" });
|
|
2820
|
+
const a = document.createElement("a");
|
|
2821
|
+
a.href = URL.createObjectURL(blob);
|
|
2822
|
+
a.download = name;
|
|
2823
|
+
a.click();
|
|
2824
|
+
setTimeout(() => URL.revokeObjectURL(a.href), 10000);
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2827
|
+
async function readFile(input) {
|
|
2828
|
+
const f = input.files?.[0];
|
|
2829
|
+
if (!f) return undefined;
|
|
2830
|
+
return rc.parseLimited(new Uint8Array(await f.arrayBuffer()), rc.FILE_LIMIT ?? 5 * 1024 * 1024);
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
function checkUrl(url) {
|
|
2834
|
+
if (!/^https:\/\//.test(url) && !/^http:\/\/(127\.0\.0\.1|localhost)(:|\/|$)/.test(url)) throw new Error(`${url}: hosts are reached over https`);
|
|
2835
|
+
return url.replace(/\/+$/, "");
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
// A host address as the user typed it: trailing slashes removed, then checked as a base URL ([HST-22]).
|
|
2839
|
+
function checkHost(url) {
|
|
2840
|
+
const base = url.replace(/\/+$/, "");
|
|
2841
|
+
try {
|
|
2842
|
+
rc.checkBaseUrl(base);
|
|
2843
|
+
} catch {
|
|
2844
|
+
throw new Error(`${url}: not a host base URL (https://host[:port][/path], without /v0 at the end)`);
|
|
2845
|
+
}
|
|
2846
|
+
return base;
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
// The API root of a host: every endpoint is under `<base URL>/v0` until 1.0.0 (HOST_PROTOCOL.md §1.3). Base URLs
|
|
2850
|
+
// (`home.url`, and `url` in signed messages) never include the version.
|
|
2851
|
+
function apiRoot(base) {
|
|
2852
|
+
return `${checkHost(base)}/${rc.API_VERSION}`;
|
|
2853
|
+
}
|
|
2854
|
+
|
|
2855
|
+
async function http(method, url, body) {
|
|
2856
|
+
const res = await fetch(checkUrl(url), {
|
|
2857
|
+
method,
|
|
2858
|
+
headers: body === undefined ? {} : { "Content-Type": "application/json" },
|
|
2859
|
+
body: body === undefined ? undefined : rc.canonicalString(body),
|
|
2860
|
+
credentials: "omit",
|
|
2861
|
+
referrerPolicy: "no-referrer",
|
|
2862
|
+
cache: "no-store",
|
|
2863
|
+
});
|
|
2864
|
+
const text = await res.text();
|
|
2865
|
+
const value = text ? rc.parseJson(text) : true;
|
|
2866
|
+
if (!res.ok) {
|
|
2867
|
+
const code = value?.error;
|
|
2868
|
+
if (code && rc.ERROR_CODES.includes(code)) throw new rc.RootcardError(code);
|
|
2869
|
+
throw new Error(`${url}: HTTP ${res.status}`);
|
|
2870
|
+
}
|
|
2871
|
+
return value;
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
function hostList() {
|
|
2875
|
+
return $("hosts").value.split(",").map((s) => s.trim()).filter(Boolean).map(checkHost);
|
|
2876
|
+
}
|
|
2877
|
+
|
|
2878
|
+
/** [KT-2]: the newest log from the card, the account file and the hosts; a fork stops. */
|
|
2879
|
+
async function gather() {
|
|
2880
|
+
let cardValue = await readFile($("cardFile"));
|
|
2881
|
+
if (!cardValue) {
|
|
2882
|
+
const key = await rc.parseRecoveryKey($("rkey").value);
|
|
2883
|
+
const realm = $("realm").value.trim();
|
|
2884
|
+
const username = $("username").value.trim();
|
|
2885
|
+
const keys = await rc.deriveRecoveryKeys(key, realm, username);
|
|
2886
|
+
const inc = await rc.inception(keys, realm, username);
|
|
2887
|
+
cardValue = await rc.recoveryCard(realm, username, await rc.accountId(inc), key);
|
|
2888
|
+
}
|
|
2889
|
+
const card = await rc.readCard(cardValue);
|
|
2890
|
+
const opts = { realms: [card.realm], now: now() };
|
|
2891
|
+
let log = card.log;
|
|
2892
|
+
let vault;
|
|
2893
|
+
let pub;
|
|
2894
|
+
const consider = async (candidate, source) => {
|
|
2895
|
+
await rc.verifyLog(candidate, opts);
|
|
2896
|
+
const order = await rc.compareLogs(log, candidate);
|
|
2897
|
+
if (order === "fork") throw new Error(`${source} holds a log that forks from the one already found. Extend the branch the primary host holds.`);
|
|
2898
|
+
if (order === "newer") log = candidate;
|
|
2899
|
+
};
|
|
2900
|
+
const file = await readFile($("accountFile"));
|
|
2901
|
+
if (file) {
|
|
2902
|
+
const a = await rc.importAccount(file, opts);
|
|
2903
|
+
if (a.state.account !== card.account) throw new Error("The account file is another identity.");
|
|
2904
|
+
await consider(a.log, "the account file");
|
|
2905
|
+
vault = a.vault?.value;
|
|
2906
|
+
pub = a.public?.value;
|
|
2907
|
+
}
|
|
2908
|
+
const hosts = hostList();
|
|
2909
|
+
const st0 = await rc.verifyLog(log, opts);
|
|
2910
|
+
if (st0.instance) hosts.push(st0.instance.home.url);
|
|
2911
|
+
const notes = [];
|
|
2912
|
+
for (const h of [...new Set(hosts)]) {
|
|
2913
|
+
try {
|
|
2914
|
+
const v = await http("GET", `${apiRoot(h)}/id/${card.account}/log`);
|
|
2915
|
+
await consider(v.events, h);
|
|
2916
|
+
} catch (e) {
|
|
2917
|
+
notes.push(`${h}: ${explain(e)}`);
|
|
2918
|
+
}
|
|
2919
|
+
}
|
|
2920
|
+
const state = await rc.verifyLog(log, opts);
|
|
2921
|
+
await rc.checkRecoveryKey(state, card.keys.signSeed);
|
|
2922
|
+
current = { keys: card.keys, key: card.key, card: cardValue, log, state, vault, public: pub };
|
|
2923
|
+
return notes;
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
function describe(state) {
|
|
2927
|
+
const lines = [
|
|
2928
|
+
`Handle: ${state.handle}`,
|
|
2929
|
+
`Account: ${state.account}`,
|
|
2930
|
+
`Realm: ${state.realm}`,
|
|
2931
|
+
`Version: seq ${state.version.seq} ${state.version.hash}`,
|
|
2932
|
+
];
|
|
2933
|
+
if (state.retired) lines.push("Status: retired");
|
|
2934
|
+
else if (state.instance) lines.push(`Instance: seq ${state.instance.seq} (${state.instance.reason}) at ${state.instance.home.url}`);
|
|
2935
|
+
else lines.push("Instance: none");
|
|
2936
|
+
return lines.join("\n");
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2939
|
+
async function currentVault() {
|
|
2940
|
+
if (current.vault) {
|
|
2941
|
+
try {
|
|
2942
|
+
return { vault: current.vault, u: await rc.unlockVault(current.vault, current.state, { recovery: current.keys }) };
|
|
2943
|
+
} catch {}
|
|
2944
|
+
}
|
|
2945
|
+
if (!current.state.instance) throw new Error("No instance found: load the account file or enter the primary host.");
|
|
2946
|
+
const base = current.state.instance.home.url;
|
|
2947
|
+
const account = current.state.account;
|
|
2948
|
+
const ch = rc.parseChallenge(await http("POST", `${apiRoot(base)}/id/${account}/challenge`, { purpose: "fetch" }));
|
|
2949
|
+
const sig = await rc.ed25519Sign(current.keys.signSeed, rc.fetchMessage(account, ch.host, "recovery", ch.nonce, base));
|
|
2950
|
+
const v = await http("POST", `${apiRoot(base)}/id/${account}/fetch`, { lock: "recovery", nonce: ch.nonce, url: base, sig: rc.base64urlEncode(sig) });
|
|
2951
|
+
return { vault: v.vault, u: await rc.unlockVault(v.vault, current.state, { recovery: current.keys }) };
|
|
2952
|
+
}
|
|
2953
|
+
|
|
2954
|
+
async function hostHome(base) {
|
|
2955
|
+
const d = await http("GET", `${apiRoot(base)}/host`);
|
|
2956
|
+
rc.parseKey(d.key, "ed25519");
|
|
2957
|
+
return { key: d.key, url: base };
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
function newSlot() {
|
|
2961
|
+
const password = $("newPassword").value;
|
|
2962
|
+
rc.checkPassword(password, current.state.username);
|
|
2963
|
+
return { label: $("label").value.trim() || "Password", password, kdf: rc.KDF_DEFAULT };
|
|
2964
|
+
}
|
|
2965
|
+
|
|
2966
|
+
async function submitInstance(base, mode, old) {
|
|
2967
|
+
const home = await hostHome(base);
|
|
2968
|
+
let publicData;
|
|
2969
|
+
if (current.public) {
|
|
2970
|
+
try {
|
|
2971
|
+
publicData = (await rc.verifyPublic(current.public, current.state)).data;
|
|
2972
|
+
} catch {}
|
|
2973
|
+
}
|
|
2974
|
+
const ni = await rc.newInstance(current.state, current.keys, old, publicData, mode, home, now(), random);
|
|
2975
|
+
const account = current.state.account;
|
|
2976
|
+
await http("POST", `${apiRoot(base)}/id/${account}/instance`, { events: current.log, instance: ni.event, vault: ni.vault, public: ni.public });
|
|
2977
|
+
const v = await http("GET", `${apiRoot(base)}/id/${account}/log`);
|
|
2978
|
+
const state = await rc.verifyLog(v.events, { realms: [current.state.realm], now: now() });
|
|
2979
|
+
const oldHome = current.state.instance?.home.url;
|
|
2980
|
+
let note = "";
|
|
2981
|
+
if (oldHome && oldHome !== base) {
|
|
2982
|
+
try {
|
|
2983
|
+
await http("POST", `${apiRoot(oldHome)}/id/${account}/events`, { events: v.events.slice(current.log.length) });
|
|
2984
|
+
} catch (e) {
|
|
2985
|
+
note = `\nCould not tell the previous primary host: ${explain(e)}`;
|
|
2986
|
+
}
|
|
2987
|
+
}
|
|
2988
|
+
current.log = v.events;
|
|
2989
|
+
current.state = state;
|
|
2990
|
+
current.vault = ni.vault;
|
|
2991
|
+
current.public = ni.public;
|
|
2992
|
+
download(rc.accountFileName(state), rc.exportAccount(state, v.events, ni.public, ni.vault));
|
|
2993
|
+
return describe(state) + "\n\nThe new account file was downloaded." + note;
|
|
2994
|
+
}
|
|
2995
|
+
|
|
2996
|
+
function refreshSlots(info) {
|
|
2997
|
+
$("slot").replaceChildren(...info.slots.map((s) => new Option(`${s.label} (${s.id})`, s.id)));
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
async function challengeOf(text) {
|
|
3001
|
+
const t = text.trim();
|
|
3002
|
+
if (/^[0-9a-fA-F]{64}$/.test(t)) return rc.hexDecode(t);
|
|
3003
|
+
return rc.statementChallenge(rc.parseJson(t));
|
|
3004
|
+
}
|
|
3005
|
+
|
|
3006
|
+
const ops = {
|
|
3007
|
+
async restore() {
|
|
3008
|
+
const { u } = await currentVault();
|
|
3009
|
+
return submitInstance(checkHost($("targetHost").value.trim()), { restore: true }, u);
|
|
3010
|
+
},
|
|
3011
|
+
async fresh() {
|
|
3012
|
+
return submitInstance(checkHost($("targetHost").value.trim()), { fresh: newSlot() }, undefined);
|
|
3013
|
+
},
|
|
3014
|
+
async reset() {
|
|
3015
|
+
const { u } = await currentVault();
|
|
3016
|
+
return submitInstance(current.state.instance.home.url, { reset: newSlot() }, u);
|
|
3017
|
+
},
|
|
3018
|
+
async revoke() {
|
|
3019
|
+
const { u } = await currentVault();
|
|
3020
|
+
const id = $("slot").value;
|
|
3021
|
+
if (!id) throw new Error("Choose a password to revoke.");
|
|
3022
|
+
return submitInstance(current.state.instance.home.url, { revoke: [id] }, u);
|
|
3023
|
+
},
|
|
3024
|
+
async add() {
|
|
3025
|
+
const { vault, u } = await currentVault();
|
|
3026
|
+
const v = await rc.addSlot(u, vault, current.keys, newSlot(), random);
|
|
3027
|
+
await http("PUT", `${apiRoot(current.state.instance.home.url)}/id/${current.state.account}/vault`, { vault: v });
|
|
3028
|
+
current.vault = v;
|
|
3029
|
+
const info = await rc.verifyVault(v, current.state);
|
|
3030
|
+
refreshSlots(info);
|
|
3031
|
+
return "Passwords:\n" + info.slots.map((s) => ` ${s.label} (${s.id})`).join("\n");
|
|
3032
|
+
},
|
|
3033
|
+
async rotate() {
|
|
3034
|
+
const { vault, u } = await currentVault();
|
|
3035
|
+
const newKey = random(32);
|
|
3036
|
+
const r = await rc.rotateRecovery(current.state, current.log, current.keys, newKey, vault, u, now(), random);
|
|
3037
|
+
await http("POST", `${apiRoot(current.state.instance.home.url)}/id/${current.state.account}/events`, { events: [r.event], vault: r.vault });
|
|
3038
|
+
download(`${current.state.username}-new.card.json`, r.card);
|
|
3039
|
+
const text = await rc.cardText(r.card);
|
|
3040
|
+
newKey.fill(0);
|
|
3041
|
+
current = null;
|
|
3042
|
+
$("actions").hidden = true;
|
|
3043
|
+
return text + "\nThe new card was downloaded. Destroy the old card, then load the new one.";
|
|
3044
|
+
},
|
|
3045
|
+
async retire() {
|
|
3046
|
+
if ($("retireConfirm").value !== current.state.username) throw new Error("Type the username to confirm.");
|
|
3047
|
+
const ev = await rc.retireEvent(current.state, current.keys.signSeed, now());
|
|
3048
|
+
const base = current.state.instance?.home.url ?? hostList()[0];
|
|
3049
|
+
if (!base) throw new Error("Enter a host.");
|
|
3050
|
+
await http("POST", `${apiRoot(base)}/id/${current.state.account}/events`, { events: [ev] });
|
|
3051
|
+
return `${current.state.handle} is retired.`;
|
|
3052
|
+
},
|
|
3053
|
+
async card() {
|
|
3054
|
+
return rc.cardText(current.card);
|
|
3055
|
+
},
|
|
3056
|
+
async prove() {
|
|
3057
|
+
const p = await rc.prove(current.state, "recovery", current.keys.signSeed, await challengeOf($("proofStatement").value), $("proofRecipient").value);
|
|
3058
|
+
return rc.canonicalString(p);
|
|
3059
|
+
},
|
|
3060
|
+
};
|
|
3061
|
+
|
|
3062
|
+
$("version").textContent = ROOTCARD_VERSION;
|
|
3063
|
+
$("load").addEventListener("click", () =>
|
|
3064
|
+
run(async () => {
|
|
3065
|
+
const notes = await gather();
|
|
3066
|
+
$("actions").hidden = false;
|
|
3067
|
+
let extra = "";
|
|
3068
|
+
try {
|
|
3069
|
+
const { u } = await currentVault();
|
|
3070
|
+
refreshSlots(u.info);
|
|
3071
|
+
} catch (e) {
|
|
3072
|
+
extra = `\n(Vault not available: ${explain(e)})`;
|
|
3073
|
+
}
|
|
3074
|
+
return describe(current.state) + extra + (notes.length ? "\n\n" + notes.join("\n") : "");
|
|
3075
|
+
}),
|
|
3076
|
+
);
|
|
3077
|
+
for (const b of document.querySelectorAll("[data-op]")) {
|
|
3078
|
+
b.addEventListener("click", () => run(() => (current ? ops[b.dataset.op]() : Promise.reject(new Error("Load the identity first.")))));
|
|
3079
|
+
}
|
|
3080
|
+
$("create").addEventListener("click", () =>
|
|
3081
|
+
run(async () => {
|
|
3082
|
+
const realm = $("nRealm").value.trim();
|
|
3083
|
+
const username = $("nUser").value.trim();
|
|
3084
|
+
const rk = random(32);
|
|
3085
|
+
const base = $("nHost").value.trim();
|
|
3086
|
+
let card;
|
|
3087
|
+
let file;
|
|
3088
|
+
let state;
|
|
3089
|
+
if (base) {
|
|
3090
|
+
const home = await hostHome(checkHost(base));
|
|
3091
|
+
const reg = await rc.register(rk, realm, username, $("nPassword").value, "Password", home, now(), rc.KDF_DEFAULT, random);
|
|
3092
|
+
await http("POST", `${apiRoot(home.url)}/id/register`, { log: reg.log, vault: reg.vault, public: reg.public });
|
|
3093
|
+
const v = await http("GET", `${apiRoot(home.url)}/id/${reg.account}/log`);
|
|
3094
|
+
state = await rc.verifyLog(v.events, { realms: [realm], now: now() });
|
|
3095
|
+
card = reg.card;
|
|
3096
|
+
file = rc.exportAccount(state, v.events, reg.public, reg.vault);
|
|
3097
|
+
} else {
|
|
3098
|
+
const keys = await rc.deriveRecoveryKeys(rk, realm, username);
|
|
3099
|
+
const inc = await rc.inception(keys, realm, username);
|
|
3100
|
+
state = await rc.verifyLog([inc], { realms: [realm] });
|
|
3101
|
+
card = await rc.recoveryCard(realm, username, state.account, rk);
|
|
3102
|
+
file = rc.exportAccount(state, [inc]);
|
|
3103
|
+
}
|
|
3104
|
+
rk.fill(0);
|
|
3105
|
+
download(`${username}-${rc.tag(state.account)}.card.json`, card);
|
|
3106
|
+
download(rc.accountFileName(state), file);
|
|
3107
|
+
return (await rc.cardText(card)) + "\n" + describe(state) + "\n\nThe card and the account file were downloaded. Keep the card offline.";
|
|
3108
|
+
}),
|
|
3109
|
+
);
|
|
3110
|
+
$("verify").addEventListener("click", () =>
|
|
3111
|
+
run(async () => {
|
|
3112
|
+
const v = rc.parseJson($("vLog").value);
|
|
3113
|
+
const log = Array.isArray(v) ? v : v.log;
|
|
3114
|
+
const realm = log?.[0]?.body?.realm;
|
|
3115
|
+
const st = await rc.verifyProof(rc.parseJson($("vProof").value), log, await challengeOf($("vStatement").value), $("vRecipient").value, { realms: [realm], now: now() });
|
|
3116
|
+
return `Valid proof by ${st.handle}\n\n` + describe(st);
|
|
3117
|
+
}),
|
|
3118
|
+
);
|
|
3119
|
+
|
|
3120
|
+
})();
|
|
3121
|
+
</script>
|
|
3122
|
+
</body>
|
|
3123
|
+
</html>
|