| 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 | |
| 19 | import { concat, equal, fromHex, randomBytes, toHex } from "./bytes.js"; |
| 20 | import { argon2id } from "./argon2id.js"; |
| 21 | import { checkParams, hkdf, kcv, LABEL_KEK } from "./kdf.js"; |
| 22 | import { newP256 } from "./p256.js"; |
| 23 | import { publicKeyFor } from "./p256math.js"; |
| 24 | import { openFrom } from "./ecies.js"; |
| 25 | import { aad, open, seal, OBJECT_WRAP_PW } from "./sealed.js"; |
| 26 | |
| 27 | export const OBJECT_ACCOUNT_KEYSET = "account.keyset"; |
| 28 | export const OBJECT_VAULT_KEYSET = "vault.keyset"; |
| 29 | |
| 30 | export class LockedError extends Error {} |
| 31 | export 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. |
| 40 | export 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. |
| 62 | export 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. |
| 75 | export 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 | |
| 103 | async 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. |
| 170 | export 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 | |
| 181 | export 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. |
| 201 | export 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 | |