rootcard 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@ All notable changes to this package are recorded here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.2.1 - 2026-11-05
8
+
9
+ - **Fixed:** `keytool.html` 0.2.0 refused every host address ("not a host base URL"), because the page did not include
10
+ `checkBaseUrl` and `API_VERSION`. A test now checks that the page has every function the package exports.
11
+ - **Fixed (loss of the recovery key):** the key tool page sent a recovery key rotation before it downloaded the new
12
+ card, and registered at a host before it downloaded the card. Both cards are now downloaded and shown first; if a
13
+ rotation cannot be sent, the page says to keep both cards.
14
+
7
15
  ## 0.2.0 - 2026-11-05
8
16
 
9
17
  - License: MIT OR Apache-2.0, at the user's option (0.1.0 was MIT only). The package now ships `LICENSE-MIT` and
package/keytool.html CHANGED
@@ -110,7 +110,7 @@
110
110
  <script>
111
111
  "use strict";
112
112
  (() => {
113
- const ROOTCARD_VERSION = "0.2.0";
113
+ const ROOTCARD_VERSION = "0.2.1";
114
114
  const __modules = {};
115
115
  __modules["errors.js"] = (() => {
116
116
  // Error codes (SPEC §20).
@@ -936,59 +936,6 @@ function versionRef(v) {
936
936
 
937
937
  return { formatKey, parseKey, object, version, members, get, string, int, intIn, bool, member, array, bytes, key, sig, account, checkAccountId, versionRef };
938
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
939
  __modules["id.js"] = (() => {
993
940
  // Realm, username, inception, account id (SPEC §7), handles and tags (SPEC §8).
994
941
 
@@ -1420,6 +1367,112 @@ function hashBytes(h) {
1420
1367
 
1421
1368
  return { MAX_EVENTS, EVENT_LIMIT, FUTURE_WINDOW, eventMessage, receiptMessage, eventHash, checkUrl, verifyLog, extendLog, verifyPending, compareLogs, unsignedEvent, signEvent, addReceipt, checkRecoveryKey, pendingInstance, rotateRecoveryEvent, retireEvent, hashBytes };
1422
1369
  })();
1370
+ __modules["binding.js"] = (() => {
1371
+ // The HTTP binding's version and base URLs (HOST_PROTOCOL.md §1, §1.3; SPEC [HST-22]). The core makes no requests;
1372
+ // these let hosts, clients and the key tool agree on where endpoints live. Mirrors `rootcard::binding`.
1373
+
1374
+ const { fail } = __modules["errors.js"];
1375
+ const { checkUrl } = __modules["log.js"];
1376
+
1377
+ /**
1378
+ * The version segment of the HTTP binding: every endpoint is under `<base URL>/v0` until 1.0.0, then under `/v1`.
1379
+ * It is never part of a base URL, `home.url` or anything signed.
1380
+ */
1381
+ const API_VERSION = "v0";
1382
+
1383
+ /** Whether a path segment has the form of an API version (`v0`, `V12`). */
1384
+ function isVersionSegment(segment) {
1385
+ return /^[vV][0-9]+$/.test(segment);
1386
+ }
1387
+
1388
+ function authorityHost(rest) {
1389
+ const authority = rest.split("/")[0];
1390
+ if (authority.startsWith("[")) {
1391
+ const i = authority.indexOf("]");
1392
+ return i < 0 ? "" : authority.slice(0, i + 1);
1393
+ }
1394
+ return authority.split(":")[0];
1395
+ }
1396
+
1397
+ /**
1398
+ * Check a base URL ([HST-22]): `https://` and a host, an optional port and an optional path without a trailing slash,
1399
+ * query, fragment or user info; plain `http://` only for `localhost`, `127.0.0.1` and `[::1]`; and its last path
1400
+ * segment is not an API version segment. → `bad_field`
1401
+ */
1402
+ function checkBaseUrl(url) {
1403
+ checkUrl(url);
1404
+ let rest;
1405
+ if (url.startsWith("https://")) rest = url.slice(8);
1406
+ else if (url.startsWith("http://")) {
1407
+ rest = url.slice(7);
1408
+ if (!["localhost", "127.0.0.1", "[::1]"].includes(authorityHost(rest))) fail("bad_field");
1409
+ } else fail("bad_field");
1410
+ if (/[?#@\\]/.test(rest)) fail("bad_field");
1411
+ const slash = rest.indexOf("/");
1412
+ const authority = slash < 0 ? rest : rest.slice(0, slash);
1413
+ if (authorityHost(authority) === "" || !/^[A-Za-z0-9.\-:[\]]+$/.test(authority)) fail("bad_field");
1414
+ if (slash >= 0) {
1415
+ const segments = rest.slice(slash + 1).split("/");
1416
+ if (segments.some((x) => x === "")) fail("bad_field");
1417
+ if (isVersionSegment(segments[segments.length - 1])) fail("bad_field");
1418
+ }
1419
+ }
1420
+
1421
+ return { API_VERSION, isVersionSegment, checkBaseUrl };
1422
+ })();
1423
+ __modules["recovery.js"] = (() => {
1424
+ // The recovery key (SPEC §6).
1425
+
1426
+ const { fail } = __modules["errors.js"];
1427
+ const { B32, base32Decode, base32Encode, base32Normalize, bytesEqual } = __modules["encoding.js"];
1428
+ const { ed25519Public, frame, frameObject, hkdf, sha256 } = __modules["crypto.js"];
1429
+ const { label } = __modules["labels.js"];
1430
+
1431
+ async function checksum(key) {
1432
+ return base32Encode(await sha256(frame(label("recovery/check"), key))).slice(0, 4);
1433
+ }
1434
+
1435
+ /** The written form `RCR-XXXX-…-CCCC` ([RK-1]). */
1436
+ async function formatRecoveryKey(key) {
1437
+ const body = base32Encode(key) + (await checksum(key));
1438
+ const groups = [];
1439
+ for (let i = 0; i < 14; i++) groups.push(body.slice(4 * i, 4 * i + 4));
1440
+ return "RCR-" + groups.join("-");
1441
+ }
1442
+
1443
+ /** Parse a typed recovery key ([RK-2]). → bad_base32, bad_checksum */
1444
+ async function parseRecoveryKey(text) {
1445
+ let s = [...text].filter((c) => c !== "-" && c !== " ").join("");
1446
+ if ([...s].length === 59 && s.slice(0, 3).toUpperCase() === "RCR") s = s.slice(3);
1447
+ if (s.length !== 56 || !/^[\x00-\x7f]*$/.test(s)) fail("bad_base32");
1448
+ const n = base32Normalize(s);
1449
+ for (const c of n) if (!B32.includes(c)) fail("bad_base32");
1450
+ const key = base32Decode(n.slice(0, 52), 32);
1451
+ if (n.slice(52) !== (await checksum(key))) fail("bad_checksum");
1452
+ return key;
1453
+ }
1454
+
1455
+ /**
1456
+ * Derive an identity's recovery keys ([RK-4]): `signSeed` (Ed25519) and `boxSecret` (X25519).
1457
+ * @returns {Promise<{ signSeed: Uint8Array, boxSecret: Uint8Array }>}
1458
+ */
1459
+ async function deriveRecoveryKeys(rk, realm, username) {
1460
+ const ctx = { realm, username };
1461
+ return {
1462
+ signSeed: await hkdf(rk, new Uint8Array(0), frameObject(label("recovery/sign"), ctx)),
1463
+ boxSecret: await hkdf(rk, new Uint8Array(0), frameObject(label("recovery/box"), ctx)),
1464
+ };
1465
+ }
1466
+
1467
+ /** The public recovery signing key. */
1468
+ async function recoverySignPublic(keys) {
1469
+ return ed25519Public(keys.signSeed);
1470
+ }
1471
+
1472
+
1473
+
1474
+ return { bytesEqual, formatRecoveryKey, parseRecoveryKey, deriveRecoveryKeys, recoverySignPublic };
1475
+ })();
1423
1476
  __modules["vaultcheck.js"] = (() => {
1424
1477
  // The vault's structure and signatures (SPEC §11), without any decryption: what hosts need ([VLT-3], [VLT-4]).
1425
1478
 
@@ -2770,8 +2823,8 @@ return { testRandom, systemRandom };
2770
2823
  // user enters ([KT-1]) and gathers the newest log before signing anything ([KT-2]).
2771
2824
 
2772
2825
  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"]) {
2826
+ for (const name of ["errors.js", "json.js", "encoding.js", "labels.js", "binding.js", "crypto.js", "keys.js", "recovery.js", "id.js", "log.js",
2827
+ "vaultcheck.js", "profile.js", "messages.js", "proof.js", "file.js", "argon2.js", "vault.js", "account.js", "testing.js"]) {
2775
2828
  Object.assign(rc, __modules[name]);
2776
2829
  }
2777
2830
  const $ = (id) => document.getElementById(id);
@@ -3034,10 +3087,21 @@ const ops = {
3034
3087
  const { vault, u } = await currentVault();
3035
3088
  const newKey = random(32);
3036
3089
  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 });
3090
+ newKey.fill(0);
3091
+ // The new card is saved and shown before the rotation is sent: once the host has the event, the old key no longer
3092
+ // signs and the new card is the only copy of the new key ([KT-5]).
3038
3093
  download(`${current.state.username}-new.card.json`, r.card);
3039
3094
  const text = await rc.cardText(r.card);
3040
- newKey.fill(0);
3095
+ show(text + "\nSending the rotation… Keep this card.", true);
3096
+ try {
3097
+ await http("POST", `${apiRoot(current.state.instance.home.url)}/id/${current.state.account}/events`, { events: [r.event], vault: r.vault });
3098
+ } catch (e) {
3099
+ throw new Error(
3100
+ `${explain(e)}\n\nThe rotation may or may not have reached the primary host. Keep both cards: the new card ` +
3101
+ `below (also downloaded) and the old one. Load the identity with the old card: if it answers that the ` +
3102
+ `recovery key is not the current one, the rotation arrived and the new card is current.\n\n${text}`,
3103
+ );
3104
+ }
3041
3105
  current = null;
3042
3106
  $("actions").hidden = true;
3043
3107
  return text + "\nThe new card was downloaded. Destroy the old card, then load the new one.";
@@ -3089,6 +3153,10 @@ $("create").addEventListener("click", () =>
3089
3153
  if (base) {
3090
3154
  const home = await hostHome(checkHost(base));
3091
3155
  const reg = await rc.register(rk, realm, username, $("nPassword").value, "Password", home, now(), rc.KDF_DEFAULT, random);
3156
+ rk.fill(0);
3157
+ // The card is saved before anything is sent: once the host has the identity, the card is the only copy of its
3158
+ // recovery key ([CLT-1]).
3159
+ download(`${username}-${rc.tag(reg.account)}.card.json`, reg.card);
3092
3160
  await http("POST", `${apiRoot(home.url)}/id/register`, { log: reg.log, vault: reg.vault, public: reg.public });
3093
3161
  const v = await http("GET", `${apiRoot(home.url)}/id/${reg.account}/log`);
3094
3162
  state = await rc.verifyLog(v.events, { realms: [realm], now: now() });
@@ -3102,7 +3170,7 @@ $("create").addEventListener("click", () =>
3102
3170
  file = rc.exportAccount(state, [inc]);
3103
3171
  }
3104
3172
  rk.fill(0);
3105
- download(`${username}-${rc.tag(state.account)}.card.json`, card);
3173
+ if (!base) download(`${username}-${rc.tag(state.account)}.card.json`, card);
3106
3174
  download(rc.accountFileName(state), file);
3107
3175
  return (await rc.cardText(card)) + "\n" + describe(state) + "\n\nThe card and the account file were downloaded. Keep the card offline.";
3108
3176
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rootcard",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "A portable, self-certifying identity for online logins (core library, zero dependencies, WebCrypto)",
5
5
  "keywords": [
6
6
  "identity",