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. // PROTOCOL.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") } }