carlos / contra Public

Clone
git clone https://amadan.net/carlos/contra

Plain git — no account needed to clone.

Download

Download this file

1// Unlocking: passphrase and Account Key in, K_vault out.
2//
3// Signing in and unlocking are different things, and the difference is the
4// whole design. Sign-in is a signature the server can verify; unlocking is
5// opening a wrap the server cannot. A session proves who you are. It does
6// not, and must never, hand you a key.
7//
8// The chain, from PROTOCOL.md's "The shape":
9//
10// passphrase --Argon2id(salt_pw)--> S_pw
11// S_pw --HKDF(salt=account_key, "contra/v1/kek")--> KEK_pw
12// KEK_pw --AES-GCM--> RK
13// RK --AES-GCM--> account keyset { d_acct, K_meta }
14// RK --AES-GCM--> vault keyset { K_vault, drop-box private half }
15//
16// Storage and network are passed in, never reached for, so a test can hand
17// in fakes and so this module has no opinion about localStorage.
18
19import { concat, equal, fromHex, randomBytes, toHex } from "./bytes.js";
20import { argon2id } from "./argon2id.js";
21import { checkParams, hkdf, kcv, LABEL_KEK } from "./kdf.js";
22import { newP256 } from "./p256.js";
23import { publicKeyFor } from "./p256math.js";
24import { openFrom } from "./ecies.js";
25import { aad, open, seal, OBJECT_WRAP_PW } from "./sealed.js";
26
27export const OBJECT_ACCOUNT_KEYSET = "account.keyset";
28export const OBJECT_VAULT_KEYSET = "vault.keyset";
29
30export class LockedError extends Error {}
31export class NoAccountKeyError extends LockedError {}
32
33// accountKeyStore wraps whatever key-value store the caller hands in, so the
34// Account Key's cache is one named thing rather than localStorage calls
35// scattered through the app.
36//
37// PROTOCOL.md: once a device holds the Account Key it is cached locally, and
38// from then on the passphrase alone unlocks that device. This is that cache,
39// and it is per-device by construction — nothing syncs it.
40export function accountKeys(storage) {
41 const key = (accountIdHex) => `contra/v1/account-key/${accountIdHex}`;
42 return {
43 get(accountIdHex) {
44 const v = storage.getItem(key(accountIdHex));
45 return v ? fromHex(v) : null;
46 },
47 set(accountIdHex, accountKey) {
48 storage.setItem(key(accountIdHex), toHex(accountKey));
49 },
50 forget(accountIdHex) {
51 storage.removeItem(key(accountIdHex));
52 },
53 };
54}
55
56// unlock opens the passphrase wrap and returns the keys the session needs.
57//
58// The KCV is checked before any decryption is attempted, so a wrong Account
59// Key is reported as a wrong Account Key rather than as a corrupted vault.
60// AES-GCM is not key-committing; without this check the two are the same
61// error, and the unlock paths need to tell them apart constantly.
62export async function unlock(bootstrap, passphrase, accountKey) {
63 return (await opener(bootstrap, passphrase)).open(accountKey);
64}
65
66// opener stretches the passphrase ONCE and hands back something that can test
67// many Account Keys against it.
68//
69// That split is what makes the ladder's "type it" rung usable. A mistyped
70// character produces about a thousand repair candidates (code.js), and the
71// checksum is only twenty bits, so several of them can pass it — the KCV is
72// what picks the real one. Re-running Argon2id per candidate would be minutes
73// of work at m=64MiB; S_pw does not depend on the Account Key, so it is one
74// stretch and then a HMAC per candidate.
75export async function opener(bootstrap, passphrase) {
76 const accountId = fromHex(bootstrap.account_id_hex);
77 const wrap = bootstrap.wraps.find((w) => w.kind === OBJECT_WRAP_PW);
78 if (!wrap) throw new LockedError("contra: this account has no passphrase wrap");
79
80 const params = checkParams(JSON.parse(wrap.kdf_params));
81 const sPW = argon2id(
82 new TextEncoder().encode(passphrase), fromHex(bootstrap.salt_hex), params, 32);
83 const wantKCV = fromHex(wrap.kcv_hex);
84
85 // accepts is the cheap test: derive the KEK and compare the KCV. Sixteen
86 // bytes of HMAC output match exactly one KEK, so a candidate that passes
87 // this is the Account Key, not a near miss.
88 const accepts = async (accountKey) =>
89 equal(await kcv(await hkdf(sPW, accountKey, LABEL_KEK, 32)), wantKCV);
90
91 // first returns the candidate that opens, or null. Order is the caller's:
92 // code.js hands back repairs in position-then-alphabet order and refuses to
93 // choose between them, because choosing on a twenty-bit checksum would
94 // sometimes choose wrong and say nothing.
95 const first = async (candidates) => {
96 for (const c of candidates) if (await accepts(c)) return c;
97 return null;
98 };
99
100 return { accountId, wrap, accepts, first, open: (ak) => openWith(bootstrap, sPW, ak) };
101}
102
103async function openWith(bootstrap, sPW, accountKey) {
104 const accountId = fromHex(bootstrap.account_id_hex);
105 const wrap = bootstrap.wraps.find((w) => w.kind === OBJECT_WRAP_PW);
106 const kekPW = await hkdf(sPW, accountKey, LABEL_KEK, 32);
107
108 if (!equal(await kcv(kekPW), fromHex(wrap.kcv_hex))) {
109 throw new LockedError(
110 "contra: that passphrase and Account Key do not open this account");
111 }
112
113 const rk = await open(
114 kekPW, fromHex(wrap.nonce_hex),
115 aad(accountId, wrap.kind, fromHex(wrap.wrap_id_hex), wrap.version),
116 fromHex(wrap.ciphertext_hex));
117
118 const accountKeyset = JSON.parse(new TextDecoder().decode(await open(
119 rk, fromHex(bootstrap.keyset.nonce_hex),
120 aad(accountId, OBJECT_ACCOUNT_KEYSET, accountId, 1),
121 fromHex(bootstrap.keyset.ciphertext_hex))));
122
123 // d_acct is the account's P-256 private half: what a vault SHARED with this
124 // account is sealed to. Its public half is recomputed rather than fetched,
125 // so the server has no say in which key a member wrap is opened against.
126 const dAcct = fromHex(accountKeyset.d_acct);
127 const acctPoint = publicKeyFor(dAcct);
128 const acctPublic = concat(new Uint8Array([0x04]), acctPoint.x, acctPoint.y);
129
130 const vaults = [];
131 for (const v of bootstrap.vaults) {
132 const vaultId = fromHex(v.vault_id_hex);
133 let keyset;
134 if (v.owned === false) {
135 // A shared vault. The AAD is scoped to the VAULT and names the member,
136 // where the owner's own copy is scoped to the account and names the
137 // vault — so neither blob can ever be opened as the other.
138 const envelope = concat(
139 fromHex(v.ephemeral_public_key_hex),
140 fromHex(v.keyset.nonce_hex),
141 fromHex(v.keyset.ciphertext_hex));
142 keyset = JSON.parse(new TextDecoder().decode(await openFrom(
143 dAcct, acctPublic,
144 aad(vaultId, OBJECT_VAULT_KEYSET, accountId, 1),
145 envelope)));
146 } else {
147 keyset = JSON.parse(new TextDecoder().decode(await open(
148 rk, fromHex(v.keyset.nonce_hex),
149 aad(accountId, OBJECT_VAULT_KEYSET, vaultId, 1),
150 fromHex(v.keyset.ciphertext_hex))));
151 }
152 vaults.push({
153 id: vaultId,
154 idHex: v.vault_id_hex,
155 kVault: fromHex(keyset.k_vault),
156 name: keyset.name || "",
157 // The compare-and-swap token for writes into this vault, and whether
158 // this account may change who else can read it.
159 epoch: v.epoch,
160 owned: v.owned !== false,
161 });
162 }
163
164 return { accountId, rk, accountKeyset, dAcct, acctPublic, vaults };
165}
166
167// newAccountKeyset and newVaultKeyset are what signup seals. Both are sealed
168// under RK with an AAD naming what they are, so one can never be opened as
169// the other.
170export async function newAccountKeyset(accountId, rk, dAcct, kMeta) {
171 const nonce = randomBytes(12);
172 const body = new TextEncoder().encode(
173 JSON.stringify({ d_acct: toHex(dAcct), k_meta: toHex(kMeta) }));
174 return {
175 nonce_hex: toHex(nonce),
176 ciphertext_hex: toHex(await seal(
177 rk, nonce, aad(accountId, OBJECT_ACCOUNT_KEYSET, accountId, 1), body)),
178 };
179}
180
181export async function newVaultKeyset(accountId, vaultId, rk, kVault, dropBoxPrivate) {
182 const nonce = randomBytes(12);
183 const body = new TextEncoder().encode(JSON.stringify({
184 name: "Personal",
185 k_vault: toHex(kVault),
186 drop_box_private: toHex(dropBoxPrivate),
187 }));
188 return {
189 nonce_hex: toHex(nonce),
190 ciphertext_hex: toHex(await seal(
191 rk, nonce, aad(accountId, OBJECT_VAULT_KEYSET, vaultId, 1), body)),
192 };
193}
194
195// provision is the second half of signup: the account keyset and the first
196// vault, both sealed under RK in the browser and posted as ciphertext.
197//
198// It is separate from signup because it needs a session, and signup ends
199// before one exists — a keyset posted without one would be a write nobody had
200// proved they were entitled to make.
201export async function provision(net, accountId, rk) {
202 const acct = await newP256(); // d_acct, and the public half others seal to
203 const dropBox = await newP256(); // vP_j, for captures with no key in memory
204 const kMeta = randomBytes(32);
205 const kVault = randomBytes(32);
206 const vaultId = randomBytes(16);
207
208 await net.post("/api/provision", {
209 public_key_hex: toHex(acct.publicKey),
210 account_keyset: await newAccountKeyset(accountId, rk, acct.scalar, kMeta),
211 vault_id_hex: toHex(vaultId),
212 drop_box_public_key_hex: toHex(dropBox.publicKey),
213 vault_keyset: await newVaultKeyset(accountId, vaultId, rk, kVault, dropBox.scalar),
214 });
215
216 return { id: vaultId, idHex: toHex(vaultId), kVault };
217}
218