| 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 | |
| 14 | import { net } from "../crypto/net.js"; |
| 15 | import { randomBytes, toHex } from "../crypto/bytes.js"; |
| 16 | import { hkdf, kcv, LABEL_PRF_KEK } from "../crypto/kdf.js"; |
| 17 | import { aad, seal, OBJECT_WRAP_PRF } from "../crypto/sealed.js"; |
| 18 | import { 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. |
| 23 | function 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. |
| 36 | function accountHandle(accountIdHex) { |
| 37 | return accountIdHex.slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, "$1-$2"); |
| 38 | } |
| 39 | |
| 40 | const box = document.querySelector("[data-status]"); |
| 41 | const button = document.querySelector("[data-add-passkey]"); |
| 42 | |
| 43 | function 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. |
| 52 | if (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 | |
| 58 | function 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 | |
| 83 | async 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 | |
| 182 | if (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 | |