carlos / contra Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Signup and passphrase sign-in, in the browser.
2//
3// This is the module that decides what the server ever learns, so read it as
4// the answer to that question. RK is generated here and never leaves except
5// as three sealed wraps. The passphrase never leaves at all. The server sees
6// public keys, ciphertext, and an address it turns into an index it cannot
7// reverse.
8
9import { concat, fromHex, randomBytes, toHex } from "./bytes.js";
10import { argon2id } from "./argon2id.js";
11import {
12 checkParams, hkdf, kcv,
13 LABEL_KEK, LABEL_LOGIN, LABEL_RC_KEK, LABEL_RC_LOGIN, LABEL_PRF_KEK,
14} from "./kdf.js";
15import { deriveP256, signChallenge } from "./p256.js";
16import { aad, seal, OBJECT_WRAP_PW, OBJECT_WRAP_RC } from "./sealed.js";
17import { accountKeys, provision } from "./vaultkeys.js";
18
19const CHALLENGE_CONTEXT = "contra/v1/login-challenge";
20
21export class AlreadyRegisteredError extends Error {}
22
23async function postJSON(path, body) {
24 const res = await fetch(path, {
25 method: "POST",
26 headers: { "Content-Type": "application/json" },
27 body: JSON.stringify(body),
28 });
29 const data = await res.json().catch(() => ({}));
30 if (res.status === 409) {
31 // Worth its own type. A half-finished signup leaves the account created
32 // and the kit unshown, and the retry lands here — so this is exactly the
33 // moment somebody needs to be told to sign in rather than told a status
34 // code.
35 throw new AlreadyRegisteredError("There is already an account for that address.");
36 }
37 if (!res.ok) throw new Error(data.error || `request to ${path} failed`);
38 return data;
39}
40
41// A wrap row as the server stores it: a format version, the KDF parameters
42// (authenticated as AAD), a KCV, a 96-bit nonce and the ciphertext.
43//
44// The wrap id is minted here and sent, because it is bound into the AAD
45// before the server sees the ciphertext. A server that minted its own would
46// store a row whose binding never matches — a wrap that can never be opened.
47async function makeWrap(kind, kek, rk, accountId, kdfParams, credSalt) {
48 const wrapId = randomBytes(16);
49 const nonce = randomBytes(12);
50 const ciphertext = await seal(kek, nonce, aad(accountId, kind, wrapId, 1), rk);
51 return {
52 kind,
53 kdf_params: kdfParams,
54 version: 1,
55 kcv_hex: toHex(await kcv(kek)),
56 cred_salt_hex: credSalt ? toHex(credSalt) : "",
57 nonce_hex: toHex(nonce),
58 ciphertext_hex: toHex(ciphertext),
59 wrap_id_hex: toHex(wrapId),
60 };
61}
62
63// signup builds the whole hierarchy client-side and posts only its sealed
64// halves.
65//
66// By the time this runs the address is already proved and the session already
67// says which one — so there is no email argument here, and no way for this
68// step to create an account for an address nobody confirmed.
69//
70// It returns the two printed values because this is the ONLY time either is
71// available. The recovery code is not stored anywhere, by anyone, including
72// us: only the wrap it opens. The caller's next job is to put them in front
73// of a person and refuse to move on until they say they have them.
74//
75// The progress messages read as actions, because the failure path turns them
76// into "Could not <message>" — so a person is told what did not happen rather
77// than which function threw.
78export async function signup(passphrase, kdfParams, storage, net, onProgress = () => {}) {
79 // The floor, before a single expensive byte. A malicious server offering
80 // m=8KiB would otherwise harvest one challenge signature and crack the
81 // passphrase cheaply offline.
82 const params = checkParams(JSON.parse(kdfParams));
83
84 const rk = randomBytes(32);
85 const accountKey = randomBytes(16);
86 const recoveryCode = randomBytes(16);
87 const salt = randomBytes(16);
88 const accountId = randomBytes(16);
89
90 onProgress("stretch your passphrase");
91 const sPW = argon2id(new TextEncoder().encode(passphrase), salt, params, 32);
92
93 onProgress("build your keys");
94 const seedLogin = await hkdf(sPW, null, LABEL_LOGIN, 32);
95 const lkPW = await deriveP256(seedLogin);
96 const kekPW = await hkdf(sPW, accountKey, LABEL_KEK, 32);
97
98 const seedRC = await hkdf(recoveryCode, null, LABEL_RC_LOGIN, 32);
99 const lkRC = await deriveP256(seedRC);
100 const kekRC = await hkdf(recoveryCode, null, LABEL_RC_KEK, 32);
101
102 onProgress("seal your vault key");
103 const wraps = [
104 await makeWrap(OBJECT_WRAP_PW, kekPW, rk, accountId, kdfParams, null),
105 await makeWrap(OBJECT_WRAP_RC, kekRC, rk, accountId, kdfParams, null),
106 ];
107
108 onProgress("create your account");
109 await postJSON("/auth/complete", {
110 account_id_hex: toHex(accountId),
111 salt_hex: toHex(salt),
112 login_keys: [
113 { branch: "pw", public_key_hex: toHex(lkPW.publicKey) },
114 { branch: "rc", public_key_hex: toHex(lkRC.publicKey) },
115 ],
116 wraps,
117 });
118
119 // /auth/complete signed us in, so provisioning needs no second ceremony —
120 // and no second Argon2id.
121 onProgress("open your vault");
122 const vault = await provision(net, accountId, rk);
123
124 // Best effort. Safari refuses localStorage outright in some
125 // configurations, and a throw here would happen AFTER the account exists,
126 // losing the one screen the recovery code is ever shown on. A device that
127 // cannot remember the Account Key is a device that asks for the card next
128 // time, which is a working state.
129 let cached = true;
130 try {
131 accountKeys(storage).set(toHex(accountId), accountKey);
132 } catch {
133 cached = false;
134 }
135
136 return { accountId, accountKey, recoveryCode, rk, vault, cached };
137}
138
139// signIn runs the ceremony: ask for a challenge, stretch, derive, sign.
140//
141// The passphrase is never sent. What crosses the wire is a signature over
142// bytes the server chose, which is why a compromised server learns nothing it
143// could replay.
144export async function signIn(email, passphrase, onProgress = () => {}) {
145 const { challenge_hex: challengeHex, salt_hex: saltHex, kdf_params: kdfParams } =
146 await postJSON("/auth/challenge", { email, branch: "pw" });
147
148 const params = checkParams(JSON.parse(kdfParams));
149
150 onProgress("stretching your passphrase");
151 const sPW = argon2id(new TextEncoder().encode(passphrase), fromHex(saltHex), params, 32);
152
153 onProgress("proving it is you");
154 const lkPW = await deriveP256(await hkdf(sPW, null, LABEL_LOGIN, 32));
155
156 const challenge = fromHex(challengeHex);
157 const message = concat(
158 new TextEncoder().encode(CHALLENGE_CONTEXT), new Uint8Array([0]),
159 new TextEncoder().encode("pw"), new Uint8Array([0]),
160 challenge,
161 );
162 const signature = await signChallenge(lkPW.privateKey, message);
163
164 return postJSON("/auth/verify", {
165 email,
166 branch: "pw",
167 challenge_hex: challengeHex,
168 signature_hex: toHex(signature),
169 });
170}
171
172export { LABEL_PRF_KEK };
173