| 1 | // The key hierarchy's derivations, browser side. |
| 2 | // |
| 3 | // This is the JS half of the contract in vectors/contra-v1.json. Go, this, |
| 4 | // and Swift must produce identical bytes for every label below; the vector |
| 5 | // suite is what proves it, and cross-platform KDF drift is the bug class |
| 6 | // behind the 1Password PRF mismatch that shaped our recovery design. |
| 7 | // |
| 8 | // Changing anything here is changing the protocol. |
| 9 | |
| 10 | import { concat, enc } from "./bytes.js"; |
| 11 | |
| 12 | // The versioned HKDF labels. Every derivation carries one, so holding the |
| 13 | // output of one branch says nothing about any other. |
| 14 | export const LABEL_LOGIN = "contra/v1/login"; |
| 15 | export const LABEL_KEK = "contra/v1/kek"; |
| 16 | export const LABEL_RC_LOGIN = "contra/v1/rc-login"; |
| 17 | export const LABEL_RC_KEK = "contra/v1/rc-kek"; |
| 18 | export const LABEL_PRF_KEK = "contra/v1/prf-kek"; |
| 19 | export const LABEL_KCV = "contra/v1/kcv"; |
| 20 | export const LABEL_EC_P256 = "contra/v1/ec-p256"; |
| 21 | export const LABEL_AAD = "contra/v1/aad"; |
| 22 | |
| 23 | // The client's hard floor on KDF parameters, from PROTOCOL.md. |
| 24 | // |
| 25 | // Without it a malicious server serves m=8KiB, t=1, harvests one challenge |
| 26 | // signature, and cracks the passphrase cheaply offline. The floor is the |
| 27 | // defence, not the ceremony — so it is enforced here, on parameters the |
| 28 | // server sent, before a single expensive byte is derived. |
| 29 | export const ARGON2_MIN_MEMORY_KIB = 64 * 1024; |
| 30 | export const ARGON2_MIN_TIME = 3; |
| 31 | export const ARGON2_LANES = 1; |
| 32 | |
| 33 | export class WeakParametersError extends Error {} |
| 34 | |
| 35 | // checkParams refuses anything below the floor, and refuses lanes != 1 |
| 36 | // outright: Argon2id's output depends on p, and WASM without |
| 37 | // SharedArrayBuffer cannot parallelise, so any other value is a set this |
| 38 | // browser could not reproduce byte-identically even if it wanted to. |
| 39 | export function checkParams(p) { |
| 40 | if (!p || p.alg !== "argon2id") { |
| 41 | throw new WeakParametersError(`contra: unknown KDF ${p && p.alg}`); |
| 42 | } |
| 43 | if (!(p.m_kib >= ARGON2_MIN_MEMORY_KIB)) { |
| 44 | throw new WeakParametersError( |
| 45 | `contra: server offered m=${p.m_kib}KiB, below the floor of ${ARGON2_MIN_MEMORY_KIB}KiB`, |
| 46 | ); |
| 47 | } |
| 48 | if (!(p.t >= ARGON2_MIN_TIME)) { |
| 49 | throw new WeakParametersError( |
| 50 | `contra: server offered t=${p.t}, below the floor of ${ARGON2_MIN_TIME}`, |
| 51 | ); |
| 52 | } |
| 53 | if (p.p !== ARGON2_LANES) { |
| 54 | throw new WeakParametersError(`contra: server offered p=${p.p}, only 1 is reproducible`); |
| 55 | } |
| 56 | return p; |
| 57 | } |
| 58 | |
| 59 | // hkdf is RFC 5869 extract-then-expand over SHA-256 — the one mode this |
| 60 | // protocol uses. |
| 61 | // |
| 62 | // An absent salt is passed as 32 zero bytes rather than as an empty array. |
| 63 | // RFC 5869 §2.2 defines the two as the same thing ("if not provided, it is |
| 64 | // set to a string of HashLen zeros"), and HMAC pads any key shorter than its |
| 65 | // block size with zeros, so the derived bytes are identical either way — the |
| 66 | // golden vectors prove it, since they were generated with a zero-LENGTH salt |
| 67 | // and still pass. |
| 68 | // |
| 69 | // The reason to prefer the explicit form is engine behaviour, not |
| 70 | // correctness: an empty BufferSource is the kind of edge case a WebCrypto |
| 71 | // implementation is entitled to refuse, and Safari refusing something in this |
| 72 | // derivation is exactly the failure that sent us looking. |
| 73 | const ZERO_SALT = new Uint8Array(32); |
| 74 | |
| 75 | export async function hkdf(ikm, salt, info, length = 32) { |
| 76 | const key = await crypto.subtle.importKey("raw", ikm, "HKDF", false, ["deriveBits"]); |
| 77 | const bits = await crypto.subtle.deriveBits( |
| 78 | { |
| 79 | name: "HKDF", |
| 80 | hash: "SHA-256", |
| 81 | salt: salt && salt.length ? salt : ZERO_SALT, |
| 82 | info: typeof info === "string" ? enc.encode(info) : info, |
| 83 | }, |
| 84 | key, |
| 85 | length * 8, |
| 86 | ); |
| 87 | return new Uint8Array(bits); |
| 88 | } |
| 89 | |
| 90 | export async function hmacSha256(key, message) { |
| 91 | const k = await crypto.subtle.importKey( |
| 92 | "raw", key, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); |
| 93 | return new Uint8Array(await crypto.subtle.sign("HMAC", k, message)); |
| 94 | } |
| 95 | |
| 96 | // kcv is the 16-byte key-check value every wrap row carries. |
| 97 | // |
| 98 | // AES-GCM is not key-committing, so this is the commitment check. It also |
| 99 | // distinguishes "wrong key" from "corrupted blob", which is what lets a |
| 100 | // passkey provider returning a mismatched PRF be treated as *fast unlock |
| 101 | // unavailable* rather than reported as an authentication failure — the |
| 102 | // 1Password-class failure PROTOCOL.md requires us to tolerate. |
| 103 | export async function kcv(kek) { |
| 104 | return (await hmacSha256(kek, enc.encode(LABEL_KCV))).slice(0, 16); |
| 105 | } |
| 106 | |
| 107 | export async function sha256(b) { |
| 108 | return new Uint8Array(await crypto.subtle.digest("SHA-256", b)); |
| 109 | } |
| 110 | |
| 111 | // blindIndex is the identity anchor: HMAC of the normalised address under a |
| 112 | // pepper. It proves identity for sign-in and can never decrypt anything. |
| 113 | // |
| 114 | // The browser only ever computes this when the server hands it the pepper, |
| 115 | // which it does not — so this exists for tests and for the daemon. The |
| 116 | // instance computes the index; nothing stores the address. |
| 117 | export async function blindIndex(pepper, email) { |
| 118 | return hmacSha256(pepper, enc.encode(normalizeEmail(email))); |
| 119 | } |
| 120 | |
| 121 | // normalizeEmail is deliberately minimal: trim and lowercase, nothing more. |
| 122 | // Anything cleverer (stripping dots, cutting +tags) would silently merge two |
| 123 | // addresses a user believes are separate accounts, and there is no recovery |
| 124 | // path from having merged two vaults. |
| 125 | export function normalizeEmail(email) { |
| 126 | return email.trim().toLowerCase(); |
| 127 | } |
| 128 | |
| 129 | export { concat }; |
| 130 | |