rootcard 0.0.0 → 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 +9 -1
- package/README.md +21 -3
- package/keytool.html +3106 -0
- package/package.json +29 -6
- 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 +28 -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/src/vault.js
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// The vault cryptography (SPEC §11): password locks, wrapping, contents, unlocking and changes. This module is separate
|
|
2
|
+
// from the verification core so that a server bundle never loads it (ARCHITECTURE.md §4).
|
|
3
|
+
//
|
|
4
|
+
// Functions that need random bytes take `random(n) → Uint8Array` ([IMP-1]). The order of the draws is part of the
|
|
5
|
+
// contract and identical to the Rust crate, so both produce the same bytes from the same stream.
|
|
6
|
+
|
|
7
|
+
import { fail } from "./errors.js";
|
|
8
|
+
import { base64urlDecode, base64urlEncode, concat, utf8 } from "./encoding.js";
|
|
9
|
+
import {
|
|
10
|
+
aesGcmDecrypt, aesGcmEncrypt, ed25519Public, ed25519Sign, frameObject, hkdf, sha256, x25519, x25519Public,
|
|
11
|
+
} from "./crypto.js";
|
|
12
|
+
import { argon2id } from "./argon2.js";
|
|
13
|
+
import { canonical, parseLimited } from "./json.js";
|
|
14
|
+
import * as s from "./keys.js";
|
|
15
|
+
import { formatKey } from "./keys.js";
|
|
16
|
+
import { label } from "./labels.js";
|
|
17
|
+
import { checkData } from "./profile.js";
|
|
18
|
+
import {
|
|
19
|
+
CONTENTS_LIMIT, DATA_LIMIT, MAX_SLOTS, VAULT_LIMIT, checkKdf, checkLabel, contentsAad, grantMessage, vaultMessage,
|
|
20
|
+
verifyVault,
|
|
21
|
+
} from "./vaultcheck.js";
|
|
22
|
+
|
|
23
|
+
const EMPTY = new Uint8Array(0);
|
|
24
|
+
|
|
25
|
+
/** The password rules ([LCK-8]). → weak_password */
|
|
26
|
+
export function checkPassword(password, username) {
|
|
27
|
+
const n = password.normalize("NFC");
|
|
28
|
+
if ([...n].length < 12 || utf8(n).length > 1024 || (n.toLowerCase() === username.toLowerCase() && /^[\x00-\x7f]*$/.test(n))) {
|
|
29
|
+
fail("weak_password");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Derive a password lock's secrets ([LCK-7]): `{ authSeed, boxSecret }`. → kdf_params */
|
|
34
|
+
export async function deriveLockSecrets(account, lock, password, kdf, salt) {
|
|
35
|
+
checkKdf(kdf);
|
|
36
|
+
const pw = utf8(password.normalize("NFC"));
|
|
37
|
+
const a = argon2id(pw, salt, { m: kdf.m, t: kdf.t, p: kdf.p });
|
|
38
|
+
pw.fill(0);
|
|
39
|
+
const ctx = { account, lock };
|
|
40
|
+
try {
|
|
41
|
+
return {
|
|
42
|
+
authSeed: await hkdf(a, EMPTY, frameObject(label("password/auth"), ctx)),
|
|
43
|
+
boxSecret: await hkdf(a, EMPTY, frameObject(label("password/box"), ctx)),
|
|
44
|
+
};
|
|
45
|
+
} finally {
|
|
46
|
+
a.fill(0);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function wrapKey(shared, epk, box, account, instance, lock) {
|
|
51
|
+
return hkdf(shared, concat(epk, box), frameObject(label("wrap"), { account, instance, lock }));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Wrap the vault key to a lock's `box` ([LCK-9]). Draws: e (32), nonce (12). → bad_key */
|
|
55
|
+
export async function wrapVaultKey(vk, box, account, instance, lock, random) {
|
|
56
|
+
const e = random(32);
|
|
57
|
+
const nonce = random(12);
|
|
58
|
+
const epk = await x25519Public(e);
|
|
59
|
+
const shared = await x25519(e, box);
|
|
60
|
+
const wk = await wrapKey(shared, epk, box, account, instance, lock);
|
|
61
|
+
const ct = await aesGcmEncrypt(wk, nonce, vk, EMPTY);
|
|
62
|
+
e.fill(0);
|
|
63
|
+
shared.fill(0);
|
|
64
|
+
wk.fill(0);
|
|
65
|
+
return { epk: formatKey("x25519", epk), nonce: base64urlEncode(nonce), ct: base64urlEncode(ct) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Unwrap the vault key with a lock secret ([LCK-9]). → unlock_failed, bad_key */
|
|
69
|
+
export async function unwrapVaultKey(wrap, secret, box, account, instance, lock) {
|
|
70
|
+
const pub = await x25519Public(secret);
|
|
71
|
+
if (formatKey("x25519", pub) !== formatKey("x25519", box)) fail("unlock_failed");
|
|
72
|
+
const epk = s.parseKey(wrap.epk, "x25519");
|
|
73
|
+
const shared = await x25519(secret, epk);
|
|
74
|
+
const wk = await wrapKey(shared, epk, box, account, instance, lock);
|
|
75
|
+
const vk = await aesGcmDecrypt(wk, base64urlDecode(wrap.nonce), base64urlDecode(wrap.ct), EMPTY);
|
|
76
|
+
shared.fill(0);
|
|
77
|
+
wk.fill(0);
|
|
78
|
+
if (vk.length !== 32) fail("unlock_failed");
|
|
79
|
+
return vk;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Check vault contents ([VLT-7]). → too_large, bad_field, unsupported_version, bad_data_key */
|
|
83
|
+
export function checkContents(v) {
|
|
84
|
+
if (canonical(v).length > CONTENTS_LIMIT) fail("too_large");
|
|
85
|
+
const m = s.object(v);
|
|
86
|
+
s.version(m);
|
|
87
|
+
s.members(m, ["v", "account_seed", "data"], ["known"]);
|
|
88
|
+
const seed = s.bytes(m, "account_seed", 32);
|
|
89
|
+
const data = s.member(m, "data");
|
|
90
|
+
checkData(data, DATA_LIMIT);
|
|
91
|
+
if (Object.hasOwn(m, "known")) {
|
|
92
|
+
const k = s.object(m.known);
|
|
93
|
+
for (const id of Object.keys(k)) {
|
|
94
|
+
s.checkAccountId(id);
|
|
95
|
+
const e = s.object(k[id]);
|
|
96
|
+
s.members(e, ["handle", "version"]);
|
|
97
|
+
s.string(e, "handle");
|
|
98
|
+
s.versionRef(s.get(e, "version"));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { accountSeed: seed, data, known: m.known };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function contentsValue(c) {
|
|
105
|
+
const v = { v: 1, account_seed: base64urlEncode(c.accountSeed), data: c.data };
|
|
106
|
+
if (c.known !== undefined) v.known = c.known;
|
|
107
|
+
return v;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Unlock a vault ([VLT-8]). `secret` is `{ lock, password }` or `{ recovery: { signSeed, boxSecret } }`.
|
|
112
|
+
* Returns `{ vaultKey, contents: { accountSeed, data, known }, lock, lockSeed, info }`.
|
|
113
|
+
*/
|
|
114
|
+
export async function unlockVault(vault, state, secret) {
|
|
115
|
+
const info = await verifyVault(vault, state);
|
|
116
|
+
let lockId;
|
|
117
|
+
let lockSecret;
|
|
118
|
+
let lockSeed;
|
|
119
|
+
if (secret.recovery) {
|
|
120
|
+
if (!info.locks.find((l) => l.id === "recovery")) fail("bad_locks");
|
|
121
|
+
lockId = "recovery";
|
|
122
|
+
lockSecret = secret.recovery.boxSecret;
|
|
123
|
+
lockSeed = secret.recovery.signSeed;
|
|
124
|
+
} else {
|
|
125
|
+
const p = info.locks.find((l) => l.kind === "password" && l.id === secret.lock);
|
|
126
|
+
if (!p) fail("bad_locks");
|
|
127
|
+
const ls = await deriveLockSecrets(state.account, secret.lock, secret.password, p.kdf, p.kdf.salt);
|
|
128
|
+
lockId = secret.lock;
|
|
129
|
+
lockSecret = ls.boxSecret;
|
|
130
|
+
lockSeed = ls.authSeed;
|
|
131
|
+
}
|
|
132
|
+
const lock = info.locks.find((l) => l.id === lockId);
|
|
133
|
+
if (!lock) fail("bad_locks");
|
|
134
|
+
const vk = await unwrapVaultKey(lock.value.wrap, lockSecret, lock.box, state.account, vault.instance, lockId);
|
|
135
|
+
const pt = await aesGcmDecrypt(vk, base64urlDecode(vault.nonce), base64urlDecode(vault.ct), contentsAad(vault));
|
|
136
|
+
const contents = checkContents(parseLimited(pt, CONTENTS_LIMIT));
|
|
137
|
+
pt.fill(0);
|
|
138
|
+
if (!state.instance) fail("no_instance");
|
|
139
|
+
if (formatKey("ed25519", await ed25519Public(contents.accountSeed)) !== state.instance.account_key) {
|
|
140
|
+
fail("wrong_account_key");
|
|
141
|
+
}
|
|
142
|
+
return { vaultKey: vk, contents, lock: lockId, lockSeed, info };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Sign a grant with the recovery key ([LCK-3]). `recoveryBox` is the raw X25519 public key. */
|
|
146
|
+
export async function signGrant(account, instance, gen, slots, recoveryBox, recoverySeed) {
|
|
147
|
+
const g = { gen, slots: slots.map((x) => ({ id: x.id, label: x.label })), recovery: formatKey("x25519", recoveryBox) };
|
|
148
|
+
g.sig = base64urlEncode(await ed25519Sign(recoverySeed, grantMessage(g, account, instance)));
|
|
149
|
+
return g;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Encrypt the contents and sign a vault ([VLT-2], [VLT-6]). Draws: the contents nonce (12). */
|
|
153
|
+
export async function sealVault(account, instance, rev, grant, list, contents, vaultKey, random) {
|
|
154
|
+
const cv = contentsValue(contents);
|
|
155
|
+
checkContents(cv);
|
|
156
|
+
const plain = canonical(cv);
|
|
157
|
+
const nonce = random(12);
|
|
158
|
+
const v = { v: 1, account, instance, rev, locks: { grant, list }, nonce: base64urlEncode(nonce) };
|
|
159
|
+
v.ct = base64urlEncode(await aesGcmEncrypt(vaultKey, nonce, plain, contentsAad(v)));
|
|
160
|
+
plain.fill(0);
|
|
161
|
+
v.sig = base64urlEncode(await ed25519Sign(contents.accountSeed, vaultMessage(v)));
|
|
162
|
+
if (canonical(v).length > VAULT_LIMIT) fail("too_large");
|
|
163
|
+
return v;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function kdfValue(kdf, salt) {
|
|
167
|
+
return { alg: "argon2id", m: kdf.m, t: kdf.t, p: kdf.p, salt: base64urlEncode(salt) };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Create a new password lock's public part. Draws: slot id (8), salt (16). Returns `[slot, lockPublic]`. */
|
|
171
|
+
export async function newPasswordLock(account, slot, random) {
|
|
172
|
+
checkLabel(slot.label);
|
|
173
|
+
const id = base64urlEncode(random(8));
|
|
174
|
+
const salt = random(16);
|
|
175
|
+
const kdf = { m: slot.kdf.m, t: slot.kdf.t, p: slot.kdf.p ?? 1 };
|
|
176
|
+
const ls = await deriveLockSecrets(account, id, slot.password, kdf, salt);
|
|
177
|
+
const pub = {
|
|
178
|
+
id,
|
|
179
|
+
kdf: kdfValue(kdf, salt),
|
|
180
|
+
auth: formatKey("ed25519", await ed25519Public(ls.authSeed)),
|
|
181
|
+
box: formatKey("x25519", await x25519Public(ls.boxSecret)),
|
|
182
|
+
};
|
|
183
|
+
ls.authSeed.fill(0);
|
|
184
|
+
ls.boxSecret.fill(0);
|
|
185
|
+
return [{ id, label: slot.label }, pub];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function passwordLock(pub, wrap) {
|
|
189
|
+
return { id: pub.id, kind: "password", kdf: pub.kdf, auth: pub.auth, box: pub.box, wrap };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function buildLocksAndSeal(account, instance, rev, grant, rbox, pairs, contents, vk, random) {
|
|
193
|
+
const list = [
|
|
194
|
+
{ id: "recovery", kind: "recovery", box: formatKey("x25519", rbox), wrap: await wrapVaultKey(vk, rbox, account, instance, "recovery", random) },
|
|
195
|
+
];
|
|
196
|
+
for (const [, pub] of pairs) {
|
|
197
|
+
const w = await wrapVaultKey(vk, s.parseKey(pub.box, "x25519"), account, instance, pub.id, random);
|
|
198
|
+
list.push(passwordLock(pub, w));
|
|
199
|
+
}
|
|
200
|
+
return sealVault(account, instance, rev, grant, list, contents, vk, random);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Build the vault of a new instance (`rev` 1, `gen` 1). Draws: vault key (32); for a new slot, id (8) and salt (16);
|
|
205
|
+
* the recovery wrap (32 + 12); each password wrap (32 + 12) in slot order; the contents nonce (12).
|
|
206
|
+
* `p`: `{ account, instance, recovery: { signSeed, boxSecret }, contents, keep: [[slot, lockPublic]], add?: slot }`.
|
|
207
|
+
*/
|
|
208
|
+
export async function instanceVault(p, random) {
|
|
209
|
+
const vk = random(32);
|
|
210
|
+
const pairs = [...p.keep];
|
|
211
|
+
if (p.add) pairs.push(await newPasswordLock(p.account, p.add, random));
|
|
212
|
+
const rbox = await x25519Public(p.recovery.boxSecret);
|
|
213
|
+
const grant = await signGrant(p.account, p.instance, 1, pairs.map(([x]) => x), rbox, p.recovery.signSeed);
|
|
214
|
+
try {
|
|
215
|
+
return await buildLocksAndSeal(p.account, p.instance, 1, grant, rbox, pairs, p.contents, vk, random);
|
|
216
|
+
} finally {
|
|
217
|
+
vk.fill(0);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The password locks of an unlocked vault to carry over ([KT-3]); `drop` names slots to leave out. */
|
|
222
|
+
export function carriedLocks(u, drop = []) {
|
|
223
|
+
const out = [];
|
|
224
|
+
for (const x of u.info.slots) {
|
|
225
|
+
if (drop.includes(x.id)) continue;
|
|
226
|
+
const l = u.info.locks.find((y) => y.kind === "password" && y.id === x.id);
|
|
227
|
+
if (l) out.push([{ id: x.id, label: x.label }, { id: l.id, kdf: l.value.kdf, auth: l.value.auth, box: l.value.box }]);
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function idsOf(u, vault) {
|
|
233
|
+
const m = s.object(vault);
|
|
234
|
+
const grant = s.member(s.member(m, "locks"), "grant");
|
|
235
|
+
if (s.int(m, "rev") !== u.info.rev || s.int(grant, "gen") !== u.info.gen) fail("stale_rev");
|
|
236
|
+
return { account: s.string(m, "account"), instance: s.string(m, "instance"), grant };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** A data update ([HST-12] case 1): `rev + 1`. Draws: the contents nonce (12). */
|
|
240
|
+
export async function updateContents(u, vault, contents, random) {
|
|
241
|
+
const { account, instance, grant } = idsOf(u, vault);
|
|
242
|
+
const list = u.info.locks.map((l) => l.value);
|
|
243
|
+
return sealVault(account, instance, u.info.rev + 1, grant, list, contents, u.vaultKey, random);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* A password change ([CHG-1] step 1). Draws: salt (16), wrap (32 + 12), contents nonce (12).
|
|
248
|
+
* Returns `{ vault, new }` where `new` is `SHA-256(canonical(new lock))`.
|
|
249
|
+
*/
|
|
250
|
+
export async function changePassword(u, vault, slot, password, kdf, random) {
|
|
251
|
+
const { account, instance, grant } = idsOf(u, vault);
|
|
252
|
+
const index = u.info.locks.findIndex((l) => l.kind === "password" && l.id === slot);
|
|
253
|
+
if (index < 0) fail("bad_locks");
|
|
254
|
+
const salt = random(16);
|
|
255
|
+
const k = { m: kdf.m, t: kdf.t, p: kdf.p ?? 1 };
|
|
256
|
+
const ls = await deriveLockSecrets(account, slot, password, k, salt);
|
|
257
|
+
const box = await x25519Public(ls.boxSecret);
|
|
258
|
+
const pub = { id: slot, kdf: kdfValue(k, salt), auth: formatKey("ed25519", await ed25519Public(ls.authSeed)), box: formatKey("x25519", box) };
|
|
259
|
+
ls.authSeed.fill(0);
|
|
260
|
+
ls.boxSecret.fill(0);
|
|
261
|
+
const lock = passwordLock(pub, await wrapVaultKey(u.vaultKey, box, account, instance, slot, random));
|
|
262
|
+
const hash = await sha256(canonical(lock));
|
|
263
|
+
const list = u.info.locks.map((l) => l.value);
|
|
264
|
+
list[index] = lock;
|
|
265
|
+
return { vault: await sealVault(account, instance, u.info.rev + 1, grant, list, u.contents, u.vaultKey, random), new: hash };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Add a password slot ([KT-4]). Draws: id (8), salt (16), wrap (32 + 12), contents nonce (12). */
|
|
269
|
+
export async function addSlot(u, vault, recovery, slot, random) {
|
|
270
|
+
const { account, instance } = idsOf(u, vault);
|
|
271
|
+
if (u.info.slots.length >= MAX_SLOTS) fail("bad_locks");
|
|
272
|
+
const [x, pub] = await newPasswordLock(account, slot, random);
|
|
273
|
+
const w = await wrapVaultKey(u.vaultKey, s.parseKey(pub.box, "x25519"), account, instance, x.id, random);
|
|
274
|
+
const slots = [...u.info.slots, x];
|
|
275
|
+
const list = [...u.info.locks.map((l) => l.value), passwordLock(pub, w)];
|
|
276
|
+
const grant = await signGrant(account, instance, u.info.gen + 1, slots, await x25519Public(recovery.boxSecret), recovery.signSeed);
|
|
277
|
+
return sealVault(account, instance, u.info.rev + 1, grant, list, u.contents, u.vaultKey, random);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The vault after a recovery-key rotation ([KT-5]). Draws: wrap (32 + 12), contents nonce (12). */
|
|
281
|
+
export async function regrant(u, vault, newRecovery, random) {
|
|
282
|
+
const { account, instance } = idsOf(u, vault);
|
|
283
|
+
const rbox = await x25519Public(newRecovery.boxSecret);
|
|
284
|
+
const grant = await signGrant(account, instance, u.info.gen + 1, u.info.slots, rbox, newRecovery.signSeed);
|
|
285
|
+
const list = [];
|
|
286
|
+
for (const l of u.info.locks) {
|
|
287
|
+
if (l.kind === "recovery") {
|
|
288
|
+
list.push({ id: "recovery", kind: "recovery", box: formatKey("x25519", rbox), wrap: await wrapVaultKey(u.vaultKey, rbox, account, instance, "recovery", random) });
|
|
289
|
+
} else list.push(l.value);
|
|
290
|
+
}
|
|
291
|
+
return sealVault(account, instance, u.info.rev + 1, grant, list, u.contents, u.vaultKey, random);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export { verifyVault };
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// The vault's structure and signatures (SPEC §11), without any decryption: what hosts need ([VLT-3], [VLT-4]).
|
|
2
|
+
|
|
3
|
+
import { fail } from "./errors.js";
|
|
4
|
+
import { base64urlDecode, bytesEqual } from "./encoding.js";
|
|
5
|
+
import { ed25519Verify, frameObject, sha256 } from "./crypto.js";
|
|
6
|
+
import { canonical, isObject, without } from "./json.js";
|
|
7
|
+
import * as s from "./keys.js";
|
|
8
|
+
import { label } from "./labels.js";
|
|
9
|
+
|
|
10
|
+
export const VAULT_LIMIT = 96 * 1024;
|
|
11
|
+
export const CONTENTS_LIMIT = 64 * 1024;
|
|
12
|
+
export const DATA_LIMIT = 48 * 1024;
|
|
13
|
+
export const MAX_SLOTS = 15;
|
|
14
|
+
export const MAX_REV = 2 ** 32;
|
|
15
|
+
|
|
16
|
+
/** Argon2id defaults and bounds ([LCK-6]). */
|
|
17
|
+
export const KDF_DEFAULT = Object.freeze({ m: 65536, t: 3, p: 1 });
|
|
18
|
+
export const KDF_MIN = Object.freeze({ m: 47104, t: 2, p: 1 });
|
|
19
|
+
|
|
20
|
+
/** [LCK-6] → kdf_params */
|
|
21
|
+
export function checkKdf(k) {
|
|
22
|
+
if (!(k.p === 1 && k.m >= 47104 && k.m <= 1048576 && k.t >= 2 && k.t <= 16)) fail("kdf_params");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** [LCK-2] → bad_field */
|
|
26
|
+
export function checkLabel(l) {
|
|
27
|
+
const chars = [...l];
|
|
28
|
+
if (chars.length < 1 || chars.length > 64 || chars.some((c) => c < " " || c === "\x7f")) fail("bad_field");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The grant message signed by the recovery key ([LCK-3]). */
|
|
32
|
+
export function grantMessage(grant, account, instance) {
|
|
33
|
+
return frameObject(label("locks"), { ...without(grant, "sig"), account, instance });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The message the account key signs on a vault ([VLT-2]). */
|
|
37
|
+
export function vaultMessage(vault) {
|
|
38
|
+
return frameObject(label("vault"), without(vault, "sig"));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The authenticated data of the contents encryption ([VLT-6]). */
|
|
42
|
+
export function contentsAad(vault) {
|
|
43
|
+
return frameObject(label("contents"), without(vault, "nonce", "ct", "sig"));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseWrap(m) {
|
|
47
|
+
s.members(m, ["epk", "nonce", "ct"]);
|
|
48
|
+
return { epk: s.key(m, "epk", "x25519"), nonce: s.bytes(m, "nonce", 12), ct: s.bytes(m, "ct", 48) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseLock(v) {
|
|
52
|
+
const m = s.object(v);
|
|
53
|
+
const kind = s.string(m, "kind");
|
|
54
|
+
if (kind === "password") {
|
|
55
|
+
s.members(m, ["id", "kind", "kdf", "auth", "box", "wrap"]);
|
|
56
|
+
const id = s.string(m, "id");
|
|
57
|
+
s.bytes(m, "id", 8);
|
|
58
|
+
const k = s.member(m, "kdf");
|
|
59
|
+
s.members(k, ["alg", "m", "t", "p", "salt"]);
|
|
60
|
+
const kdf = { alg: s.string(k, "alg"), m: s.int(k, "m"), t: s.int(k, "t"), p: s.int(k, "p"), salt: s.bytes(k, "salt", 16) };
|
|
61
|
+
const auth = s.key(m, "auth", "ed25519");
|
|
62
|
+
const box = s.key(m, "box", "x25519");
|
|
63
|
+
const wrap = parseWrap(s.member(m, "wrap"));
|
|
64
|
+
return { kind, id, kdf, auth, box, wrap, value: m };
|
|
65
|
+
}
|
|
66
|
+
if (kind === "recovery") {
|
|
67
|
+
s.members(m, ["id", "kind", "box", "wrap"]);
|
|
68
|
+
const id = s.string(m, "id");
|
|
69
|
+
const box = s.key(m, "box", "x25519");
|
|
70
|
+
return { kind, id, box, boxString: m.box, wrap: parseWrap(s.member(m, "wrap")), value: m };
|
|
71
|
+
}
|
|
72
|
+
fail("bad_field");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseVault(v) {
|
|
76
|
+
if (isObject(v) && canonical(v).length > VAULT_LIMIT) fail("too_large");
|
|
77
|
+
const m = s.object(v);
|
|
78
|
+
s.version(m);
|
|
79
|
+
s.members(m, ["v", "account", "instance", "rev", "locks", "nonce", "ct", "sig"]);
|
|
80
|
+
s.account(m, "account");
|
|
81
|
+
s.bytes(m, "instance", 32);
|
|
82
|
+
const rev = s.intIn(m, "rev", 1, MAX_REV);
|
|
83
|
+
const locks = s.member(m, "locks");
|
|
84
|
+
s.members(locks, ["grant", "list"]);
|
|
85
|
+
const grant = s.member(locks, "grant");
|
|
86
|
+
s.members(grant, ["gen", "slots", "recovery", "sig"]);
|
|
87
|
+
const gen = s.intIn(grant, "gen", 1, MAX_REV);
|
|
88
|
+
const slots = [];
|
|
89
|
+
for (const x of s.array(grant, "slots")) {
|
|
90
|
+
const o = s.object(x);
|
|
91
|
+
s.members(o, ["id", "label"]);
|
|
92
|
+
s.bytes(o, "id", 8);
|
|
93
|
+
slots.push({ id: s.string(o, "id"), label: s.string(o, "label") });
|
|
94
|
+
}
|
|
95
|
+
s.key(grant, "recovery", "x25519");
|
|
96
|
+
const grantSig = s.sig(grant, "sig");
|
|
97
|
+
const list = s.array(locks, "list").map(parseLock);
|
|
98
|
+
s.bytes(m, "nonce", 12);
|
|
99
|
+
let ct;
|
|
100
|
+
try {
|
|
101
|
+
ct = base64urlDecode(s.string(m, "ct"));
|
|
102
|
+
} catch {
|
|
103
|
+
fail("bad_field");
|
|
104
|
+
}
|
|
105
|
+
if (ct.length < 16) fail("bad_field");
|
|
106
|
+
const sig = s.sig(m, "sig");
|
|
107
|
+
return { map: m, grant, rev, gen, slots, list, grantSig, sig };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function checkLockSet(p) {
|
|
111
|
+
const { slots, list } = p;
|
|
112
|
+
const unique = new Set(slots.map((x) => x.id)).size === slots.length;
|
|
113
|
+
const rec = list.filter((l) => l.kind === "recovery");
|
|
114
|
+
const recoveryOk = rec.length === 1 && rec[0].id === "recovery" && rec[0].boxString === p.grant.recovery;
|
|
115
|
+
const pw = list.filter((l) => l.kind === "password");
|
|
116
|
+
const perSlot = pw.length === slots.length && slots.every((x) => pw.filter((l) => l.id === x.id).length === 1);
|
|
117
|
+
if (slots.length > MAX_SLOTS || !unique || !recoveryOk || !perSlot) fail("bad_locks");
|
|
118
|
+
for (const x of slots) checkLabel(x.label);
|
|
119
|
+
for (const l of pw) {
|
|
120
|
+
const ok32 = (n) => Number.isInteger(n) && n >= 0 && n <= 0xffffffff;
|
|
121
|
+
if (l.kdf.alg !== "argon2id" || ![l.kdf.m, l.kdf.t, l.kdf.p].every(ok32)) fail("kdf_params");
|
|
122
|
+
checkKdf(l.kdf);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Verify a vault against a log state ([VLT-3]), without decrypting it. Returns `{ rev, gen, slots, locks, hints }`.
|
|
128
|
+
*/
|
|
129
|
+
export async function verifyVault(vault, state) {
|
|
130
|
+
const p = parseVault(vault);
|
|
131
|
+
if (p.map.account !== state.account) fail("account_mismatch");
|
|
132
|
+
if (state.retired) fail("account_retired");
|
|
133
|
+
if (!state.instance) fail("no_instance");
|
|
134
|
+
if (p.map.instance !== state.instance.hash) fail("wrong_instance");
|
|
135
|
+
checkLockSet(p);
|
|
136
|
+
await ed25519Verify(s.parseKey(state.recovery, "ed25519"), grantMessage(p.grant, state.account, p.map.instance), p.grantSig);
|
|
137
|
+
await ed25519Verify(s.parseKey(state.instance.account_key, "ed25519"), vaultMessage(p.map), p.sig);
|
|
138
|
+
return info(p);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function info(p) {
|
|
142
|
+
const hints = [];
|
|
143
|
+
for (const x of p.slots) {
|
|
144
|
+
const l = p.list.find((y) => y.kind === "password" && y.id === x.id);
|
|
145
|
+
if (l) hints.push({ id: x.id, label: x.label, kdf: l.value.kdf });
|
|
146
|
+
}
|
|
147
|
+
return { rev: p.rev, gen: p.gen, slots: p.slots, locks: p.list, hints };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Classify a replacement of `old` by `new` ([VLT-4], [HST-12]). → stale_rev, stale_locks */
|
|
151
|
+
export async function vaultChange(oldVault, newVault) {
|
|
152
|
+
const o = s.object(oldVault);
|
|
153
|
+
const n = s.object(newVault);
|
|
154
|
+
if (s.int(n, "rev") <= s.int(o, "rev")) fail("stale_rev");
|
|
155
|
+
const ol = s.member(o, "locks");
|
|
156
|
+
const nl = s.member(n, "locks");
|
|
157
|
+
const og = s.int(s.member(ol, "grant"), "gen");
|
|
158
|
+
const ng = s.int(s.member(nl, "grant"), "gen");
|
|
159
|
+
if (ng < og) fail("stale_locks");
|
|
160
|
+
if (ng > og) return { change: "grant" };
|
|
161
|
+
const same = (a, b) => {
|
|
162
|
+
try {
|
|
163
|
+
return bytesEqual(canonical(a), canonical(b));
|
|
164
|
+
} catch {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
if (!same(ol.grant, nl.grant)) fail("stale_locks");
|
|
169
|
+
const olist = s.array(ol, "list");
|
|
170
|
+
const nlist = s.array(nl, "list");
|
|
171
|
+
if (olist.length !== nlist.length) fail("stale_locks");
|
|
172
|
+
const changed = [];
|
|
173
|
+
for (let i = 0; i < olist.length; i++) if (!same(olist[i], nlist[i])) changed.push(i);
|
|
174
|
+
if (changed.length === 0) return { change: "data" };
|
|
175
|
+
if (changed.length > 1) fail("stale_locks");
|
|
176
|
+
const a = s.object(olist[changed[0]]);
|
|
177
|
+
const b = s.object(nlist[changed[0]]);
|
|
178
|
+
if (a.kind !== "password" || b.kind !== "password" || a.id !== b.id) fail("stale_locks");
|
|
179
|
+
return { change: "password", slot: s.string(b, "id"), new: await sha256(canonical(b)) };
|
|
180
|
+
}
|