| 1 | // Reissuing the recovery code. |
| 2 | // |
| 3 | // The Account Key card reprints exactly, because devices cache the key — that |
| 4 | // needs no crypto and no server, and lives in the page. This module is the |
| 5 | // other document, which cannot be reprinted at all. |
| 6 | // |
| 7 | // Only the WRAP of the recovery code is stored, never the code. So there is |
| 8 | // nothing to print a second copy of, and "reprint" has to mean "issue a new |
| 9 | // one": a new code, a new W_rc sealed under a KEK derived from it, a new |
| 10 | // login key for the recovery branch, and the old pair deleted. |
| 11 | // |
| 12 | // That the old sheet stops working is not a side effect to be smoothed over. |
| 13 | // A reissue that left the old code live would leave two working keys to the |
| 14 | // vault with one of them lost somewhere, which is the opposite of what |
| 15 | // somebody reaching for this wants. |
| 16 | |
| 17 | import { randomBytes, toHex } from "./bytes.js"; |
| 18 | import { hkdf, kcv, LABEL_RC_KEK, LABEL_RC_LOGIN } from "./kdf.js"; |
| 19 | import { deriveP256 } from "./p256.js"; |
| 20 | import { aad, seal, OBJECT_WRAP_PW, OBJECT_WRAP_RC } from "./sealed.js"; |
| 21 | import { opener } from "./vaultkeys.js"; |
| 22 | |
| 23 | // reissueRecovery opens the vault under the passphrase and the cached Account |
| 24 | // Key, mints a new recovery code, and replaces the recovery branch with one |
| 25 | // derived from it. |
| 26 | // |
| 27 | // It returns the new code because this is the only moment it exists anywhere. |
| 28 | // The caller's job is the same as signup's: put it in front of a person and |
| 29 | // do not pretend it can be fetched again. |
| 30 | // |
| 31 | // The order matters. RK is opened FIRST, so a wrong passphrase fails before |
| 32 | // anything is sent and the old sheet is still the working one. Only once the |
| 33 | // new wrap is stored does the old code stop working — there is no window in |
| 34 | // which neither opens the vault. |
| 35 | export async function reissueRecovery(bootstrap, passphrase, accountKey, net, |
| 36 | onProgress = () => {}) { |
| 37 | onProgress("stretch your passphrase"); |
| 38 | const o = await opener(bootstrap, passphrase); |
| 39 | |
| 40 | // Checked before the expensive part and before anything leaves, so a |
| 41 | // mistyped passphrase costs one stretch and changes nothing. |
| 42 | if (!await o.accepts(accountKey)) { |
| 43 | throw new Error( |
| 44 | "contra: that passphrase and this device's Account Key do not open " + |
| 45 | "this account"); |
| 46 | } |
| 47 | |
| 48 | onProgress("open your vault key"); |
| 49 | const { rk, accountId } = await o.open(accountKey); |
| 50 | |
| 51 | onProgress("mint a new recovery code"); |
| 52 | const recoveryCode = randomBytes(16); |
| 53 | const seedRC = await hkdf(recoveryCode, null, LABEL_RC_LOGIN, 32); |
| 54 | const lkRC = await deriveP256(seedRC); |
| 55 | const kekRC = await hkdf(recoveryCode, null, LABEL_RC_KEK, 32); |
| 56 | |
| 57 | onProgress("seal your vault key to it"); |
| 58 | const wrapId = randomBytes(16); |
| 59 | const nonce = randomBytes(12); |
| 60 | const ciphertext = await seal( |
| 61 | kekRC, nonce, aad(accountId, OBJECT_WRAP_RC, wrapId, 1), rk); |
| 62 | |
| 63 | // The KDF parameters come from the wrap that is already stored rather than |
| 64 | // from a constant here, so a reissue cannot quietly move an account onto a |
| 65 | // different set from the one its passphrase branch was built with. |
| 66 | const pw = bootstrap.wraps.find((w) => w.kind === OBJECT_WRAP_PW); |
| 67 | |
| 68 | onProgress("replace the old sheet"); |
| 69 | await net.post("/api/recovery", { |
| 70 | wrap_id_hex: toHex(wrapId), |
| 71 | kcv_hex: toHex(await kcv(kekRC)), |
| 72 | kdf_params: pw.kdf_params, |
| 73 | login_public_key_hex: toHex(lkRC.publicKey), |
| 74 | sealed: { nonce_hex: toHex(nonce), ciphertext_hex: toHex(ciphertext) }, |
| 75 | }); |
| 76 | |
| 77 | return { recoveryCode, accountId }; |
| 78 | } |
| 79 | |