carlos / contra Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Step 3: add a passkey, optionally.
2//
3// A passkey here is a third wrap of the same root key — never a shortcut
4// around it. The passphrase wrap and the recovery wrap are not touched and
5// are never removed, so a passkey that is lost, deleted, or that stops
6// returning the same PRF cannot strand anybody. That is why every failure on
7// this screen ends with "carry on" rather than "try again".
8//
9// It asks for the passphrase because it genuinely needs to: wrapping RK
10// requires RK, and getting RK means unlocking. That is the same rule the
11// vault screen follows, and pretending otherwise would mean holding the root
12// key somewhere across a page load.
13
14import { net } from "../crypto/net.js";
15import { randomBytes, toHex } from "../crypto/bytes.js";
16import { hkdf, kcv, LABEL_PRF_KEK } from "../crypto/kdf.js";
17import { aad, seal, OBJECT_WRAP_PRF } from "../crypto/sealed.js";
18import { accountKeys, unlock } from "../crypto/vaultkeys.js";
19
20// signupEmail is the address typed at step one, in this tab, if the ceremony
21// is finishing in the browser it started in. Read once and then forgotten:
22// there is no reason for it to outlive the credential it labels.
23function signupEmail() {
24 try {
25 const key = "contra/v1/signup-email";
26 const value = window.sessionStorage.getItem(key);
27 if (value) window.sessionStorage.removeItem(key);
28 return value;
29 } catch {
30 return null;
31 }
32}
33
34// accountHandle is the fallback label: the same short form the Emergency Kit
35// prints, so a passkey and a printed card name the same account the same way.
36function accountHandle(accountIdHex) {
37 return accountIdHex.slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, "$1-$2");
38}
39
40const box = document.querySelector("[data-status]");
41const button = document.querySelector("[data-add-passkey]");
42
43function say(text, kind = "working") {
44 if (!box) return;
45 const p = document.createElement("p");
46 p.setAttribute("data-kind", kind);
47 p.append(document.createTextNode(text));
48 box.replaceChildren(p);
49}
50
51// A passkey step that cannot work should say so before asking for anything.
52if (button && !window.PublicKeyCredential) {
53 button.disabled = true;
54 say("This browser does not support passkeys. Your passphrase works exactly " +
55 "as it does now — carry on.", "notice");
56}
57
58function ask(label) {
59 return new Promise((resolve) => {
60 const form = document.createElement("form");
61 const input = document.createElement("input");
62 input.type = "password";
63 input.name = "passphrase";
64 input.autocomplete = "current-password";
65 input.required = true;
66 const p = document.createElement("p");
67 p.append(document.createTextNode(label + " "), input, " ");
68 const go = document.createElement("button");
69 go.type = "submit";
70 go.append(document.createTextNode("Unlock"));
71 p.append(go);
72 form.append(p);
73 form.addEventListener("submit", (e) => {
74 e.preventDefault();
75 resolve(input.value);
76 form.remove();
77 });
78 box.replaceChildren(form);
79 input.focus();
80 });
81}
82
83async function enrol() {
84 button.disabled = true;
85
86 const bootstrap = await net.get("/api/bootstrap");
87 const accountKey = accountKeys(window.localStorage).get(bootstrap.account_id_hex);
88 if (!accountKey) {
89 say("This device does not have your Account Key, so it cannot open your " +
90 "vault to add a passkey. Your passphrase still works — carry on.", "notice");
91 return;
92 }
93
94 const passphrase = await ask("Your passphrase, once, to unlock:");
95 say("Unlocking…");
96 const opened = await unlock(bootstrap, passphrase, accountKey);
97
98 // cred_salt is 32 non-secret random bytes, stored server-side and used as
99 // the PRF's input. It is not a secret and does not need to be one.
100 const credSalt = randomBytes(32);
101
102 // What a password manager shows. The site line comes from the domain and
103 // is not ours to choose — on contra.bdj.oncarlos.com it reads "oncarlos",
104 // and only a custom domain changes that. The account line is user.name and
105 // user.displayName, which are ours, so they carry the address and the
106 // product name.
107 const label = signupEmail() || `account ${accountHandle(bootstrap.account_id_hex)}`;
108
109 say("Waiting for your passkey…");
110 // userVerification is required in every ceremony. CTAP2 keeps distinct
111 // with-UV and without-UV secrets, and mixed UV policy is a known cause of a
112 // provider returning a different PRF for the same credential.
113 const created = await navigator.credentials.create({
114 publicKey: {
115 challenge: randomBytes(32),
116 rp: { name: "CONTRA" },
117 user: {
118 id: opened.accountId,
119 name: label,
120 displayName: `${label} CONTRA`,
121 },
122 pubKeyCredParams: [
123 { type: "public-key", alg: -7 },
124 { type: "public-key", alg: -257 },
125 ],
126 authenticatorSelection: { userVerification: "required", residentKey: "preferred" },
127 extensions: { prf: {} },
128 },
129 });
130 if (!created) throw new Error("no credential was created");
131
132 const supported = created.getClientExtensionResults?.().prf?.enabled;
133 if (supported === false) {
134 say("Your passkey provider cannot do the part we need (a PRF). The passkey " +
135 "was not kept. Your passphrase works exactly as it does now — carry on.",
136 "notice");
137 return;
138 }
139
140 // One assertion, both to obtain the PRF output and to prove the credential
141 // is present. The output is what the fast-unlock wrap is keyed on.
142 say("Confirming with your passkey…");
143 const asserted = await navigator.credentials.get({
144 publicKey: {
145 challenge: randomBytes(32),
146 allowCredentials: [{ type: "public-key", id: created.rawId }],
147 userVerification: "required",
148 extensions: { prf: { eval: { first: credSalt } } },
149 },
150 });
151 const prf = asserted?.getClientExtensionResults?.().prf?.results?.first;
152 if (!prf) {
153 say("Your passkey provider did not return a PRF, so a fast unlock is not " +
154 "available with it. Nothing has changed and your passphrase still " +
155 "works — carry on.", "notice");
156 return;
157 }
158
159 say("Saving…");
160 const kekPRF = await hkdf(new Uint8Array(prf), credSalt, LABEL_PRF_KEK, 32);
161 const wrapId = randomBytes(16);
162 const nonce = randomBytes(12);
163 const sealed = await seal(kekPRF, nonce,
164 aad(opened.accountId, OBJECT_WRAP_PRF, wrapId, 1), opened.rk);
165
166 await net.post("/api/passkey", {
167 wrap_id_hex: toHex(wrapId),
168 credential_id_hex: toHex(new Uint8Array(created.rawId)),
169 cred_salt_hex: toHex(credSalt),
170 kcv_hex: toHex(await kcv(kekPRF)),
171 // The PRF branch has no Argon2id and no server-supplied parameters at
172 // all: the input is already full-entropy, so there is nothing to tune and
173 // nothing for a malicious server to downgrade.
174 kdf_params: '{"alg":"prf-hkdf","v":1}',
175 sealed: { nonce_hex: toHex(nonce), ciphertext_hex: toHex(sealed) },
176 });
177
178 say("Passkey added. It is an extra way in, not a replacement — your " +
179 "passphrase and your recovery sheet are unchanged.", "done");
180}
181
182if (button) {
183 button.addEventListener("click", async () => {
184 try {
185 await enrol();
186 } catch (err) {
187 // Never a dead end. Every screen that offers a passkey offers the
188 // passphrase beside it, and this one is skippable by design.
189 console.error("CONTRA passkey enrolment failed", err);
190 const name = err && err.name && err.name !== "Error" ? `${err.name}: ` : "";
191 say(`Could not add a passkey. ${name}${err.message} Your passphrase works ` +
192 "exactly as it does now — carry on to your vault.", "error");
193 } finally {
194 button.disabled = false;
195 }
196 });
197}
198