carlos / contra Public

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

Plain git — no account needed to clone.

Download

Download this file

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
10import { 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.
14export const LABEL_LOGIN = "contra/v1/login";
15export const LABEL_KEK = "contra/v1/kek";
16export const LABEL_RC_LOGIN = "contra/v1/rc-login";
17export const LABEL_RC_KEK = "contra/v1/rc-kek";
18export const LABEL_PRF_KEK = "contra/v1/prf-kek";
19export const LABEL_KCV = "contra/v1/kcv";
20export const LABEL_EC_P256 = "contra/v1/ec-p256";
21export 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.
29export const ARGON2_MIN_MEMORY_KIB = 64 * 1024;
30export const ARGON2_MIN_TIME = 3;
31export const ARGON2_LANES = 1;
32
33export 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.
39export 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.
73const ZERO_SALT = new Uint8Array(32);
74
75export 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
90export 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.
103export async function kcv(kek) {
104 return (await hmacSha256(kek, enc.encode(LABEL_KCV))).slice(0, 16);
105}
106
107export 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.
117export 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.
125export function normalizeEmail(email) {
126 return email.trim().toLowerCase();
127}
128
129export { concat };
130