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/src/crypto.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Cryptographic primitives (SPEC §4) over WebCrypto, and domain separation (SPEC §5).
|
|
2
|
+
// Private keys are imported non-extractable for signing and agreement; raw secret buffers are overwritten by the
|
|
3
|
+
// callers that own them ([IMP-2]).
|
|
4
|
+
|
|
5
|
+
import { fail } from "./errors.js";
|
|
6
|
+
import { canonical } from "./json.js";
|
|
7
|
+
import { concat, utf8 } from "./encoding.js";
|
|
8
|
+
|
|
9
|
+
const subtle = globalThis.crypto?.subtle;
|
|
10
|
+
|
|
11
|
+
let support;
|
|
12
|
+
/** Check that WebCrypto has Ed25519 and X25519 ([IMP-3]). → unsupported_browser */
|
|
13
|
+
export async function checkSupport() {
|
|
14
|
+
if (support === undefined) {
|
|
15
|
+
support = (async () => {
|
|
16
|
+
if (!subtle) return false;
|
|
17
|
+
try {
|
|
18
|
+
await subtle.importKey("raw", new Uint8Array(32).fill(9), { name: "X25519" }, true, []);
|
|
19
|
+
const k = await subtle.importKey("raw", ED_TEST_KEY, { name: "Ed25519" }, true, ["verify"]);
|
|
20
|
+
return !!k;
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
})();
|
|
25
|
+
}
|
|
26
|
+
if (!(await support)) fail("unsupported_browser");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The Ed25519 public key of RFC 8032 test 1, used only to probe for support.
|
|
30
|
+
const ED_TEST_KEY = new Uint8Array([
|
|
31
|
+
0xd7, 0x5a, 0x98, 0x01, 0x82, 0xb1, 0x0a, 0xb7, 0xd5, 0x4b, 0xfe, 0xd3, 0xc9, 0x64, 0x07, 0x3a, 0x0e, 0xe1, 0x72,
|
|
32
|
+
0xf3, 0xda, 0xa6, 0x23, 0x25, 0xaf, 0x02, 0x1a, 0x68, 0xf7, 0x07, 0x51, 0x1a,
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
/** `frame(label, m) = utf8(label) ‖ 0x00 ‖ m` ([DS-1]). */
|
|
36
|
+
export function frame(label, m) {
|
|
37
|
+
return concat(utf8(label), new Uint8Array([0]), m);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `frame(label, canonical(x))`. */
|
|
41
|
+
export function frameObject(label, x) {
|
|
42
|
+
return frame(label, canonical(x));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** SHA-256. */
|
|
46
|
+
export async function sha256(m) {
|
|
47
|
+
return new Uint8Array(await subtle.digest("SHA-256", m));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** HKDF-SHA256 with a 32-byte output ([CRY-5]). */
|
|
51
|
+
export async function hkdf(ikm, salt, info) {
|
|
52
|
+
const key = await subtle.importKey("raw", ikm, "HKDF", false, ["deriveBits"]);
|
|
53
|
+
return new Uint8Array(await subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt, info }, key, 256));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const ED_PKCS8 = new Uint8Array([0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20]);
|
|
57
|
+
const X_PKCS8 = new Uint8Array([0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x6e, 0x04, 0x22, 0x04, 0x20]);
|
|
58
|
+
|
|
59
|
+
function b64uToBytes(s) {
|
|
60
|
+
const b = atob(s.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (s.length % 4)) % 4));
|
|
61
|
+
return Uint8Array.from(b, (c) => c.charCodeAt(0));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function importPrivate(prefix, alg, secret, extractable, usages) {
|
|
65
|
+
const der = concat(prefix, secret);
|
|
66
|
+
try {
|
|
67
|
+
return await subtle.importKey("pkcs8", der, { name: alg }, extractable, usages);
|
|
68
|
+
} finally {
|
|
69
|
+
der.fill(0);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function publicOf(prefix, alg, secret, usages) {
|
|
74
|
+
const k = await importPrivate(prefix, alg, secret, true, usages);
|
|
75
|
+
const jwk = await subtle.exportKey("jwk", k);
|
|
76
|
+
const x = b64uToBytes(jwk.x);
|
|
77
|
+
jwk.d = "";
|
|
78
|
+
return x;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The Ed25519 public key of a 32-byte seed ([CRY-2]). */
|
|
82
|
+
export async function ed25519Public(seed) {
|
|
83
|
+
return publicOf(ED_PKCS8, "Ed25519", seed, ["sign"]);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Import an Ed25519 seed as a non-extractable signing key ([IMP-2]). */
|
|
87
|
+
export async function ed25519SigningKey(seed) {
|
|
88
|
+
return importPrivate(ED_PKCS8, "Ed25519", seed, false, ["sign"]);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A deterministic Ed25519 signature ([CRY-8]). `key` is a seed or a key from ed25519SigningKey. */
|
|
92
|
+
export async function ed25519Sign(key, m) {
|
|
93
|
+
const k = key instanceof Uint8Array ? await ed25519SigningKey(key) : key;
|
|
94
|
+
return new Uint8Array(await subtle.sign("Ed25519", k, m));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// L, p and the small-order encodings of [CRY-3].
|
|
98
|
+
const L = 2n ** 252n + 27742317777372353535851937790883648493n;
|
|
99
|
+
const P = 2n ** 255n - 19n;
|
|
100
|
+
const SMALL_ORDER = [
|
|
101
|
+
"0100000000000000000000000000000000000000000000000000000000000000",
|
|
102
|
+
"ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f",
|
|
103
|
+
"0000000000000000000000000000000000000000000000000000000000000000",
|
|
104
|
+
"0000000000000000000000000000000000000000000000000000000000000080",
|
|
105
|
+
"26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05",
|
|
106
|
+
"26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc85",
|
|
107
|
+
"c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a",
|
|
108
|
+
"c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa",
|
|
109
|
+
];
|
|
110
|
+
const NEG_ZERO = [
|
|
111
|
+
"0100000000000000000000000000000000000000000000000000000000000080",
|
|
112
|
+
"ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
function le(bytes) {
|
|
116
|
+
let n = 0n;
|
|
117
|
+
for (let i = bytes.length - 1; i >= 0; i--) n = (n << 8n) | BigInt(bytes[i]);
|
|
118
|
+
return n;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function hex(b) {
|
|
122
|
+
let s = "";
|
|
123
|
+
for (const x of b) s += x.toString(16).padStart(2, "0");
|
|
124
|
+
return s;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** [CRY-3] steps 3 and 4 for an encoded point: canonical and not of small order (also [KEY-3]). */
|
|
128
|
+
export function ed25519PointOk(e) {
|
|
129
|
+
if (e.length !== 32) return false;
|
|
130
|
+
const y = e.slice();
|
|
131
|
+
y[31] &= 0x7f;
|
|
132
|
+
if (le(y) >= P) return false;
|
|
133
|
+
const h = hex(e);
|
|
134
|
+
return !NEG_ZERO.includes(h) && !SMALL_ORDER.includes(h);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Strict Ed25519 verification ([CRY-3]). → bad_signature */
|
|
138
|
+
export async function ed25519Verify(publicKey, m, sig) {
|
|
139
|
+
if (publicKey.length !== 32 || sig.length !== 64) fail("bad_signature");
|
|
140
|
+
const r = sig.subarray(0, 32);
|
|
141
|
+
const s = sig.subarray(32);
|
|
142
|
+
if (le(s) >= L || !ed25519PointOk(publicKey) || !ed25519PointOk(r)) fail("bad_signature");
|
|
143
|
+
let ok = false;
|
|
144
|
+
try {
|
|
145
|
+
const k = await subtle.importKey("raw", publicKey, { name: "Ed25519" }, false, ["verify"]);
|
|
146
|
+
ok = await subtle.verify("Ed25519", k, sig, m);
|
|
147
|
+
} catch {
|
|
148
|
+
ok = false;
|
|
149
|
+
}
|
|
150
|
+
if (!ok) fail("bad_signature");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The X25519 public key of a 32-byte secret ([CRY-2]). */
|
|
154
|
+
export async function x25519Public(secret) {
|
|
155
|
+
return publicOf(X_PKCS8, "X25519", secret, ["deriveBits"]);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** X25519 key agreement; an all-zero result fails ([CRY-4]). → bad_key */
|
|
159
|
+
export async function x25519(secret, publicKey) {
|
|
160
|
+
let bits;
|
|
161
|
+
try {
|
|
162
|
+
const priv = await importPrivate(X_PKCS8, "X25519", secret, false, ["deriveBits"]);
|
|
163
|
+
const pub = await subtle.importKey("raw", publicKey, { name: "X25519" }, false, []);
|
|
164
|
+
bits = new Uint8Array(await subtle.deriveBits({ name: "X25519", public: pub }, priv, 256));
|
|
165
|
+
} catch {
|
|
166
|
+
fail("bad_key");
|
|
167
|
+
}
|
|
168
|
+
if (bits.every((b) => b === 0)) fail("bad_key");
|
|
169
|
+
return bits;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** AES-256-GCM encryption: ciphertext ‖ tag ([CRY-7]). */
|
|
173
|
+
export async function aesGcmEncrypt(key, nonce, plaintext, aad) {
|
|
174
|
+
const k = await subtle.importKey("raw", key, "AES-GCM", false, ["encrypt"]);
|
|
175
|
+
return new Uint8Array(await subtle.encrypt({ name: "AES-GCM", iv: nonce, additionalData: aad, tagLength: 128 }, k, plaintext));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** AES-256-GCM decryption. → unlock_failed */
|
|
179
|
+
export async function aesGcmDecrypt(key, nonce, ct, aad) {
|
|
180
|
+
const k = await subtle.importKey("raw", key, "AES-GCM", false, ["decrypt"]);
|
|
181
|
+
try {
|
|
182
|
+
return new Uint8Array(await subtle.decrypt({ name: "AES-GCM", iv: nonce, additionalData: aad, tagLength: 128 }, k, ct));
|
|
183
|
+
} catch {
|
|
184
|
+
fail("unlock_failed");
|
|
185
|
+
}
|
|
186
|
+
}
|
package/src/encoding.js
ADDED
|
@@ -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
CHANGED
|
@@ -1,22 +1,34 @@
|
|
|
1
1
|
// rootcard: a portable, self-certifying identity for online logins.
|
|
2
2
|
//
|
|
3
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
|
|
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".
|
|
5
6
|
//
|
|
6
|
-
// Status:
|
|
7
|
+
// Status: 0.x. Nothing here is stable before 1.0.0.
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
export
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
export
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
9
|
+
export { FORMAT_VERSION, LABEL_PREFIX, label } from "./labels.js";
|
|
10
|
+
export { API_VERSION, checkBaseUrl, isVersionSegment } from "./binding.js";
|
|
11
|
+
export { ERROR_CODES, RootcardError } from "./errors.js";
|
|
12
|
+
export { canonical, canonicalString, parseJson, parseLimited, validate } from "./json.js";
|
|
13
|
+
export {
|
|
14
|
+
base64urlEncode, base64urlDecode, base32Encode, base32Decode, base32DecodeLenient, hexEncode, hexDecode,
|
|
15
|
+
} from "./encoding.js";
|
|
16
|
+
export { checkSupport, sha256, ed25519Public, ed25519Sign, ed25519Verify, ed25519SigningKey } from "./crypto.js";
|
|
17
|
+
export { formatKey, parseKey } from "./keys.js";
|
|
18
|
+
export { formatRecoveryKey, parseRecoveryKey, deriveRecoveryKeys } from "./recovery.js";
|
|
19
|
+
export {
|
|
20
|
+
checkRealm, checkUsername, defaultUsernamePolicy, similarUsernames, accountId, inception, tag, handle, parseHandle,
|
|
21
|
+
handleMatches, extendedTags,
|
|
22
|
+
} from "./id.js";
|
|
23
|
+
export {
|
|
24
|
+
MAX_EVENTS, verifyLog, extendLog, verifyPending, compareLogs, eventHash, eventMessage, receiptMessage, unsignedEvent,
|
|
25
|
+
signEvent, addReceipt, pendingInstance, rotateRecoveryEvent, retireEvent, checkRecoveryKey, checkUrl,
|
|
26
|
+
} from "./log.js";
|
|
27
|
+
export { verifyVault, vaultChange, KDF_DEFAULT, KDF_MIN } from "./vaultcheck.js";
|
|
28
|
+
export { checkDataKey, setData, removeData, signPublic, verifyPublic } from "./profile.js";
|
|
29
|
+
export {
|
|
30
|
+
parseChallenge, loginMessage, fetchMessage, changeMessage, signLogin, parseLoginRequest, verifyLogin,
|
|
31
|
+
} from "./messages.js";
|
|
32
|
+
export { statementChallenge, prove, verifyProof } from "./proof.js";
|
|
33
|
+
export { exportAccount, importAccount, accountFileName, recoveryCard, readCard, cardText } from "./file.js";
|
|
34
|
+
export { systemRandom } from "./testing.js";
|