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 +8 -0
- package/keytool.html +127 -59
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}),
|