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.
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +23 -2
- package/keytool.html +3106 -0
- package/package.json +42 -4
- package/src/account.js +89 -0
- package/src/argon2.js +239 -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 +33 -0
- 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/src/log.js
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// The identity log (SPEC §9) and versions (SPEC §10). A log state is plain JSON, identical to the Rust crate's
|
|
2
|
+
// `LogState::to_value`, so that hosts can store it ([LOG-16]).
|
|
3
|
+
|
|
4
|
+
import { fail } from "./errors.js";
|
|
5
|
+
import { base64urlEncode, base64urlDecode, bytesEqual } from "./encoding.js";
|
|
6
|
+
import { ed25519Public, ed25519Sign, ed25519Verify, frameObject, sha256 } from "./crypto.js";
|
|
7
|
+
import { canonical, without, isObject, MAX_SAFE } from "./json.js";
|
|
8
|
+
import * as s from "./keys.js";
|
|
9
|
+
import { formatKey } from "./keys.js";
|
|
10
|
+
import { accountId, checkRealm, checkUsername, handle } from "./id.js";
|
|
11
|
+
import { label } from "./labels.js";
|
|
12
|
+
|
|
13
|
+
export const MAX_EVENTS = 1000;
|
|
14
|
+
export const EVENT_LIMIT = 4096;
|
|
15
|
+
export const FUTURE_WINDOW = 600;
|
|
16
|
+
const REASONS = ["register", "revoke", "restore", "fresh"];
|
|
17
|
+
|
|
18
|
+
/** The message owners sign on an event ([LOG-6]). */
|
|
19
|
+
export function eventMessage(e) {
|
|
20
|
+
return frameObject(label("event"), without(e, "sigs"));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The message the primary host signs as its receipt ([LOG-8]). */
|
|
24
|
+
export function receiptMessage(e) {
|
|
25
|
+
return frameObject(label("receipt"), without(e, "sigs"));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The event hash ([LOG-5]). */
|
|
29
|
+
export async function eventHash(e) {
|
|
30
|
+
return sha256(eventMessage(e));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** `home.url` ([LOG-4]). → bad_field */
|
|
34
|
+
export function checkUrl(url) {
|
|
35
|
+
if (typeof url !== "string" || url.length < 1 || url.length > 256 || !/^[\x21-\x7e]+$/.test(url)) fail("bad_field");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseHome(m) {
|
|
39
|
+
s.members(m, ["key", "url"]);
|
|
40
|
+
const key = s.key(m, "key", "ed25519");
|
|
41
|
+
checkUrl(s.string(m, "url"));
|
|
42
|
+
return { key: m.key, keyRaw: key, url: m.url };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** [LOG-12] step 2 for one event. */
|
|
46
|
+
function parseEvent(v, index) {
|
|
47
|
+
if (isObject(v) && canonical(v).length > EVENT_LIMIT) fail("too_large");
|
|
48
|
+
const m = s.object(v);
|
|
49
|
+
s.version(m);
|
|
50
|
+
const kind = s.string(m, "kind");
|
|
51
|
+
if (!["incept", "instance", "rotate_recovery", "retire"].includes(kind)) fail("bad_kind");
|
|
52
|
+
if ((index === 0) !== (kind === "incept")) fail("bad_kind");
|
|
53
|
+
if (kind === "incept") s.members(m, ["v", "kind", "seq", "body", "sigs"]);
|
|
54
|
+
else s.members(m, ["v", "account", "seq", "prev", "kind", "time", "body", "sigs"]);
|
|
55
|
+
const e = { map: m, kind, seq: s.intIn(m, "seq", 0, MAX_SAFE) };
|
|
56
|
+
if (kind !== "incept") {
|
|
57
|
+
e.account = s.account(m, "account");
|
|
58
|
+
e.prev = s.bytes(m, "prev", 32);
|
|
59
|
+
e.time = s.intIn(m, "time", 0, MAX_SAFE);
|
|
60
|
+
} else {
|
|
61
|
+
e.time = 0;
|
|
62
|
+
}
|
|
63
|
+
e.sigs = [];
|
|
64
|
+
for (const x of s.array(m, "sigs")) {
|
|
65
|
+
const o = s.object(x);
|
|
66
|
+
s.members(o, ["key", "sig"]);
|
|
67
|
+
s.key(o, "key", "ed25519");
|
|
68
|
+
e.sigs.push({ key: o.key, sig: s.sig(o, "sig") });
|
|
69
|
+
}
|
|
70
|
+
const body = s.member(m, "body");
|
|
71
|
+
if (kind === "incept") {
|
|
72
|
+
s.members(body, ["realm", "username", "recovery"]);
|
|
73
|
+
e.realm = s.string(body, "realm");
|
|
74
|
+
e.username = s.string(body, "username");
|
|
75
|
+
s.key(body, "recovery", "ed25519");
|
|
76
|
+
e.recovery = body.recovery;
|
|
77
|
+
} else if (kind === "instance") {
|
|
78
|
+
s.members(body, ["account_key", "home", "reason"]);
|
|
79
|
+
s.key(body, "account_key", "ed25519");
|
|
80
|
+
e.accountKey = body.account_key;
|
|
81
|
+
e.home = parseHome(s.member(body, "home"));
|
|
82
|
+
const reason = s.string(body, "reason");
|
|
83
|
+
if (!REASONS.includes(reason)) fail("bad_reason");
|
|
84
|
+
e.reason = reason;
|
|
85
|
+
} else if (kind === "rotate_recovery") {
|
|
86
|
+
s.members(body, ["recovery"]);
|
|
87
|
+
s.key(body, "recovery", "ed25519");
|
|
88
|
+
e.recovery = body.recovery;
|
|
89
|
+
} else {
|
|
90
|
+
s.members(body, []);
|
|
91
|
+
}
|
|
92
|
+
return e;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function checkLength(n) {
|
|
96
|
+
if (n === 0) fail("bad_field");
|
|
97
|
+
if (n > MAX_EVENTS) fail("too_many_events");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function rawKey(k) {
|
|
101
|
+
return s.parseKey(k, "ed25519");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** [LOG-12] step 4 for one event, updating the state `st` (a copy) in place. */
|
|
105
|
+
async function apply(st, e, index, now, receipt) {
|
|
106
|
+
if (st.retired) fail("account_retired");
|
|
107
|
+
if (e.account !== st.account) fail("account_mismatch");
|
|
108
|
+
if (e.seq !== index) fail("bad_seq");
|
|
109
|
+
if (base64urlEncode(e.prev) !== st.version.hash) fail("bad_prev");
|
|
110
|
+
if (e.time < st.time) fail("time_backwards");
|
|
111
|
+
if (now !== undefined && now !== null && e.time > now + FUTURE_WINDOW) fail("future_time");
|
|
112
|
+
let expected;
|
|
113
|
+
if (e.kind === "instance") {
|
|
114
|
+
expected = [st.recovery, e.accountKey];
|
|
115
|
+
if (receipt) expected.push(e.home.key);
|
|
116
|
+
} else if (e.kind === "rotate_recovery") expected = [st.recovery, e.recovery];
|
|
117
|
+
else expected = [st.recovery];
|
|
118
|
+
if (e.sigs.length !== expected.length || e.sigs.some((x, i) => x.key !== expected[i])) fail("wrong_signer");
|
|
119
|
+
if (e.kind === "instance") {
|
|
120
|
+
const ak = e.accountKey;
|
|
121
|
+
const hk = e.home.key;
|
|
122
|
+
if (ak === hk || st.keys.includes(ak) || st.homes.includes(ak) || st.keys.includes(hk)) fail("key_reuse");
|
|
123
|
+
} else if (e.kind === "rotate_recovery") {
|
|
124
|
+
if (st.keys.includes(e.recovery) || st.homes.includes(e.recovery)) fail("key_reuse");
|
|
125
|
+
}
|
|
126
|
+
if (e.reason !== undefined) {
|
|
127
|
+
const has = st.instance !== undefined;
|
|
128
|
+
const ok = e.reason === "register" ? !has : e.reason === "fresh" ? true : has;
|
|
129
|
+
if (!ok) fail("bad_reason");
|
|
130
|
+
}
|
|
131
|
+
const msg = eventMessage(e.map);
|
|
132
|
+
const owners = e.kind === "instance" ? 2 : e.sigs.length;
|
|
133
|
+
for (let i = 0; i < owners; i++) await ed25519Verify(rawKey(e.sigs[i].key), msg, e.sigs[i].sig);
|
|
134
|
+
if (e.kind === "instance" && receipt) {
|
|
135
|
+
try {
|
|
136
|
+
await ed25519Verify(rawKey(e.sigs[2].key), receiptMessage(e.map), e.sigs[2].sig);
|
|
137
|
+
} catch {
|
|
138
|
+
fail("bad_receipt");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const hash = base64urlEncode(await sha256(msg));
|
|
142
|
+
if (e.kind === "instance") {
|
|
143
|
+
st.keys.push(e.accountKey);
|
|
144
|
+
if (!st.homes.includes(e.home.key)) st.homes.push(e.home.key);
|
|
145
|
+
st.instance = {
|
|
146
|
+
hash,
|
|
147
|
+
seq: index,
|
|
148
|
+
account_key: e.accountKey,
|
|
149
|
+
home: { key: e.home.key, url: e.home.url },
|
|
150
|
+
reason: e.reason,
|
|
151
|
+
};
|
|
152
|
+
} else if (e.kind === "rotate_recovery") {
|
|
153
|
+
st.recovery = e.recovery;
|
|
154
|
+
st.keys.push(e.recovery);
|
|
155
|
+
} else if (e.kind === "retire") {
|
|
156
|
+
st.retired = true;
|
|
157
|
+
delete st.instance;
|
|
158
|
+
}
|
|
159
|
+
st.time = e.time;
|
|
160
|
+
st.version = { seq: index, hash };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function copyState(st) {
|
|
164
|
+
const c = {
|
|
165
|
+
account: st.account,
|
|
166
|
+
realm: st.realm,
|
|
167
|
+
username: st.username,
|
|
168
|
+
handle: st.handle,
|
|
169
|
+
recovery: st.recovery,
|
|
170
|
+
retired: st.retired,
|
|
171
|
+
version: { ...st.version },
|
|
172
|
+
time: st.time,
|
|
173
|
+
keys: [...st.keys],
|
|
174
|
+
homes: [...st.homes],
|
|
175
|
+
};
|
|
176
|
+
if (st.instance) c.instance = { ...st.instance, home: { ...st.instance.home } };
|
|
177
|
+
return c;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Verify a whole log ([LOG-12]) and return its state.
|
|
182
|
+
* @param {any[]} events
|
|
183
|
+
* @param {{ realms: string[], now?: number }} options
|
|
184
|
+
*/
|
|
185
|
+
export async function verifyLog(events, options) {
|
|
186
|
+
if (!Array.isArray(events)) fail("bad_field");
|
|
187
|
+
checkLength(events.length);
|
|
188
|
+
const parsed = events.map((v, i) => parseEvent(v, i));
|
|
189
|
+
const inc = parsed[0];
|
|
190
|
+
if (inc.seq !== 0) fail("bad_seq");
|
|
191
|
+
checkRealm(inc.realm);
|
|
192
|
+
if (!options.realms.includes(inc.realm)) fail("realm_not_accepted");
|
|
193
|
+
checkUsername(inc.username);
|
|
194
|
+
if (inc.sigs.length !== 1 || inc.sigs[0].key !== inc.recovery) fail("wrong_signer");
|
|
195
|
+
const msg = eventMessage(inc.map);
|
|
196
|
+
await ed25519Verify(rawKey(inc.recovery), msg, inc.sigs[0].sig);
|
|
197
|
+
const account = await accountId(inc.map);
|
|
198
|
+
const st = {
|
|
199
|
+
account,
|
|
200
|
+
realm: inc.realm,
|
|
201
|
+
username: inc.username,
|
|
202
|
+
handle: handle(inc.username, account),
|
|
203
|
+
recovery: inc.recovery,
|
|
204
|
+
retired: false,
|
|
205
|
+
version: { seq: 0, hash: base64urlEncode(await sha256(msg)) },
|
|
206
|
+
time: 0,
|
|
207
|
+
keys: [inc.recovery],
|
|
208
|
+
homes: [],
|
|
209
|
+
};
|
|
210
|
+
for (let i = 1; i < parsed.length; i++) await apply(st, parsed[i], i, options.now, true);
|
|
211
|
+
return st;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Verify events that extend a verified log state ([LOG-15]). */
|
|
215
|
+
export async function extendLog(state, events, options) {
|
|
216
|
+
const start = state.version.seq + 1;
|
|
217
|
+
if (start + events.length > MAX_EVENTS) fail("too_many_events");
|
|
218
|
+
const parsed = events.map((v, i) => parseEvent(v, start + i));
|
|
219
|
+
const st = copyState(state);
|
|
220
|
+
for (let i = 0; i < parsed.length; i++) await apply(st, parsed[i], start + i, options.now, true);
|
|
221
|
+
return st;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Check a pending instance ([LOG-9]). */
|
|
225
|
+
export async function verifyPending(state, event, now) {
|
|
226
|
+
const index = state.version.seq + 1;
|
|
227
|
+
if (index >= MAX_EVENTS) fail("too_many_events");
|
|
228
|
+
const e = parseEvent(event, index);
|
|
229
|
+
if (e.kind !== "instance") fail("bad_kind");
|
|
230
|
+
await apply(copyState(state), e, index, now, false);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Compare two valid logs of one account ([VER-2]): `b` relative to `a`. → account_mismatch */
|
|
234
|
+
export async function compareLogs(a, b) {
|
|
235
|
+
if (!a.length || !b.length || !isObject(a[0]) || !isObject(b[0])) fail("bad_field");
|
|
236
|
+
if ((await accountId(a[0])) !== (await accountId(b[0]))) fail("account_mismatch");
|
|
237
|
+
const h = async (v) => {
|
|
238
|
+
if (!isObject(v)) fail("bad_field");
|
|
239
|
+
return eventHash(v);
|
|
240
|
+
};
|
|
241
|
+
if (a.length === b.length) {
|
|
242
|
+
return bytesEqual(await h(a[a.length - 1]), await h(b[b.length - 1])) ? "same" : "fork";
|
|
243
|
+
}
|
|
244
|
+
const [short, long, order] = a.length < b.length ? [a, b, "newer"] : [b, a, "older"];
|
|
245
|
+
return bytesEqual(await h(short[short.length - 1]), await h(long[short.length - 1])) ? order : "fork";
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** An unsigned event after `state` (`sigs` empty). */
|
|
249
|
+
export function unsignedEvent(state, kind, body, time) {
|
|
250
|
+
return {
|
|
251
|
+
v: 1,
|
|
252
|
+
account: state.account,
|
|
253
|
+
seq: state.version.seq + 1,
|
|
254
|
+
prev: state.version.hash,
|
|
255
|
+
kind,
|
|
256
|
+
time,
|
|
257
|
+
body,
|
|
258
|
+
sigs: [],
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Append an owner signature ([LOG-6]). */
|
|
263
|
+
export async function signEvent(e, seed) {
|
|
264
|
+
const sig = await ed25519Sign(seed, eventMessage(e));
|
|
265
|
+
e.sigs = [...e.sigs, { key: formatKey("ed25519", await ed25519Public(seed)), sig: base64urlEncode(sig) }];
|
|
266
|
+
return e;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Append the primary host's receipt ([LOG-8]). */
|
|
270
|
+
export async function addReceipt(e, hostSeed) {
|
|
271
|
+
const sig = await ed25519Sign(hostSeed, receiptMessage(e));
|
|
272
|
+
e.sigs = [...e.sigs, { key: formatKey("ed25519", await ed25519Public(hostSeed)), sig: base64urlEncode(sig) }];
|
|
273
|
+
return e;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** [RK-5] → wrong_recovery_key */
|
|
277
|
+
export async function checkRecoveryKey(state, seed) {
|
|
278
|
+
if (formatKey("ed25519", await ed25519Public(seed)) !== state.recovery) fail("wrong_recovery_key");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** A pending instance ([LOG-9]). `home` is `{ key: "ed25519:…", url }`. */
|
|
282
|
+
export async function pendingInstance(state, recoverySeed, accountSeed, home, reason, time) {
|
|
283
|
+
await checkRecoveryKey(state, recoverySeed);
|
|
284
|
+
checkUrl(home.url);
|
|
285
|
+
const body = {
|
|
286
|
+
account_key: formatKey("ed25519", await ed25519Public(accountSeed)),
|
|
287
|
+
home: { key: home.key, url: home.url },
|
|
288
|
+
reason,
|
|
289
|
+
};
|
|
290
|
+
const e = unsignedEvent(state, "instance", body, time);
|
|
291
|
+
await signEvent(e, recoverySeed);
|
|
292
|
+
await signEvent(e, accountSeed);
|
|
293
|
+
return e;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** A `rotate_recovery` event ([KT-5]). */
|
|
297
|
+
export async function rotateRecoveryEvent(state, oldSeed, newSeed, time) {
|
|
298
|
+
await checkRecoveryKey(state, oldSeed);
|
|
299
|
+
const e = unsignedEvent(state, "rotate_recovery", { recovery: formatKey("ed25519", await ed25519Public(newSeed)) }, time);
|
|
300
|
+
await signEvent(e, oldSeed);
|
|
301
|
+
await signEvent(e, newSeed);
|
|
302
|
+
return e;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** A `retire` event ([KT-6]). */
|
|
306
|
+
export async function retireEvent(state, recoverySeed, time) {
|
|
307
|
+
await checkRecoveryKey(state, recoverySeed);
|
|
308
|
+
const e = unsignedEvent(state, "retire", {}, time);
|
|
309
|
+
await signEvent(e, recoverySeed);
|
|
310
|
+
return e;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** The raw bytes of a b64u hash string. */
|
|
314
|
+
export function hashBytes(h) {
|
|
315
|
+
return base64urlDecode(h);
|
|
316
|
+
}
|
package/src/messages.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Challenges and the signed messages of vault fetch, login and password change (SPEC §13).
|
|
2
|
+
|
|
3
|
+
import { fail } from "./errors.js";
|
|
4
|
+
import { base64urlEncode } from "./encoding.js";
|
|
5
|
+
import { ed25519Sign, ed25519Verify, frameObject } from "./crypto.js";
|
|
6
|
+
import * as s from "./keys.js";
|
|
7
|
+
import { label } from "./labels.js";
|
|
8
|
+
import { checkUrl } from "./log.js";
|
|
9
|
+
|
|
10
|
+
export const CHALLENGE_LIFETIME = 60;
|
|
11
|
+
|
|
12
|
+
/** Parse a challenge ([CHL-1]). → bad_field, bad_key */
|
|
13
|
+
export function parseChallenge(v) {
|
|
14
|
+
const m = s.object(v);
|
|
15
|
+
s.members(m, ["nonce", "host", "realm", "version", "expires"]);
|
|
16
|
+
s.bytes(m, "nonce", 32);
|
|
17
|
+
s.key(m, "host", "ed25519");
|
|
18
|
+
s.string(m, "realm");
|
|
19
|
+
s.versionRef(s.get(m, "version"));
|
|
20
|
+
s.int(m, "expires");
|
|
21
|
+
return m;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The login message ([LGN-1] step 5). `host` is a key string, `nonce` b64u. */
|
|
25
|
+
export function loginMessage(account, host, lock, nonce, realm, url, version) {
|
|
26
|
+
return frameObject(label("login"), { account, host, lock, nonce, realm, url, version: { seq: version.seq, hash: version.hash } });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The vault fetch message ([FET-1]). */
|
|
30
|
+
export function fetchMessage(account, host, lock, nonce, url) {
|
|
31
|
+
return frameObject(label("fetch"), { account, host, lock, nonce, url });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The password change proof message ([CHG-1] step 2). `newLockHash` is b64u. */
|
|
35
|
+
export function changeMessage(account, host, lock, newLockHash, nonce, url) {
|
|
36
|
+
return frameObject(label("change"), { account, host, lock, new: newLockHash, nonce, url });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Sign a login for `challenge` with the account key and the lock's key ([LGN-1] steps 4, 5). → wrong_host, stale_version
|
|
41
|
+
* @returns {Promise<{account, nonce, version, url, lock, sig, lock_sig}>}
|
|
42
|
+
*/
|
|
43
|
+
export async function signLogin(state, challenge, url, lock, accountSeed, lockSeed) {
|
|
44
|
+
const ch = parseChallenge(challenge);
|
|
45
|
+
if (!state.instance) fail("no_instance");
|
|
46
|
+
if (ch.host !== state.instance.home.key || ch.realm !== state.realm) fail("wrong_host");
|
|
47
|
+
if (ch.version.seq !== state.version.seq || ch.version.hash !== state.version.hash) fail("stale_version");
|
|
48
|
+
checkUrl(url);
|
|
49
|
+
const msg = loginMessage(state.account, ch.host, lock, ch.nonce, state.realm, url, state.version);
|
|
50
|
+
return {
|
|
51
|
+
account: state.account,
|
|
52
|
+
nonce: ch.nonce,
|
|
53
|
+
version: { seq: state.version.seq, hash: state.version.hash },
|
|
54
|
+
url,
|
|
55
|
+
lock,
|
|
56
|
+
sig: base64urlEncode(await ed25519Sign(accountSeed, msg)),
|
|
57
|
+
lock_sig: base64urlEncode(await ed25519Sign(lockSeed, msg)),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Parse a login request. → bad_field */
|
|
62
|
+
export function parseLoginRequest(v) {
|
|
63
|
+
const m = s.object(v);
|
|
64
|
+
s.members(m, ["account", "nonce", "version", "url", "lock", "sig", "lock_sig"]);
|
|
65
|
+
checkUrl(s.string(m, "url"));
|
|
66
|
+
const lock = s.string(m, "lock");
|
|
67
|
+
if (lock.length < 1 || lock.length > 16) fail("bad_field");
|
|
68
|
+
s.account(m, "account");
|
|
69
|
+
s.bytes(m, "nonce", 32);
|
|
70
|
+
s.versionRef(s.get(m, "version"));
|
|
71
|
+
s.sig(m, "sig");
|
|
72
|
+
s.sig(m, "lock_sig");
|
|
73
|
+
return m;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Verify both signatures of a login request ([LGN-1] step 6). Keys are key strings. → bad_signature */
|
|
77
|
+
export async function verifyLogin(request, realm, homeKey, accountKey, lockKey) {
|
|
78
|
+
const r = parseLoginRequest(request);
|
|
79
|
+
const msg = loginMessage(r.account, homeKey, r.lock, r.nonce, realm, r.url, r.version);
|
|
80
|
+
await ed25519Verify(s.parseKey(accountKey, "ed25519"), msg, s.sig(r, "sig"));
|
|
81
|
+
await ed25519Verify(s.parseKey(lockKey, "ed25519"), msg, s.sig(r, "lock_sig"));
|
|
82
|
+
}
|
package/src/profile.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Arbitrary data: the public profile and data keys (SPEC §12).
|
|
2
|
+
|
|
3
|
+
import { fail } from "./errors.js";
|
|
4
|
+
import { base64urlEncode } from "./encoding.js";
|
|
5
|
+
import { ed25519Sign, ed25519Verify, frameObject } from "./crypto.js";
|
|
6
|
+
import { canonical, validate, without } from "./json.js";
|
|
7
|
+
import * as s from "./keys.js";
|
|
8
|
+
import { label } from "./labels.js";
|
|
9
|
+
|
|
10
|
+
export const PROFILE_LIMIT = 16 * 1024;
|
|
11
|
+
const MAX_REV = 2 ** 32;
|
|
12
|
+
|
|
13
|
+
/** [DAT-4] → bad_data_key */
|
|
14
|
+
export function checkDataKey(key) {
|
|
15
|
+
if (typeof key !== "string" || key.length < 1 || key.length > 64 || !/^[a-z0-9._-]+$/.test(key)) fail("bad_data_key");
|
|
16
|
+
let rest;
|
|
17
|
+
if (key.startsWith("user.")) rest = key.slice(5);
|
|
18
|
+
else if (key.startsWith("app.")) {
|
|
19
|
+
const r = key.slice(4);
|
|
20
|
+
const dot = r.indexOf(".");
|
|
21
|
+
if (dot < 0) fail("bad_data_key");
|
|
22
|
+
checkAppName(r.slice(0, dot));
|
|
23
|
+
rest = r.slice(dot + 1);
|
|
24
|
+
} else fail("bad_data_key");
|
|
25
|
+
if (rest.length === 0) fail("bad_data_key");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** An application namespace name ([DAT-4]). → bad_data_key */
|
|
29
|
+
export function checkAppName(name) {
|
|
30
|
+
if (!/^[a-z0-9][a-z0-9-]{0,31}$/.test(name)) fail("bad_data_key");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Every key of `data` is valid and its canonical form is at most `limit` bytes. → bad_data_key, too_large */
|
|
34
|
+
export function checkData(data, limit) {
|
|
35
|
+
for (const k of Object.keys(data)) checkDataKey(k);
|
|
36
|
+
if (canonical(data).length > limit) fail("too_large");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Set `key` on behalf of application `app` ([DAT-7]). Returns a new object. → bad_data_key, bad_json */
|
|
40
|
+
export function setData(data, app, key, value) {
|
|
41
|
+
checkDataKey(key);
|
|
42
|
+
checkAppName(app);
|
|
43
|
+
if (!key.startsWith("user.") && !key.startsWith(`app.${app}.`)) fail("bad_data_key");
|
|
44
|
+
validate(value);
|
|
45
|
+
return { ...data, [key]: value };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Remove `key` on behalf of application `app` ([DAT-7]). Returns a new object. → bad_data_key */
|
|
49
|
+
export function removeData(data, app, key) {
|
|
50
|
+
checkDataKey(key);
|
|
51
|
+
if (!key.startsWith("user.") && !key.startsWith(`app.${app}.`)) fail("bad_data_key");
|
|
52
|
+
const d = { ...data };
|
|
53
|
+
delete d[key];
|
|
54
|
+
return d;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The message the account key signs on a profile ([DAT-2]). */
|
|
58
|
+
export function publicMessage(profile) {
|
|
59
|
+
return frameObject(label("public"), without(profile, "sig"));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Sign a public profile with the account key. → bad_data_key, too_large */
|
|
63
|
+
export async function signPublic(account, instance, rev, data, accountSeed) {
|
|
64
|
+
checkData(data, PROFILE_LIMIT);
|
|
65
|
+
const p = { v: 1, account, instance, rev, data };
|
|
66
|
+
p.sig = base64urlEncode(await ed25519Sign(accountSeed, publicMessage(p)));
|
|
67
|
+
if (canonical(p).length > PROFILE_LIMIT) fail("too_large");
|
|
68
|
+
return p;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Verify a public profile ([DAT-2]). Returns `{ rev, data }`. */
|
|
72
|
+
export async function verifyPublic(profile, state) {
|
|
73
|
+
if (canonical(profile).length > PROFILE_LIMIT) fail("too_large");
|
|
74
|
+
const m = s.object(profile);
|
|
75
|
+
s.version(m);
|
|
76
|
+
s.members(m, ["v", "account", "instance", "rev", "data", "sig"]);
|
|
77
|
+
const account = s.account(m, "account");
|
|
78
|
+
s.bytes(m, "instance", 32);
|
|
79
|
+
const rev = s.intIn(m, "rev", 1, MAX_REV);
|
|
80
|
+
const data = s.member(m, "data");
|
|
81
|
+
const sig = s.sig(m, "sig");
|
|
82
|
+
if (account !== state.account) fail("account_mismatch");
|
|
83
|
+
if (state.retired) fail("account_retired");
|
|
84
|
+
if (!state.instance) fail("no_instance");
|
|
85
|
+
if (m.instance !== state.instance.hash) fail("wrong_instance");
|
|
86
|
+
for (const k of Object.keys(data)) checkDataKey(k);
|
|
87
|
+
await ed25519Verify(s.parseKey(state.instance.account_key, "ed25519"), publicMessage(m), sig);
|
|
88
|
+
return { rev, data };
|
|
89
|
+
}
|
package/src/proof.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Proofs of identity (SPEC §14).
|
|
2
|
+
|
|
3
|
+
import { fail } from "./errors.js";
|
|
4
|
+
import { base64urlEncode } from "./encoding.js";
|
|
5
|
+
import { ed25519Public, ed25519Sign, ed25519Verify, frameObject, sha256 } from "./crypto.js";
|
|
6
|
+
import { utf8 } from "./encoding.js";
|
|
7
|
+
import { validate, without } from "./json.js";
|
|
8
|
+
import * as s from "./keys.js";
|
|
9
|
+
import { formatKey } from "./keys.js";
|
|
10
|
+
import { label } from "./labels.js";
|
|
11
|
+
import { checkRecoveryKey, verifyLog } from "./log.js";
|
|
12
|
+
|
|
13
|
+
/** `SHA-256(frame("rootcard/statement/v1", { statement }))` ([PRF-5]). → bad_json */
|
|
14
|
+
export async function statementChallenge(statement) {
|
|
15
|
+
validate(statement);
|
|
16
|
+
return sha256(frameObject(label("statement"), { statement }));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function checkRecipient(r) {
|
|
20
|
+
const n = utf8(r).length;
|
|
21
|
+
if (n < 1 || n > 256) fail("bad_field");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Make a proof ([PRF-1], [PRF-2]). `by` is "account" or "recovery"; `challenge` is 32 bytes. */
|
|
25
|
+
export async function prove(state, by, seed, challenge, recipient) {
|
|
26
|
+
checkRecipient(recipient);
|
|
27
|
+
const pub = formatKey("ed25519", await ed25519Public(seed));
|
|
28
|
+
const p = { v: 1, by, account: state.account, challenge: base64urlEncode(challenge), recipient };
|
|
29
|
+
if (by === "account") {
|
|
30
|
+
if (!state.instance) fail("no_instance");
|
|
31
|
+
if (state.instance.account_key !== pub) fail("wrong_account_key");
|
|
32
|
+
p.instance = state.instance.hash;
|
|
33
|
+
} else {
|
|
34
|
+
await checkRecoveryKey(state, seed);
|
|
35
|
+
}
|
|
36
|
+
p.sig = base64urlEncode(await ed25519Sign(seed, frameObject(label("proof"), p)));
|
|
37
|
+
return p;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Verify a proof against the newest log the verifier knows ([PRF-4]). Returns the log state. */
|
|
41
|
+
export async function verifyProof(proof, log, challenge, recipient, options) {
|
|
42
|
+
const m = s.object(proof);
|
|
43
|
+
s.version(m);
|
|
44
|
+
const by = s.string(m, "by");
|
|
45
|
+
if (by !== "account" && by !== "recovery") fail("bad_field");
|
|
46
|
+
if (by === "account") s.members(m, ["v", "by", "account", "instance", "challenge", "recipient", "sig"]);
|
|
47
|
+
else s.members(m, ["v", "by", "account", "challenge", "recipient", "sig"]);
|
|
48
|
+
const account = s.account(m, "account");
|
|
49
|
+
if (by === "account") s.bytes(m, "instance", 32);
|
|
50
|
+
s.bytes(m, "challenge", 32);
|
|
51
|
+
checkRecipient(s.string(m, "recipient"));
|
|
52
|
+
const sig = s.sig(m, "sig");
|
|
53
|
+
const state = await verifyLog(log, options);
|
|
54
|
+
if (account !== state.account) fail("account_mismatch");
|
|
55
|
+
if (state.retired) fail("account_retired");
|
|
56
|
+
if (m.challenge !== base64urlEncode(challenge)) fail("wrong_challenge");
|
|
57
|
+
if (m.recipient !== recipient) fail("wrong_recipient");
|
|
58
|
+
let key;
|
|
59
|
+
if (by === "account") {
|
|
60
|
+
if (!state.instance) fail("no_instance");
|
|
61
|
+
if (m.instance !== state.instance.hash) fail("stale_version");
|
|
62
|
+
key = state.instance.account_key;
|
|
63
|
+
} else key = state.recovery;
|
|
64
|
+
await ed25519Verify(s.parseKey(key, "ed25519"), frameObject(label("proof"), without(m, "sig")), sig);
|
|
65
|
+
return state;
|
|
66
|
+
}
|
package/src/recovery.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The recovery key (SPEC §6).
|
|
2
|
+
|
|
3
|
+
import { fail } from "./errors.js";
|
|
4
|
+
import { B32, base32Decode, base32Encode, base32Normalize, bytesEqual } from "./encoding.js";
|
|
5
|
+
import { ed25519Public, frame, frameObject, hkdf, sha256 } from "./crypto.js";
|
|
6
|
+
import { label } from "./labels.js";
|
|
7
|
+
|
|
8
|
+
async function checksum(key) {
|
|
9
|
+
return base32Encode(await sha256(frame(label("recovery/check"), key))).slice(0, 4);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The written form `RCR-XXXX-…-CCCC` ([RK-1]). */
|
|
13
|
+
export async function formatRecoveryKey(key) {
|
|
14
|
+
const body = base32Encode(key) + (await checksum(key));
|
|
15
|
+
const groups = [];
|
|
16
|
+
for (let i = 0; i < 14; i++) groups.push(body.slice(4 * i, 4 * i + 4));
|
|
17
|
+
return "RCR-" + groups.join("-");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Parse a typed recovery key ([RK-2]). → bad_base32, bad_checksum */
|
|
21
|
+
export async function parseRecoveryKey(text) {
|
|
22
|
+
let s = [...text].filter((c) => c !== "-" && c !== " ").join("");
|
|
23
|
+
if ([...s].length === 59 && s.slice(0, 3).toUpperCase() === "RCR") s = s.slice(3);
|
|
24
|
+
if (s.length !== 56 || !/^[\x00-\x7f]*$/.test(s)) fail("bad_base32");
|
|
25
|
+
const n = base32Normalize(s);
|
|
26
|
+
for (const c of n) if (!B32.includes(c)) fail("bad_base32");
|
|
27
|
+
const key = base32Decode(n.slice(0, 52), 32);
|
|
28
|
+
if (n.slice(52) !== (await checksum(key))) fail("bad_checksum");
|
|
29
|
+
return key;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Derive an identity's recovery keys ([RK-4]): `signSeed` (Ed25519) and `boxSecret` (X25519).
|
|
34
|
+
* @returns {Promise<{ signSeed: Uint8Array, boxSecret: Uint8Array }>}
|
|
35
|
+
*/
|
|
36
|
+
export async function deriveRecoveryKeys(rk, realm, username) {
|
|
37
|
+
const ctx = { realm, username };
|
|
38
|
+
return {
|
|
39
|
+
signSeed: await hkdf(rk, new Uint8Array(0), frameObject(label("recovery/sign"), ctx)),
|
|
40
|
+
boxSecret: await hkdf(rk, new Uint8Array(0), frameObject(label("recovery/box"), ctx)),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The public recovery signing key. */
|
|
45
|
+
export async function recoverySignPublic(keys) {
|
|
46
|
+
return ed25519Public(keys.signSeed);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export { bytesEqual };
|
package/src/testing.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// A deterministic byte stream for tests and vectors. Never use it for keys or nonces.
|
|
2
|
+
// The stream is SHA-256(seed ‖ be32(0)) ‖ SHA-256(seed ‖ be32(1)) ‖ …, consumed byte by byte, the same as the Rust
|
|
3
|
+
// crate's `testing::TestRandom`, so both implementations draw identical bytes (SPEC §21).
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A deterministic `random(n)` function for tests. It is synchronous; it needs `sha256Sync`, a synchronous SHA-256,
|
|
7
|
+
* which the tests take from `node:crypto`.
|
|
8
|
+
* @param {Uint8Array} seed
|
|
9
|
+
* @param {(m: Uint8Array) => Uint8Array} sha256Sync
|
|
10
|
+
*/
|
|
11
|
+
export function testRandom(seed, sha256Sync) {
|
|
12
|
+
let counter = 0;
|
|
13
|
+
let buf = new Uint8Array(0);
|
|
14
|
+
let pos = 0;
|
|
15
|
+
return (n) => {
|
|
16
|
+
const out = new Uint8Array(n);
|
|
17
|
+
for (let i = 0; i < n; i++) {
|
|
18
|
+
if (pos === buf.length) {
|
|
19
|
+
const m = new Uint8Array(seed.length + 4);
|
|
20
|
+
m.set(seed);
|
|
21
|
+
new DataView(m.buffer).setUint32(seed.length, counter++);
|
|
22
|
+
buf = sha256Sync(m);
|
|
23
|
+
pos = 0;
|
|
24
|
+
}
|
|
25
|
+
out[i] = buf[pos++];
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Random bytes from the platform's secure generator, for applications ([CRY-1]). Not used by the core itself. */
|
|
32
|
+
export function systemRandom(n) {
|
|
33
|
+
return globalThis.crypto.getRandomValues(new Uint8Array(n));
|
|
34
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// rootcard/vault: password locks, Argon2id, vault encryption and the high-level flows that need them.
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
checkPassword, deriveLockSecrets, wrapVaultKey, unwrapVaultKey, checkContents, unlockVault, signGrant, sealVault,
|
|
5
|
+
newPasswordLock, instanceVault, carriedLocks, updateContents, changePassword, addSlot, regrant,
|
|
6
|
+
} from "./vault.js";
|
|
7
|
+
export { register, newInstance, rotateRecovery } from "./account.js";
|
|
8
|
+
export { argon2id } from "./argon2.js";
|
|
9
|
+
export { x25519, x25519Public } from "./crypto.js";
|