carlos / contra Public

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

Plain git — no account needed to clone.

Download

Download this file

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
17import { randomBytes, toHex } from "./bytes.js";
18import { hkdf, kcv, LABEL_RC_KEK, LABEL_RC_LOGIN } from "./kdf.js";
19import { deriveP256 } from "./p256.js";
20import { aad, seal, OBJECT_WRAP_PW, OBJECT_WRAP_RC } from "./sealed.js";
21import { 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.
35export 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