web/5: the reprint flow, and the nag
DESIGN.md's mitigation for the loss mode the Account Key introduced, and the second half of discussion 11. The two documents behave differently and the page is built around that difference rather than hiding it. The Account Key card reprints EXACTLY: devices cache the key, so a new card is the same key on new paper and the old card keeps working. Pure client, no server, no consequence. The recovery sheet cannot be reprinted at all. Only its wrap is stored, so there is nothing to print a second copy of — "reprint" has to mean issue a new one, and the old code has to stop working. A reissue that left it live would leave two working keys to the vault with one of them lost somewhere, which is the opposite of what somebody reaching for this wants. So ReplaceRecoveryWrap is the one write in the app that deletes, and it is shaped so it cannot strand anybody: - One transaction: old wrap and old login key out, new ones in. There is no window in which neither code opens the vault. - The passphrase branch is not in it. W_pw and W_rc are never removed, and nothing here can reach W_pw. - RK is opened in the browser BEFORE anything is sent, so a wrong passphrase costs one Argon2id and changes nothing. - The recovery LOGIN key is replaced too. Leaving it would let the old sheet pass sign-in and fail at unlock, which is a much worse way to find out. The warning is above the control, the control needs an explicit tick, and a test asserts that ORDER — this is the one screen where wording is a safety feature and an edit that moved it would otherwise be invisible. The nag is the other half of row six of DESIGN.md's loss table. It says the true thing: this browser cannot know whether another one holds the key, because nothing about other devices is stored anywhere we could ask. Tests: the new code opens the vault and the old one does not; the passphrase branch is untouched; another account's recovery wrap survives (this is a delete, so a dropped owner filter would not leak data, it would destroy somebody's only spare key); and the greps gained the reissue, which is the path most likely to leave old bytes in a freed page.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
12 files changed,
+761
−1
internal/contra/app.go+8 −0internal/contra/handlers.go+7 −0internal/contra/render.go+1 −1internal/contra/static/crypto/reissue.js+78 −0internal/contra/static/pages/kit.js+156 −0internal/contra/store/recovery.go+94 −0internal/contra/templates/kit.html+86 −0internal/contra/templates/vault.html+7 −0internal/contra/vault/recovery.go+61 −0internal/contratest/greps_test.go+34 −0internal/contratest/jsmodules_test.go+2 −0internal/contratest/recovery_test.go+227 −0
diff --git a/internal/contra/app.go b/internal/contra/app.go| index 6499817..2f022ba 100644 |
| --- a/internal/contra/app.go |
| +++ b/internal/contra/app.go |
| @@ -124,6 +124,14 @@ func App(d *db.DB, cfg Config, logger *slog.Logger) (*http.ServeMux, error) { |
| // alone and only then finds it has no Account Key — so every one of |
| // these is scoped to the session, which is what makes a code short |
| // enough to read aloud safe to use. |
| + // The printed kit: reprint the Account Key card exactly, or reissue |
| + // the recovery code — which regenerates rather than reprints, because |
| + // only its wrap is stored. DESIGN.md wants this obvious rather than |
| + // buried: it is the mitigation for the one loss mode the Account Key |
| + // introduced. |
| + r.Get("/kit", a.kit) |
| + r.Post("/api/recovery", items.RegenerateRecovery) |
| + |
| r.Get("/device", a.device) |
| r.Post("/api/link/start", items.StartLink) |
| r.Get("/api/link/pending", items.PendingLink) |
diff --git a/internal/contra/handlers.go b/internal/contra/handlers.go| index f4ebd16..4f29e46 100644 |
| --- a/internal/contra/handlers.go |
| +++ b/internal/contra/handlers.go |
| @@ -104,3 +104,10 @@ func (a *app) vault(w http.ResponseWriter, r *http.Request) { |
| func (a *app) device(w http.ResponseWriter, r *http.Request) { |
| render(w, "device", a.pageData(r)) |
| } |
| + |
| +// kit is the reprint flow. DESIGN.md: if either document is lost while a |
| +// device is still enrolled, producing a new one must be obvious rather than |
| +// buried — it is the mitigation for the loss mode the Account Key introduced. |
| +func (a *app) kit(w http.ResponseWriter, r *http.Request) { |
| + render(w, "kit", a.pageData(r)) |
| +} |
diff --git a/internal/contra/render.go b/internal/contra/render.go| index 813a297..ddd7dae 100644 |
| --- a/internal/contra/render.go |
| +++ b/internal/contra/render.go |
| @@ -26,7 +26,7 @@ var assets = rastrillo.NewAssets(appFS) |
| var pages = map[string]*template.Template{} |
| func init() { |
| - for _, name := range []string{"index", "errors", "signup", "signup_sent", "signup_passphrase", "signup_passkey", "signin", "vault", "device"} { |
| + for _, name := range []string{"index", "errors", "signup", "signup_sent", "signup_passphrase", "signup_passkey", "signin", "vault", "device", "kit"} { |
| pages[name] = template.Must( |
| // ui's partials and their helpers first, so every page can |
| // call one without registering anything. WithIcons points |
diff --git a/internal/contra/static/crypto/reissue.js b/internal/contra/static/crypto/reissue.js| new file mode 100644 |
| index 0000000..018bcd1 |
| --- /dev/null |
| +++ b/internal/contra/static/crypto/reissue.js |
| @@ -0,0 +1,78 @@ |
| +// Reissuing the recovery code. |
| +// |
| +// The Account Key card reprints exactly, because devices cache the key — that |
| +// needs no crypto and no server, and lives in the page. This module is the |
| +// other document, which cannot be reprinted at all. |
| +// |
| +// Only the WRAP of the recovery code is stored, never the code. So there is |
| +// nothing to print a second copy of, and "reprint" has to mean "issue a new |
| +// one": a new code, a new W_rc sealed under a KEK derived from it, a new |
| +// login key for the recovery branch, and the old pair deleted. |
| +// |
| +// That the old sheet stops working is not a side effect to be smoothed over. |
| +// A reissue that left the old code live would leave two working keys to the |
| +// vault with one of them lost somewhere, which is the opposite of what |
| +// somebody reaching for this wants. |
| + |
| +import { randomBytes, toHex } from "./bytes.js"; |
| +import { hkdf, kcv, LABEL_RC_KEK, LABEL_RC_LOGIN } from "./kdf.js"; |
| +import { deriveP256 } from "./p256.js"; |
| +import { aad, seal, OBJECT_WRAP_PW, OBJECT_WRAP_RC } from "./sealed.js"; |
| +import { opener } from "./vaultkeys.js"; |
| + |
| +// reissueRecovery opens the vault under the passphrase and the cached Account |
| +// Key, mints a new recovery code, and replaces the recovery branch with one |
| +// derived from it. |
| +// |
| +// It returns the new code because this is the only moment it exists anywhere. |
| +// The caller's job is the same as signup's: put it in front of a person and |
| +// do not pretend it can be fetched again. |
| +// |
| +// The order matters. RK is opened FIRST, so a wrong passphrase fails before |
| +// anything is sent and the old sheet is still the working one. Only once the |
| +// new wrap is stored does the old code stop working — there is no window in |
| +// which neither opens the vault. |
| +export async function reissueRecovery(bootstrap, passphrase, accountKey, net, |
| + onProgress = () => {}) { |
| + onProgress("stretch your passphrase"); |
| + const o = await opener(bootstrap, passphrase); |
| + |
| + // Checked before the expensive part and before anything leaves, so a |
| + // mistyped passphrase costs one stretch and changes nothing. |
| + if (!await o.accepts(accountKey)) { |
| + throw new Error( |
| + "contra: that passphrase and this device's Account Key do not open " + |
| + "this account"); |
| + } |
| + |
| + onProgress("open your vault key"); |
| + const { rk, accountId } = await o.open(accountKey); |
| + |
| + onProgress("mint a new recovery code"); |
| + const recoveryCode = randomBytes(16); |
| + const seedRC = await hkdf(recoveryCode, null, LABEL_RC_LOGIN, 32); |
| + const lkRC = await deriveP256(seedRC); |
| + const kekRC = await hkdf(recoveryCode, null, LABEL_RC_KEK, 32); |
| + |
| + onProgress("seal your vault key to it"); |
| + const wrapId = randomBytes(16); |
| + const nonce = randomBytes(12); |
| + const ciphertext = await seal( |
| + kekRC, nonce, aad(accountId, OBJECT_WRAP_RC, wrapId, 1), rk); |
| + |
| + // The KDF parameters come from the wrap that is already stored rather than |
| + // from a constant here, so a reissue cannot quietly move an account onto a |
| + // different set from the one its passphrase branch was built with. |
| + const pw = bootstrap.wraps.find((w) => w.kind === OBJECT_WRAP_PW); |
| + |
| + onProgress("replace the old sheet"); |
| + await net.post("/api/recovery", { |
| + wrap_id_hex: toHex(wrapId), |
| + kcv_hex: toHex(await kcv(kekRC)), |
| + kdf_params: pw.kdf_params, |
| + login_public_key_hex: toHex(lkRC.publicKey), |
| + sealed: { nonce_hex: toHex(nonce), ciphertext_hex: toHex(ciphertext) }, |
| + }); |
| + |
| + return { recoveryCode, accountId }; |
| +} |
diff --git a/internal/contra/static/pages/kit.js b/internal/contra/static/pages/kit.js| new file mode 100644 |
| index 0000000..caae9af |
| --- /dev/null |
| +++ b/internal/contra/static/pages/kit.js |
| @@ -0,0 +1,156 @@ |
| +// Reprinting the kit, and the nag that tries to stop anyone needing to. |
| +// |
| +// The two documents behave differently and this file keeps them apart on |
| +// purpose. The Account Key card is a pure reprint — same key, new paper, no |
| +// server, no consequence. The recovery sheet cannot be reprinted at all, only |
| +// reissued, and reissuing destroys the old one. Sharing a code path between |
| +// those two would be how a "print" button one day silently invalidated a |
| +// sheet in somebody's safe. |
| + |
| +import { net } from "../crypto/net.js"; |
| +import { formatCode, TAG_ACCOUNT_KEY, TAG_RECOVERY } from "../crypto/code.js"; |
| +import { accountKeyCard, recoverySheet } from "../crypto/kit.js"; |
| +import { reissueRecovery } from "../crypto/reissue.js"; |
| +import { accountKeys } from "../crypto/vaultkeys.js"; |
| + |
| +const $ = (sel, root = document) => root.querySelector(sel); |
| + |
| +function el(tag, attrs = {}, ...kids) { |
| + const node = document.createElement(tag); |
| + for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v); |
| + node.append(...kids.filter((k) => k !== null && k !== undefined)); |
| + return node; |
| +} |
| + |
| +function say(box, text, kind = "working") { |
| + box.replaceChildren(el("p", { "data-kind": kind }, text)); |
| +} |
| + |
| +// download is signup's, and one button per document for the same reason: |
| +// browsers refuse a second programmatic save from one gesture, Safari |
| +// silently. |
| +function download(bytes, filename) { |
| + const url = URL.createObjectURL(new Blob([bytes], { type: "application/pdf" })); |
| + const a = el("a", { href: url, download: filename }); |
| + document.body.append(a); |
| + a.click(); |
| + a.remove(); |
| + setTimeout(() => URL.revokeObjectURL(url), 10000); |
| +} |
| + |
| +async function main() { |
| + const cardSection = $("[data-card]"); |
| + if (!cardSection) return; |
| + |
| + const bootstrap = await net.get("/api/bootstrap"); |
| + const accountIdHex = bootstrap.account_id_hex; |
| + const short = accountIdHex.slice(0, 8); |
| + const keys = accountKeys(window.localStorage); |
| + const accountKey = keys.get(accountIdHex); |
| + |
| + wireCard(accountIdHex, short, accountKey); |
| + wireReissue(bootstrap, accountIdHex, short, accountKey); |
| + |
| + // The nag, DESIGN.md's row six. Shown when this browser holds the key and |
| + // has no way of knowing another one does — which is the honest state, since |
| + // nothing about other devices is stored anywhere we could ask. |
| + if (accountKey) $("[data-nag]").hidden = false; |
| +} |
| + |
| +function wireCard(accountIdHex, short, accountKey) { |
| + const status = $("[data-card-status]"); |
| + if (!accountKey) { |
| + $("[data-card-missing]").hidden = false; |
| + return; |
| + } |
| + $("[data-card-ready]").hidden = false; |
| + |
| + $("[data-print-card]").addEventListener("click", async () => { |
| + try { |
| + const printed = await formatCode(TAG_ACCOUNT_KEY, accountKey); |
| + download(accountKeyCard(accountIdHex, printed), |
| + `contra-account-key-${short}.pdf`); |
| + say(status, "Saved. It is the same key as the card you already have — " + |
| + "both work.", "ok"); |
| + } catch (err) { |
| + console.error("CONTRA could not reprint the Account Key card", err); |
| + say(status, "Could not build the card. The browser console has the " + |
| + "detail.", "error"); |
| + } |
| + }); |
| +} |
| + |
| +function wireReissue(bootstrap, accountIdHex, short, accountKey) { |
| + const form = $("[data-recovery-form]"); |
| + const status = $("[data-recovery-status]"); |
| + const button = $("[data-regenerate]"); |
| + const check = $("[data-confirm-recovery]"); |
| + |
| + // The button stays disabled until the checkbox is ticked. A destructive |
| + // action a person can reach by pressing Enter in a password field is a |
| + // destructive action somebody will reach by accident. |
| + check.addEventListener("change", () => { |
| + button.disabled = !check.checked; |
| + }); |
| + |
| + form.addEventListener("submit", async (e) => { |
| + e.preventDefault(); |
| + if (!check.checked) return; |
| + if (!accountKey) { |
| + say(status, "This device does not have your Account Key, so it cannot " + |
| + "open your vault key to seal a new code to it. Give it the key first.", |
| + "error"); |
| + return; |
| + } |
| + |
| + button.disabled = true; |
| + let step = "start"; |
| + try { |
| + const { recoveryCode } = await reissueRecovery( |
| + bootstrap, $("#recovery-passphrase").value, accountKey, net, |
| + (m) => { |
| + step = m; |
| + say(status, m + "…"); |
| + }); |
| + |
| + const printed = await formatCode(TAG_RECOVERY, recoveryCode); |
| + showNewSheet(printed, accountIdHex, short); |
| + say(status, "Done. The old sheet no longer opens this vault.", "ok"); |
| + form.hidden = true; |
| + } catch (err) { |
| + // Nothing was sent unless the last step reached the server, and the |
| + // failure modes before that leave the old sheet working — which is |
| + // what the person needs told, first. |
| + console.error("CONTRA could not reissue the recovery code while", step, err); |
| + say(status, `Could not issue a new sheet (failed while: ${step}). ` + |
| + "Your existing recovery sheet is unchanged and still works.", "error"); |
| + button.disabled = false; |
| + } |
| + }); |
| +} |
| + |
| +// The new sheet, shown exactly once — the same contract signup makes, and for |
| +// the same reason: nobody stores this, including us. |
| +function showNewSheet(printed, accountIdHex, short) { |
| + const box = $("[data-new-sheet]"); |
| + const save = el("button", { type: "button", "data-save": "recovery-sheet" }, |
| + "Save the new recovery sheet"); |
| + save.addEventListener("click", () => { |
| + download(recoverySheet(accountIdHex, printed), |
| + `contra-recovery-sheet-${short}.pdf`); |
| + save.replaceChildren(document.createTextNode("Save the new recovery sheet — saved")); |
| + save.setAttribute("data-saved", ""); |
| + }); |
| + |
| + box.replaceChildren( |
| + el("h3", {}, "Your new recovery sheet"), |
| + el("p", {}, "Shown once. Save it now and put it where the old one was — " + |
| + "then destroy the old one, which no longer opens anything."), |
| + el("pre", { "data-kit": "recovery" }, printed), |
| + el("p", {}, save)); |
| + box.hidden = false; |
| +} |
| + |
| +main().catch((err) => { |
| + console.error("CONTRA kit page failed to start", err); |
| +}); |
diff --git a/internal/contra/store/recovery.go b/internal/contra/store/recovery.go| new file mode 100644 |
| index 0000000..f10dba8 |
| --- /dev/null |
| +++ b/internal/contra/store/recovery.go |
| @@ -0,0 +1,94 @@ |
| +package store |
| + |
| +import ( |
| + "context" |
| + "time" |
| + |
| + "gorm.io/gorm" |
| + |
| + "github.com/carlosframework/contra/internal/contra/crypto" |
| + "github.com/carlosframework/contra/internal/contra/model" |
| +) |
| + |
| +// ReplaceRecoveryWrap swaps an account's recovery branch for a new one: a new |
| +// wrap of RK, and the login public key that goes with it. |
| +// |
| +// # Why this one deletes |
| +// |
| +// Everything else in this package is additive. DESIGN.md says the recovery |
| +// code is the exception, and says why: only its wrap is stored, never the |
| +// value, so it cannot be *reprinted* — it can only be *regenerated*. A new |
| +// sheet means a new code, and the old sheet has to stop working, or "reprint" |
| +// would quietly leave two live keys to the vault with one of them lost |
| +// somewhere. The old wrap and the old login key go in the same transaction as |
| +// the new ones arrive. |
| +// |
| +// The copy in front of this says so before the person commits. That is not |
| +// decoration: it is the difference between a feature and a data-loss bug. |
| +// |
| +// # What it does not do |
| +// |
| +// It never touches the passphrase wrap. DESIGN.md's rule is that W_pw and |
| +// W_rc are never removed — losing a passkey or a device must not strand |
| +// anybody — so a failure part-way through this must never be able to leave an |
| +// account with no way in. One transaction, and the passphrase branch is not |
| +// in it. |
| +func (s *Store) ReplaceRecoveryWrap(ctx context.Context, accountID, wrapID []byte, |
| + kdfParams string, version uint64, kcv []byte, sealed Sealed, loginPublicKey []byte) error { |
| + if !sealed.valid() || len(kcv) != 16 || len(wrapID) != 16 || len(accountID) != 16 { |
| + return ErrShortField |
| + } |
| + if len(loginPublicKey) != 65 || loginPublicKey[0] != 0x04 { |
| + return ErrShortField |
| + } |
| + |
| + loginID, err := NewID() |
| + if err != nil { |
| + return err |
| + } |
| + |
| + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| + // Refuse to work on an account that does not exist, rather than |
| + // leaving a wrap belonging to nobody. |
| + var count int64 |
| + if err := tx.Model(&model.Account{}). |
| + Where("id = ?", accountID).Count(&count).Error; err != nil { |
| + return err |
| + } |
| + if count == 0 { |
| + return gorm.ErrRecordNotFound |
| + } |
| + |
| + if err := tx.Where("account_id = ? AND kind = ?", accountID, crypto.ObjectWrapRC). |
| + Delete(&model.Wrap{}).Error; err != nil { |
| + return err |
| + } |
| + if err := tx.Where("account_id = ? AND branch = ?", accountID, "rc"). |
| + Delete(&model.LoginKey{}).Error; err != nil { |
| + return err |
| + } |
| + |
| + now := time.Now().UTC() |
| + if err := tx.Create(&model.Wrap{ |
| + ID: wrapID, |
| + AccountID: accountID, |
| + Kind: crypto.ObjectWrapRC, |
| + FormatVersion: FormatVersion, |
| + Version: version, |
| + KDFParams: kdfParams, |
| + KCV: kcv, |
| + Nonce: sealed.Nonce, |
| + Ciphertext: sealed.Ciphertext, |
| + CreatedAt: now, |
| + }).Error; err != nil { |
| + return err |
| + } |
| + return tx.Create(&model.LoginKey{ |
| + ID: loginID, |
| + AccountID: accountID, |
| + Branch: "rc", |
| + PublicKey: loginPublicKey, |
| + CreatedAt: now, |
| + }).Error |
| + }) |
| +} |
diff --git a/internal/contra/templates/kit.html b/internal/contra/templates/kit.html| new file mode 100644 |
| index 0000000..8e6dd47 |
| --- /dev/null |
| +++ b/internal/contra/templates/kit.html |
| @@ -0,0 +1,86 @@ |
| +{{define "title"}}Your printed kit — CONTRA{{end}} |
| +{{define "brand"}}<a rst-shell-brand href="/">CONTRA</a>{{end}} |
| +{{define "head"}}<script type="module" src="{{asset "static/pages/kit.js"}}"></script>{{end}} |
| + |
| +{{define "content"}} |
| +<h1>Your printed kit</h1> |
| + |
| +{{/* The two documents behave differently and the page says so up front, |
| + because the difference is the whole thing a person needs to understand |
| + before they press anything here. */}} |
| +<p>Two documents, and only one of them can be reprinted. That is not an |
| +oversight — it is what "nobody can open your vault for you" costs.</p> |
| + |
| +<section data-card> |
| + <h2>Account Key card</h2> |
| + <p>This <strong>reprints exactly</strong>. Your devices keep a copy of the |
| + Account Key, so a new card is the same key on new paper. The old card keeps |
| + working — throw it away or keep it, it makes no difference.</p> |
| + |
| + <div data-card-ready hidden> |
| + <p><button type="button" data-print-card>Print the Account Key card |
| + again</button></p> |
| + </div> |
| + |
| + <p data-card-missing hidden> |
| + This device does not have your Account Key, so it cannot print the card. |
| + <a href="/device">Give it the key</a> from another device or from the card |
| + you still have, and this will work. |
| + </p> |
| + |
| + <p data-card-status aria-live="polite"></p> |
| +</section> |
| + |
| +<section data-recovery> |
| + <h2>Recovery sheet</h2> |
| + |
| + {{/* The warning goes BEFORE the control, not beside it and not after it. |
| + This is the one destructive action in the app. */}} |
| + <p><strong>This cannot be reprinted.</strong> Nobody stores your recovery |
| + code — only a wrap that it opens — so there is nothing to print a second |
| + copy of. What this does instead is <strong>issue a new one</strong>.</p> |
| + |
| + <p data-kind="warning">When you do this, <strong>the code on your old |
| + recovery sheet stops working immediately</strong>. If you are not certain |
| + the old sheet is lost, find it first: doing this while it is still in the |
| + safe means shredding it and filing the new one.</p> |
| + |
| + <p>Your passphrase and your other devices are unaffected.</p> |
| + |
| + <form data-recovery-form method="post" action="#"> |
| + <p> |
| + <label for="recovery-passphrase">Passphrase</label> |
| + <input id="recovery-passphrase" name="passphrase" type="password" |
| + autocomplete="current-password" required> |
| + <small>Needed to open your vault key so the new code can be sealed to |
| + it. It is not sent anywhere.</small> |
| + </p> |
| + <p> |
| + <label> |
| + <input type="checkbox" data-confirm-recovery> |
| + I understand the old recovery sheet will stop working. |
| + </label> |
| + </p> |
| + <p><button type="submit" data-regenerate disabled>Issue a new recovery |
| + sheet</button></p> |
| + </form> |
| + |
| + <div data-new-sheet hidden></div> |
| + <p data-recovery-status aria-live="polite"></p> |
| +</section> |
| + |
| +<section data-nag hidden> |
| + <h2>Only one device</h2> |
| + {{/* DESIGN.md's loss table, row six: passphrase known, kit lost, every |
| + device gone. The obligation is not to soften the rule — it is to make |
| + sure nobody arrives there by accident. This is that. */}} |
| + <p>This is the only browser holding your Account Key. If you lose it and the |
| + printed card at the same time, the vault cannot be opened — by you, by us, by |
| + anyone.</p> |
| + <p>Two minutes of work makes that much less likely: |
| + <a href="/device">add a second device</a>, or check you still have the |
| + printed card.</p> |
| +</section> |
| + |
| +<p><a href="/vault">Back to your vault</a></p> |
| +{{end}} |
diff --git a/internal/contra/templates/vault.html b/internal/contra/templates/vault.html| index f15908c..ad73cf6 100644 |
| --- a/internal/contra/templates/vault.html |
| +++ b/internal/contra/templates/vault.html |
| @@ -22,6 +22,13 @@ |
| <div data-vault hidden></div> |
| +{{/* DESIGN.md: the reprint flow must be obvious rather than buried, and |
| + the nag to enrol a second device lives beside it. Both are the |
| + mitigation for the one loss mode the Account Key introduced. */}} |
| +<p><a href="/kit">Your printed kit</a> — reprint the Account Key card, or |
| +issue a new recovery sheet. <a href="/device">This device</a> — approve |
| +another browser, or give this one your Account Key.</p> |
| + |
| <p>Build <code>{{.Version}}</code>.</p> |
| <form method="post" action="/auth/signout"> |
diff --git a/internal/contra/vault/recovery.go b/internal/contra/vault/recovery.go| new file mode 100644 |
| index 0000000..26910a4 |
| --- /dev/null |
| +++ b/internal/contra/vault/recovery.go |
| @@ -0,0 +1,61 @@ |
| +package vault |
| + |
| +import ( |
| + "encoding/hex" |
| + "net/http" |
| + |
| + "github.com/carlosframework/contra/internal/contra/crypto" |
| +) |
| + |
| +// RegenerateRecovery replaces the recovery branch: a new wrap of RK sealed |
| +// under a KEK derived from a code the client just minted, and the login public |
| +// key for that same code. |
| +// |
| +// # This is the one destructive write in the app, and it is meant to be |
| +// |
| +// The recovery code is not stored — only its wrap is — so a lost sheet cannot |
| +// be reprinted, only reissued. That necessarily invalidates the old one, and |
| +// the alternative is worse: a "reprint" that left the old code live would |
| +// leave two working keys to the vault with one of them lost somewhere. The |
| +// page in front of this says so before the person commits. |
| +// |
| +// Everything that decides anything happened in the browser: the code was |
| +// minted there, RK was opened there under the passphrase and Account Key, and |
| +// what arrives here is a KCV, a public key and ciphertext. A server that |
| +// wanted to hand somebody a working recovery code cannot, because it never |
| +// sees RK — the worst it can do is refuse, or store a wrap that opens nothing, |
| +// and the KCV the client wrote is what catches the second. |
| +func (v *Vault) RegenerateRecovery(w http.ResponseWriter, r *http.Request) { |
| + acct, ok := accountID(r) |
| + if !ok { |
| + notFound(w) |
| + return |
| + } |
| + var req struct { |
| + WrapIDHex string `json:"wrap_id_hex"` |
| + KCVHex string `json:"kcv_hex"` |
| + KDFParams string `json:"kdf_params"` |
| + PublicKeyHex string `json:"login_public_key_hex"` |
| + Sealed sealedJSON `json:"sealed"` |
| + } |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + wrapID, e1 := hex.DecodeString(req.WrapIDHex) |
| + kcv, e2 := hex.DecodeString(req.KCVHex) |
| + publicKey, e3 := hex.DecodeString(req.PublicKeyHex) |
| + sealed, e4 := req.Sealed.decode() |
| + if e1 != nil || e2 != nil || e3 != nil || e4 != nil || |
| + len(publicKey) != crypto.PublicKeyLen { |
| + writeJSON(w, http.StatusBadRequest, |
| + map[string]string{"error": "bad recovery wrap"}) |
| + return |
| + } |
| + |
| + if err := v.store.ReplaceRecoveryWrap(r.Context(), acct, wrapID, |
| + req.KDFParams, 1, kcv, sealed, publicKey); err != nil { |
| + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]string{"wrap_id_hex": req.WrapIDHex}) |
| +} |
diff --git a/internal/contratest/greps_test.go b/internal/contratest/greps_test.go| index b5de733..daf8dd1 100644 |
| --- a/internal/contratest/greps_test.go |
| +++ b/internal/contratest/greps_test.go |
| @@ -315,6 +315,40 @@ func writeEverything(t *testing.T, s *store.Store) secrets { |
| t.Fatalf("StartLink(stranded): %v", err) |
| } |
| + // Reissuing the recovery code: the one path in the app that DELETES, so |
| + // the one most likely to leave the old row's bytes behind in a freed |
| + // page. The grep reads the whole file including free space, which is the |
| + // only reason that is worth saying out loud. |
| + reissued := randBytes(t, 16) |
| + reissuedKEK, err := crypto.Derive(reissued, nil, crypto.LabelRCKEK, 32) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + reissuedSeed, err := crypto.Derive(reissued, nil, crypto.LabelRCLogin, 32) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + reissuedLogin, err := crypto.DeriveP256(reissuedSeed) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + reissuedWrapID, err := store.NewID() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + reissuedNonce := randBytes(t, 12) |
| + reissuedCT, err := crypto.Seal(reissuedKEK, reissuedNonce, |
| + crypto.AAD(acct.ID, crypto.ObjectWrapRC, reissuedWrapID, 1), sec.rk) |
| + if err != nil { |
| + t.Fatalf("Seal(reissued rc): %v", err) |
| + } |
| + if err := s.ReplaceRecoveryWrap(ctx, acct.ID, reissuedWrapID, params, 1, |
| + crypto.KCV(reissuedKEK), |
| + store.Sealed{Nonce: reissuedNonce, Ciphertext: reissuedCT}, |
| + reissuedLogin.PublicKey().Bytes()); err != nil { |
| + t.Fatalf("ReplaceRecoveryWrap: %v", err) |
| + } |
| + |
| return sec |
| } |
diff --git a/internal/contratest/jsmodules_test.go b/internal/contratest/jsmodules_test.go| index 86fef59..ffe7ed5 100644 |
| --- a/internal/contratest/jsmodules_test.go |
| +++ b/internal/contratest/jsmodules_test.go |
| @@ -40,6 +40,7 @@ var jsHighWater = map[string]int{ |
| "p256.js": 107, |
| "p256math.js": 143, |
| "pdf.js": 180, |
| + "reissue.js": 78, |
| "qr.js": 297, |
| "qrgf.js": 59, |
| "qrtables.js": 44, |
| @@ -52,6 +53,7 @@ var jsHighWater = map[string]int{ |
| "pages/signin.js": 49, |
| "pages/device.js": 264, |
| "pages/devicescan.js": 96, |
| + "pages/kit.js": 156, |
| "pages/signup.js": 258, |
| "pages/vault.js": 179, |
| } |
diff --git a/internal/contratest/recovery_test.go b/internal/contratest/recovery_test.go| new file mode 100644 |
| index 0000000..80d788f |
| --- /dev/null |
| +++ b/internal/contratest/recovery_test.go |
| @@ -0,0 +1,227 @@ |
| +package contratest |
| + |
| +import ( |
| + "encoding/hex" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/contra/internal/contra/crypto" |
| + "github.com/carlosframework/contra/internal/contra/model" |
| +) |
| + |
| +// Reissuing the recovery code — the one destructive write in the app. |
| +// |
| +// The thing worth testing is not that a row changed. It is the pair of facts |
| +// the copy promises: the NEW code opens the vault, and the OLD one does not, |
| +// with no window in which neither does. |
| + |
| +// newRecovery is the client's half: mint a code, derive its branch, seal RK |
| +// under it. Exactly what reissue.js does, in Go. |
| +type newRecovery struct { |
| + code []byte |
| + kek []byte |
| + wrapID []byte |
| + nonce []byte |
| + sealed []byte |
| + publicKey []byte |
| +} |
| + |
| +func mintRecovery(t *testing.T, accountID, rk []byte) newRecovery { |
| + t.Helper() |
| + n := newRecovery{ |
| + code: randBytes(t, 16), |
| + wrapID: randBytes(t, 16), |
| + nonce: randBytes(t, 12), |
| + } |
| + seed, err := crypto.Derive(n.code, nil, crypto.LabelRCLogin, 32) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + key, err := crypto.DeriveP256(seed) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + n.publicKey = key.PublicKey().Bytes() |
| + |
| + n.kek, err = crypto.Derive(n.code, nil, crypto.LabelRCKEK, 32) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + n.sealed, err = crypto.Seal(n.kek, n.nonce, |
| + crypto.AAD(accountID, crypto.ObjectWrapRC, n.wrapID, 1), rk) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return n |
| +} |
| + |
| +func (n newRecovery) post(t *testing.T, h http.Handler, cookie string) int { |
| + t.Helper() |
| + rec := as(t, h, cookie, http.MethodPost, "/api/recovery", map[string]any{ |
| + "wrap_id_hex": hex.EncodeToString(n.wrapID), |
| + "kcv_hex": hex.EncodeToString(crypto.KCV(n.kek)), |
| + "kdf_params": `{"alg":"argon2id","m_kib":65536,"t":3,"p":1}`, |
| + "login_public_key_hex": hex.EncodeToString(n.publicKey), |
| + "sealed": map[string]string{ |
| + "nonce_hex": hex.EncodeToString(n.nonce), |
| + "ciphertext_hex": hex.EncodeToString(n.sealed), |
| + }, |
| + }) |
| + return rec.Code |
| +} |
| + |
| +func TestReissuingRecoveryReplacesTheBranch(t *testing.T) { |
| + h, gdb := newAppWithDB(t) |
| + p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| + |
| + var before model.Wrap |
| + if err := gdb.Where("account_id = ? AND kind = ?", |
| + p.id, crypto.ObjectWrapRC).Take(&before).Error; err != nil { |
| + t.Fatalf("no recovery wrap to begin with: %v", err) |
| + } |
| + |
| + fresh := mintRecovery(t, p.id, p.rk) |
| + if code := fresh.post(t, h, p.cookie); code != http.StatusOK { |
| + t.Fatalf("reissue: status %d", code) |
| + } |
| + |
| + // Exactly one recovery wrap, and it is the new one. Two would mean two |
| + // live sheets, which is the failure this whole flow exists to avoid. |
| + var wraps []model.Wrap |
| + if err := gdb.Where("account_id = ? AND kind = ?", |
| + p.id, crypto.ObjectWrapRC).Find(&wraps).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(wraps) != 1 { |
| + t.Fatalf("%d recovery wraps after a reissue, want 1", len(wraps)) |
| + } |
| + if hex.EncodeToString(wraps[0].ID) == hex.EncodeToString(before.ID) { |
| + t.Error("the old recovery wrap is still the one stored") |
| + } |
| + |
| + // The new code opens RK. |
| + got, err := crypto.Open(fresh.kek, wraps[0].Nonce, |
| + crypto.AAD(p.id, crypto.ObjectWrapRC, wraps[0].ID, wraps[0].Version), |
| + wraps[0].Ciphertext) |
| + if err != nil { |
| + t.Fatalf("the new recovery code does not open the new wrap: %v", err) |
| + } |
| + if hex.EncodeToString(got) != hex.EncodeToString(p.rk) { |
| + t.Error("the new wrap does not contain RK") |
| + } |
| + |
| + // And the old one does not — which is what the copy promises before the |
| + // person commits, and the only reason this is allowed to delete. |
| + oldKEK, err := crypto.Derive(p.recovery, nil, crypto.LabelRCKEK, 32) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := crypto.Open(oldKEK, wraps[0].Nonce, |
| + crypto.AAD(p.id, crypto.ObjectWrapRC, wraps[0].ID, wraps[0].Version), |
| + wraps[0].Ciphertext); err == nil { |
| + t.Error("the old recovery code still opens the vault") |
| + } |
| + |
| + // The recovery LOGIN key is replaced too. Leaving the old one would let |
| + // the old sheet still pass sign-in and then fail at unlock, which is a |
| + // much worse way to find out. |
| + var logins []model.LoginKey |
| + if err := gdb.Where("account_id = ? AND branch = ?", p.id, "rc"). |
| + Find(&logins).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(logins) != 1 { |
| + t.Fatalf("%d recovery login keys, want 1", len(logins)) |
| + } |
| + if hex.EncodeToString(logins[0].PublicKey) != hex.EncodeToString(fresh.publicKey) { |
| + t.Error("the recovery login key was not replaced") |
| + } |
| +} |
| + |
| +// The passphrase branch is not in the transaction and must not be touched. |
| +// DESIGN.md: W_pw and W_rc are never removed, so nothing here may be able to |
| +// strand an account. |
| +func TestReissuingRecoveryLeavesThePassphraseBranchAlone(t *testing.T) { |
| + h, gdb := newAppWithDB(t) |
| + p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| + |
| + var before model.Wrap |
| + if err := gdb.Where("account_id = ? AND kind = ?", |
| + p.id, crypto.ObjectWrapPW).Take(&before).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + if code := mintRecovery(t, p.id, p.rk).post(t, h, p.cookie); code != http.StatusOK { |
| + t.Fatalf("reissue: status %d", code) |
| + } |
| + |
| + var after model.Wrap |
| + if err := gdb.Where("account_id = ? AND kind = ?", |
| + p.id, crypto.ObjectWrapPW).Take(&after).Error; err != nil { |
| + t.Fatalf("the passphrase wrap is gone: %v", err) |
| + } |
| + if hex.EncodeToString(after.ID) != hex.EncodeToString(before.ID) || |
| + hex.EncodeToString(after.Ciphertext) != hex.EncodeToString(before.Ciphertext) { |
| + t.Error("the passphrase wrap changed during a recovery reissue") |
| + } |
| +} |
| + |
| +// One account's reissue must not reach another's branch. This is a delete, so |
| +// a dropped owner filter here would not return the wrong data — it would |
| +// destroy somebody else's only spare key. |
| +func TestReissuingRecoveryCannotReachAnotherAccount(t *testing.T) { |
| + h, gdb := newAppWithDB(t) |
| + mine := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| + theirs := provision(t, h, "other@example.invalid", "a different generated one 4d2e9b") |
| + |
| + var before model.Wrap |
| + if err := gdb.Where("account_id = ? AND kind = ?", |
| + theirs.id, crypto.ObjectWrapRC).Take(&before).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + if code := mintRecovery(t, mine.id, mine.rk).post(t, h, mine.cookie); code != http.StatusOK { |
| + t.Fatalf("reissue: status %d", code) |
| + } |
| + |
| + var after model.Wrap |
| + if err := gdb.Where("account_id = ? AND kind = ?", |
| + theirs.id, crypto.ObjectWrapRC).Take(&after).Error; err != nil { |
| + t.Fatalf("another account's recovery wrap was destroyed: %v", err) |
| + } |
| + if hex.EncodeToString(after.ID) != hex.EncodeToString(before.ID) { |
| + t.Error("another account's recovery wrap changed") |
| + } |
| +} |
| + |
| +func TestReissuingRecoveryRefusesAStranger(t *testing.T) { |
| + h := newApp(t) |
| + rec := as(t, h, "", http.MethodPost, "/api/recovery", map[string]string{}) |
| + if rec.Code == http.StatusOK { |
| + t.Error("a signed-out browser reissued a recovery code") |
| + } |
| +} |
| + |
| +// The kit page has to say what is about to happen BEFORE the control that |
| +// does it. This is the one screen where wording is a safety feature. |
| +func TestKitPageWarnsBeforeItOffersToReissue(t *testing.T) { |
| + h := newApp(t) |
| + p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| + |
| + body := as(t, h, p.cookie, http.MethodGet, "/kit", nil).Body.String() |
| + warn := strings.Index(body, "stops working immediately") |
| + control := strings.Index(body, "data-regenerate") |
| + if warn < 0 { |
| + t.Fatal("the kit page does not say the old sheet stops working") |
| + } |
| + if control < 0 { |
| + t.Fatal("the kit page has no reissue control") |
| + } |
| + if warn > control { |
| + t.Error("the warning comes after the button that acts on it") |
| + } |
| + if !strings.Contains(body, "data-confirm-recovery") { |
| + t.Error("the reissue has no explicit confirmation") |
| + } |
| +} |