rootcard 0.0.0-stage → 0.1.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.
@@ -0,0 +1,145 @@
1
+ // Byte encodings (SPEC §3): unpadded base64url, Crockford base32, hex and UTF-8.
2
+
3
+ import { fail } from "./errors.js";
4
+
5
+ const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
6
+ const B64_VALUE = new Map([...B64].map((c, i) => [c, i]));
7
+
8
+ /** Unpadded base64url ([B64-1]). @param {Uint8Array} bytes */
9
+ export function base64urlEncode(bytes) {
10
+ let out = "";
11
+ for (let i = 0; i < bytes.length; i += 3) {
12
+ const n = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0);
13
+ const chars = Math.min(3, bytes.length - i) + 1;
14
+ for (let j = 0; j < chars; j++) out += B64[(n >>> (18 - 6 * j)) & 63];
15
+ }
16
+ return out;
17
+ }
18
+
19
+ /** Strict unpadded base64url decoding ([B64-2]). → bad_base64 */
20
+ export function base64urlDecode(text) {
21
+ if (typeof text !== "string" || text.length % 4 === 1) fail("bad_base64");
22
+ const out = new Uint8Array(Math.floor((text.length * 3) / 4));
23
+ let o = 0;
24
+ for (let i = 0; i < text.length; i += 4) {
25
+ const chunk = text.slice(i, i + 4);
26
+ let n = 0;
27
+ for (let j = 0; j < chunk.length; j++) {
28
+ const v = B64_VALUE.get(chunk[j]);
29
+ if (v === undefined) fail("bad_base64");
30
+ n |= v << (18 - 6 * j);
31
+ }
32
+ const len = chunk.length - 1;
33
+ const unused = len === 1 ? n & 0xffff : len === 2 ? n & 0xff : 0;
34
+ if (unused !== 0) fail("bad_base64");
35
+ const b = [(n >>> 16) & 255, (n >>> 8) & 255, n & 255];
36
+ for (let j = 0; j < len; j++) out[o++] = b[j];
37
+ }
38
+ return out;
39
+ }
40
+
41
+ /** The Crockford base32 alphabet ([B32-1]). */
42
+ export const B32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
43
+
44
+ /** Crockford base32 ([B32-2]). */
45
+ export function base32Encode(bytes) {
46
+ let out = "";
47
+ let acc = 0;
48
+ let bits = 0;
49
+ for (const b of bytes) {
50
+ acc = ((acc << 8) | b) >>> 0;
51
+ bits += 8;
52
+ while (bits >= 5) {
53
+ bits -= 5;
54
+ out += B32[(acc >>> bits) & 31];
55
+ }
56
+ acc &= (1 << bits) - 1;
57
+ }
58
+ if (bits > 0) out += B32[(acc << (5 - bits)) & 31];
59
+ return out;
60
+ }
61
+
62
+ /** Strict Crockford base32 decoding of exactly `len` bytes ([B32-3]). → bad_base32 */
63
+ export function base32Decode(text, len) {
64
+ if (typeof text !== "string" || text.length !== Math.ceil((len * 8) / 5)) fail("bad_base32");
65
+ const out = new Uint8Array(len);
66
+ let o = 0;
67
+ let acc = 0;
68
+ let bits = 0;
69
+ for (const c of text) {
70
+ const v = B32.indexOf(c);
71
+ if (v < 0 || c.length !== 1) fail("bad_base32");
72
+ acc = (acc << 5) | v;
73
+ bits += 5;
74
+ if (bits >= 8) {
75
+ bits -= 8;
76
+ out[o++] = (acc >>> bits) & 255;
77
+ acc &= (1 << bits) - 1;
78
+ }
79
+ }
80
+ if (acc !== 0) fail("bad_base32");
81
+ return out;
82
+ }
83
+
84
+ /** Lenient normalization of typed base32 ([B32-4]). */
85
+ export function base32Normalize(text) {
86
+ let out = "";
87
+ for (const c of text) {
88
+ if (c === "-" || c === " ") continue;
89
+ const u = c.length === 1 && c >= "a" && c <= "z" ? c.toUpperCase() : c;
90
+ out += u === "I" || u === "L" ? "1" : u === "O" ? "0" : u;
91
+ }
92
+ return out;
93
+ }
94
+
95
+ /** Lenient decoding for human input ([B32-4]). → bad_base32 */
96
+ export function base32DecodeLenient(text, len) {
97
+ return base32Decode(base32Normalize(text), len);
98
+ }
99
+
100
+ /** Lower-case hexadecimal. */
101
+ export function hexEncode(bytes) {
102
+ let s = "";
103
+ for (const b of bytes) s += b.toString(16).padStart(2, "0");
104
+ return s;
105
+ }
106
+
107
+ /** Hexadecimal to bytes. → bad_field */
108
+ export function hexDecode(text) {
109
+ if (text.length % 2 || !/^[0-9a-fA-F]*$/.test(text)) fail("bad_field");
110
+ const out = new Uint8Array(text.length / 2);
111
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(text.slice(2 * i, 2 * i + 2), 16);
112
+ return out;
113
+ }
114
+
115
+ const te = new TextEncoder();
116
+ /** UTF-8 bytes of a string. */
117
+ export function utf8(s) {
118
+ return te.encode(s);
119
+ }
120
+
121
+ /** Concatenate byte arrays. */
122
+ export function concat(...parts) {
123
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
124
+ let o = 0;
125
+ for (const p of parts) {
126
+ out.set(p, o);
127
+ o += p.length;
128
+ }
129
+ return out;
130
+ }
131
+
132
+ /** Byte equality (not constant time; use ctEqual for secrets). */
133
+ export function bytesEqual(a, b) {
134
+ if (a.length !== b.length) return false;
135
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
136
+ return true;
137
+ }
138
+
139
+ /** Constant-time equality of two byte arrays ([IMP-4]). */
140
+ export function ctEqual(a, b) {
141
+ if (a.length !== b.length) return false;
142
+ let d = 0;
143
+ for (let i = 0; i < a.length; i++) d |= a[i] ^ b[i];
144
+ return d === 0;
145
+ }
package/src/errors.js ADDED
@@ -0,0 +1,38 @@
1
+ // Error codes (SPEC §20).
2
+
3
+ /** Every error code of SPEC §20, in its order. */
4
+ export const ERROR_CODES = Object.freeze([
5
+ "bad_json", "bad_field", "unsupported_version", "too_large", "bad_base64", "bad_base32", "bad_checksum", "bad_key",
6
+ "bad_signature", "bad_realm", "realm_not_accepted", "bad_username", "username_policy", "bad_handle", "handle_taken",
7
+ "ambiguous_handle", "account_mismatch", "wrong_recovery_key", "too_many_events", "bad_kind", "bad_seq", "bad_prev",
8
+ "time_backwards", "future_time", "bad_time", "wrong_signer", "key_reuse", "bad_receipt", "bad_reason",
9
+ "account_retired", "no_instance", "stale_version", "fork", "wrong_instance", "stale_rev", "stale_locks", "bad_locks",
10
+ "kdf_params", "weak_password", "unlock_failed", "wrong_account_key", "bad_data_key", "wrong_challenge",
11
+ "wrong_recipient", "challenge_unknown", "challenge_expired", "not_home", "wrong_host", "unknown_account",
12
+ "unauthorized", "rate_limited", "unsupported_browser",
13
+ ]);
14
+
15
+ /** A rootcard error. `code` is one of the stable codes of SPEC §20. */
16
+ export class RootcardError extends Error {
17
+ /** @param {string} code */
18
+ constructor(code) {
19
+ super(code);
20
+ this.name = "RootcardError";
21
+ this.code = code;
22
+ }
23
+ }
24
+
25
+ /** Throw the error `code`. @param {string} code @returns {never} */
26
+ export function fail(code) {
27
+ throw new RootcardError(code);
28
+ }
29
+
30
+ /** Run `f`, replacing any RootcardError by `code`. */
31
+ export function remap(code, f) {
32
+ try {
33
+ return f();
34
+ } catch (e) {
35
+ if (e instanceof RootcardError) fail(code);
36
+ throw e;
37
+ }
38
+ }
package/src/file.js ADDED
@@ -0,0 +1,118 @@
1
+ // The account file and the recovery card (SPEC §15).
2
+
3
+ import { fail } from "./errors.js";
4
+ import { canonical } from "./json.js";
5
+ import * as s from "./keys.js";
6
+ import { verifyLog } from "./log.js";
7
+ import { verifyPublic } from "./profile.js";
8
+ import { verifyVault } from "./vaultcheck.js";
9
+ import { accountId, inception, tag } from "./id.js";
10
+ import { deriveRecoveryKeys, formatRecoveryKey, parseRecoveryKey, recoverySignPublic } from "./recovery.js";
11
+ import { formatKey } from "./keys.js";
12
+
13
+ export const FILE_LIMIT = 5 * 1024 * 1024;
14
+ export const HOST_DATA_LIMIT = 16 * 1024;
15
+
16
+ /** Build an account file ([FIL-2]). */
17
+ export function exportAccount(state, log, publicProfile, vault, hostData) {
18
+ const f = {
19
+ format: "rootcard-account",
20
+ v: 1,
21
+ realm: state.realm,
22
+ account: state.account,
23
+ handle: state.handle,
24
+ version: { seq: state.version.seq, hash: state.version.hash },
25
+ log: [...log],
26
+ };
27
+ if (publicProfile !== undefined && publicProfile !== null) f.public = publicProfile;
28
+ if (vault !== undefined && vault !== null) f.vault = vault;
29
+ if (hostData !== undefined && hostData !== null) f.host_data = hostData;
30
+ return f;
31
+ }
32
+
33
+ /** The suggested file name ([FIL-1]). */
34
+ export function accountFileName(state) {
35
+ return `${state.username}-${tag(state.account)}.rootcard.json`;
36
+ }
37
+
38
+ /** Import an account file ([FIL-3]). Returns `{ state, log, public?, vault?, hostData? }`. */
39
+ export async function importAccount(file, options) {
40
+ if (canonical(file).length > FILE_LIMIT) fail("too_large");
41
+ const m = s.object(file);
42
+ if (m.format !== "rootcard-account") fail("bad_field");
43
+ s.version(m);
44
+ s.members(m, ["format", "v", "realm", "account", "handle", "version", "log"], ["public", "vault", "host_data"]);
45
+ const realm = s.string(m, "realm");
46
+ const account = s.account(m, "account");
47
+ const handle = s.string(m, "handle");
48
+ const version = s.versionRef(s.get(m, "version"));
49
+ const log = s.array(m, "log");
50
+ let hostData;
51
+ if (Object.hasOwn(m, "host_data")) {
52
+ hostData = s.object(m.host_data);
53
+ if (canonical(hostData).length > HOST_DATA_LIMIT) fail("too_large");
54
+ }
55
+ const state = await verifyLog(log, options);
56
+ if (state.account !== account || state.realm !== realm || state.handle !== handle ||
57
+ state.version.seq !== version.seq || state.version.hash !== version.hash) {
58
+ fail("account_mismatch");
59
+ }
60
+ const out = { state, log };
61
+ if (Object.hasOwn(m, "public")) out.public = { value: m.public, info: await verifyPublic(m.public, state) };
62
+ if (Object.hasOwn(m, "vault")) out.vault = { value: m.vault, info: await verifyVault(m.vault, state) };
63
+ if (hostData !== undefined) out.hostData = hostData;
64
+ return out;
65
+ }
66
+
67
+ /** Build a recovery card ([CRD-1], [CRD-2]). */
68
+ export async function recoveryCard(realm, username, account, key, log) {
69
+ const c = { format: "rootcard-card", v: 1, realm, username, account, key: await formatRecoveryKey(key) };
70
+ if (log) c.log = [...log];
71
+ return c;
72
+ }
73
+
74
+ /** Read a recovery card ([CRD-3]). Returns `{ realm, username, account, key, keys, log, state }`. */
75
+ export async function readCard(card) {
76
+ const m = s.object(card);
77
+ if (m.format !== "rootcard-card") fail("bad_field");
78
+ s.version(m);
79
+ s.members(m, ["format", "v", "realm", "username", "account", "key"], ["log"]);
80
+ const realm = s.string(m, "realm");
81
+ const username = s.string(m, "username");
82
+ const account = s.account(m, "account");
83
+ const keyText = s.string(m, "key");
84
+ const logValue = Object.hasOwn(m, "log") ? s.array(m, "log") : undefined;
85
+ const key = await parseRecoveryKey(keyText);
86
+ const keys = await deriveRecoveryKeys(key, realm, username);
87
+ const options = { realms: [realm] };
88
+ let log;
89
+ let state;
90
+ if (logValue === undefined) {
91
+ const inc = await inception(keys, realm, username);
92
+ if ((await accountId(inc)) !== account) fail("account_mismatch");
93
+ log = [inc];
94
+ state = await verifyLog(log, options);
95
+ } else {
96
+ log = logValue;
97
+ state = await verifyLog(log, options);
98
+ if (state.account !== account || state.realm !== realm || state.username !== username) fail("account_mismatch");
99
+ if (formatKey("ed25519", await recoverySignPublic(keys)) !== state.recovery) fail("wrong_recovery_key");
100
+ }
101
+ return { realm, username, account, key, keys, log, state };
102
+ }
103
+
104
+ /** The printable text form of a recovery card ([CRD-4]). */
105
+ export async function cardText(card) {
106
+ const c = await readCard(card);
107
+ const groups = (str) => str.match(/.{1,4}/g).join(" ");
108
+ const key = (await formatRecoveryKey(c.key)).slice(4).replace(/-/g, " ");
109
+ return (
110
+ "rootcard recovery card\n\n" +
111
+ `Handle: ${c.state.handle}\n` +
112
+ `Realm: ${c.realm}\n` +
113
+ `Account id: ${groups(c.account)}\n\n` +
114
+ `Recovery key:\n RCR ${key}\n\n` +
115
+ "Anyone who has this card controls the identity. Keep it offline and private.\n" +
116
+ "Only enter the recovery key in the rootcard key tool.\n"
117
+ );
118
+ }
package/src/id.js ADDED
@@ -0,0 +1,107 @@
1
+ // Realm, username, inception, account id (SPEC §7), handles and tags (SPEC §8).
2
+
3
+ import { fail } from "./errors.js";
4
+ import { B32, base32Encode, base64urlEncode } from "./encoding.js";
5
+ import { ed25519Public, ed25519Sign, frameObject, sha256 } from "./crypto.js";
6
+ import { without } from "./json.js";
7
+ import { formatKey } from "./keys.js";
8
+ import { label } from "./labels.js";
9
+
10
+ /** [RLM-1] → bad_realm */
11
+ export function checkRealm(realm) {
12
+ if (typeof realm !== "string" || !/^[a-z0-9]([a-z0-9._-]{0,62}[a-z0-9])?$/.test(realm)) fail("bad_realm");
13
+ }
14
+
15
+ /** [USR-1] → bad_username */
16
+ export function checkUsername(username) {
17
+ if (typeof username !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$/.test(username)) fail("bad_username");
18
+ }
19
+
20
+ /** The default username policy ([USR-3]): [USR-1], 3 to 20 characters, beginning with A–Z a–z. */
21
+ export function defaultUsernamePolicy(username) {
22
+ return /^[A-Za-z][A-Za-z0-9_-]{2,19}$/.test(username);
23
+ }
24
+
25
+ /** [USR-2] */
26
+ export function similarUsernames(a, b) {
27
+ return a.toLowerCase() === b.toLowerCase() && /^[\x00-\x7f]*$/.test(a + b);
28
+ }
29
+
30
+ /** The account id of an inception ([ID-3]). */
31
+ export async function accountId(inception) {
32
+ return base32Encode(await sha256(frameObject(label("id"), without(inception, "sigs"))));
33
+ }
34
+
35
+ /** The deterministic inception of an identity ([ID-1], [ID-2]). → bad_realm, bad_username */
36
+ export async function inception(keys, realm, username) {
37
+ checkRealm(realm);
38
+ checkUsername(username);
39
+ const recovery = formatKey("ed25519", await ed25519Public(keys.signSeed));
40
+ const unsigned = { v: 1, kind: "incept", seq: 0, body: { realm, username, recovery } };
41
+ const sig = await ed25519Sign(keys.signSeed, frameObject(label("event"), unsigned));
42
+ return { ...unsigned, sigs: [{ key: recovery, sig: base64urlEncode(sig) }] };
43
+ }
44
+
45
+ /** The tag of an account id ([HDL-1]). */
46
+ export function tag(account) {
47
+ return account.slice(0, 4);
48
+ }
49
+
50
+ /** `username + "#" + tag` ([HDL-1]). */
51
+ export function handle(username, account) {
52
+ return `${username}#${tag(account)}`;
53
+ }
54
+
55
+ function lenientChars(s) {
56
+ let out = "";
57
+ for (const c of s) {
58
+ const u = c.length === 1 && c >= "a" && c <= "z" ? c.toUpperCase() : c;
59
+ const m = u === "I" || u === "L" ? "1" : u === "O" ? "0" : u;
60
+ if (m.length !== 1 || !B32.includes(m)) return null;
61
+ out += m;
62
+ }
63
+ return out;
64
+ }
65
+
66
+ /** Parse a typed handle ([HDL-3]). → bad_handle */
67
+ export function parseHandle(text) {
68
+ const at = text.lastIndexOf("#");
69
+ if (at < 0) fail("bad_handle");
70
+ const username = text.slice(0, at);
71
+ try {
72
+ checkUsername(username);
73
+ } catch {
74
+ fail("bad_handle");
75
+ }
76
+ const t = text.slice(at + 1);
77
+ const dash = t.indexOf("-");
78
+ const t4 = lenientChars(dash < 0 ? t : t.slice(0, dash));
79
+ if (t4 === null || t4.length !== 4) fail("bad_handle");
80
+ const result = { username, tag: t4 };
81
+ if (dash >= 0) {
82
+ const e = lenientChars(t.slice(dash + 1));
83
+ if (e === null || e.length < 1 || e.length > 48) fail("bad_handle");
84
+ result.extension = e;
85
+ }
86
+ return result;
87
+ }
88
+
89
+ /** Whether an identity matches a parsed handle (similar username, [USR-2]). */
90
+ export function handleMatches(parsed, username, account) {
91
+ return similarUsernames(parsed.username, username) && account.startsWith(parsed.tag + (parsed.extension ?? ""));
92
+ }
93
+
94
+ /** The extended tags of colliding account ids ([HDL-6]), in the order given. */
95
+ export function extendedTags(ids) {
96
+ const distinct = [...new Set(ids)];
97
+ if (distinct.length < 2) return ids.map(tag);
98
+ const max = Math.min(...distinct.map((s) => s.length));
99
+ let n = 5;
100
+ while (n < max) {
101
+ const prefixes = distinct.map((s) => s.slice(0, n));
102
+ if (new Set(prefixes).size === prefixes.length) break;
103
+ n++;
104
+ }
105
+ n = Math.min(n, max);
106
+ return ids.map((id) => `${id.slice(0, 4)}-${id.slice(4, n)}`);
107
+ }
package/src/index.js ADDED
@@ -0,0 +1,33 @@
1
+ // rootcard: a portable, self-certifying identity for online logins.
2
+ //
3
+ // This package mirrors the `rootcard` Rust crate byte for byte. The normative format is spec/SPEC.md in the repository,
4
+ // and spec/vectors/ holds the shared test vectors both implementations reproduce. This module is the verification
5
+ // core; the vault cryptography (passwords, Argon2id, encryption) is in "rootcard/vault".
6
+ //
7
+ // Status: 0.x. Nothing here is stable before 1.0.0.
8
+
9
+ export { FORMAT_VERSION, LABEL_PREFIX, label } from "./labels.js";
10
+ export { ERROR_CODES, RootcardError } from "./errors.js";
11
+ export { canonical, canonicalString, parseJson, parseLimited, validate } from "./json.js";
12
+ export {
13
+ base64urlEncode, base64urlDecode, base32Encode, base32Decode, base32DecodeLenient, hexEncode, hexDecode,
14
+ } from "./encoding.js";
15
+ export { checkSupport, sha256, ed25519Public, ed25519Sign, ed25519Verify, ed25519SigningKey } from "./crypto.js";
16
+ export { formatKey, parseKey } from "./keys.js";
17
+ export { formatRecoveryKey, parseRecoveryKey, deriveRecoveryKeys } from "./recovery.js";
18
+ export {
19
+ checkRealm, checkUsername, defaultUsernamePolicy, similarUsernames, accountId, inception, tag, handle, parseHandle,
20
+ handleMatches, extendedTags,
21
+ } from "./id.js";
22
+ export {
23
+ MAX_EVENTS, verifyLog, extendLog, verifyPending, compareLogs, eventHash, eventMessage, receiptMessage, unsignedEvent,
24
+ signEvent, addReceipt, pendingInstance, rotateRecoveryEvent, retireEvent, checkRecoveryKey, checkUrl,
25
+ } from "./log.js";
26
+ export { verifyVault, vaultChange, KDF_DEFAULT, KDF_MIN } from "./vaultcheck.js";
27
+ export { checkDataKey, setData, removeData, signPublic, verifyPublic } from "./profile.js";
28
+ export {
29
+ parseChallenge, loginMessage, fetchMessage, changeMessage, signLogin, parseLoginRequest, verifyLogin,
30
+ } from "./messages.js";
31
+ export { statementChallenge, prove, verifyProof } from "./proof.js";
32
+ export { exportAccount, importAccount, accountFileName, recoveryCard, readCard, cardText } from "./file.js";
33
+ export { systemRandom } from "./testing.js";