| 1 | package contratest |
| 2 | |
| 3 | import ( |
| 4 | "encoding/hex" |
| 5 | "net/http" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | |
| 9 | "github.com/carlosframework/contra/internal/contra/crypto" |
| 10 | "github.com/carlosframework/contra/internal/contra/model" |
| 11 | ) |
| 12 | |
| 13 | // Reissuing the recovery code — the one destructive write in the app. |
| 14 | // |
| 15 | // The thing worth testing is not that a row changed. It is the pair of facts |
| 16 | // the copy promises: the NEW code opens the vault, and the OLD one does not, |
| 17 | // with no window in which neither does. |
| 18 | |
| 19 | // newRecovery is the client's half: mint a code, derive its branch, seal RK |
| 20 | // under it. Exactly what reissue.js does, in Go. |
| 21 | type newRecovery struct { |
| 22 | code []byte |
| 23 | kek []byte |
| 24 | wrapID []byte |
| 25 | nonce []byte |
| 26 | sealed []byte |
| 27 | publicKey []byte |
| 28 | } |
| 29 | |
| 30 | func mintRecovery(t *testing.T, accountID, rk []byte) newRecovery { |
| 31 | t.Helper() |
| 32 | n := newRecovery{ |
| 33 | code: randBytes(t, 16), |
| 34 | wrapID: randBytes(t, 16), |
| 35 | nonce: randBytes(t, 12), |
| 36 | } |
| 37 | seed, err := crypto.Derive(n.code, nil, crypto.LabelRCLogin, 32) |
| 38 | if err != nil { |
| 39 | t.Fatal(err) |
| 40 | } |
| 41 | key, err := crypto.DeriveP256(seed) |
| 42 | if err != nil { |
| 43 | t.Fatal(err) |
| 44 | } |
| 45 | n.publicKey = key.PublicKey().Bytes() |
| 46 | |
| 47 | n.kek, err = crypto.Derive(n.code, nil, crypto.LabelRCKEK, 32) |
| 48 | if err != nil { |
| 49 | t.Fatal(err) |
| 50 | } |
| 51 | n.sealed, err = crypto.Seal(n.kek, n.nonce, |
| 52 | crypto.AAD(accountID, crypto.ObjectWrapRC, n.wrapID, 1), rk) |
| 53 | if err != nil { |
| 54 | t.Fatal(err) |
| 55 | } |
| 56 | return n |
| 57 | } |
| 58 | |
| 59 | func (n newRecovery) post(t *testing.T, h http.Handler, cookie string) int { |
| 60 | t.Helper() |
| 61 | rec := as(t, h, cookie, http.MethodPost, "/api/recovery", map[string]any{ |
| 62 | "wrap_id_hex": hex.EncodeToString(n.wrapID), |
| 63 | "kcv_hex": hex.EncodeToString(crypto.KCV(n.kek)), |
| 64 | "kdf_params": `{"alg":"argon2id","m_kib":65536,"t":3,"p":1}`, |
| 65 | "login_public_key_hex": hex.EncodeToString(n.publicKey), |
| 66 | "sealed": map[string]string{ |
| 67 | "nonce_hex": hex.EncodeToString(n.nonce), |
| 68 | "ciphertext_hex": hex.EncodeToString(n.sealed), |
| 69 | }, |
| 70 | }) |
| 71 | return rec.Code |
| 72 | } |
| 73 | |
| 74 | func TestReissuingRecoveryReplacesTheBranch(t *testing.T) { |
| 75 | h, gdb := newAppWithDB(t) |
| 76 | p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| 77 | |
| 78 | var before model.Wrap |
| 79 | if err := gdb.Where("account_id = ? AND kind = ?", |
| 80 | p.id, crypto.ObjectWrapRC).Take(&before).Error; err != nil { |
| 81 | t.Fatalf("no recovery wrap to begin with: %v", err) |
| 82 | } |
| 83 | |
| 84 | fresh := mintRecovery(t, p.id, p.rk) |
| 85 | if code := fresh.post(t, h, p.cookie); code != http.StatusOK { |
| 86 | t.Fatalf("reissue: status %d", code) |
| 87 | } |
| 88 | |
| 89 | // Exactly one recovery wrap, and it is the new one. Two would mean two |
| 90 | // live sheets, which is the failure this whole flow exists to avoid. |
| 91 | var wraps []model.Wrap |
| 92 | if err := gdb.Where("account_id = ? AND kind = ?", |
| 93 | p.id, crypto.ObjectWrapRC).Find(&wraps).Error; err != nil { |
| 94 | t.Fatal(err) |
| 95 | } |
| 96 | if len(wraps) != 1 { |
| 97 | t.Fatalf("%d recovery wraps after a reissue, want 1", len(wraps)) |
| 98 | } |
| 99 | if hex.EncodeToString(wraps[0].ID) == hex.EncodeToString(before.ID) { |
| 100 | t.Error("the old recovery wrap is still the one stored") |
| 101 | } |
| 102 | |
| 103 | // The new code opens RK. |
| 104 | got, err := crypto.Open(fresh.kek, wraps[0].Nonce, |
| 105 | crypto.AAD(p.id, crypto.ObjectWrapRC, wraps[0].ID, wraps[0].Version), |
| 106 | wraps[0].Ciphertext) |
| 107 | if err != nil { |
| 108 | t.Fatalf("the new recovery code does not open the new wrap: %v", err) |
| 109 | } |
| 110 | if hex.EncodeToString(got) != hex.EncodeToString(p.rk) { |
| 111 | t.Error("the new wrap does not contain RK") |
| 112 | } |
| 113 | |
| 114 | // And the old one does not — which is what the copy promises before the |
| 115 | // person commits, and the only reason this is allowed to delete. |
| 116 | oldKEK, err := crypto.Derive(p.recovery, nil, crypto.LabelRCKEK, 32) |
| 117 | if err != nil { |
| 118 | t.Fatal(err) |
| 119 | } |
| 120 | if _, err := crypto.Open(oldKEK, wraps[0].Nonce, |
| 121 | crypto.AAD(p.id, crypto.ObjectWrapRC, wraps[0].ID, wraps[0].Version), |
| 122 | wraps[0].Ciphertext); err == nil { |
| 123 | t.Error("the old recovery code still opens the vault") |
| 124 | } |
| 125 | |
| 126 | // The recovery LOGIN key is replaced too. Leaving the old one would let |
| 127 | // the old sheet still pass sign-in and then fail at unlock, which is a |
| 128 | // much worse way to find out. |
| 129 | var logins []model.LoginKey |
| 130 | if err := gdb.Where("account_id = ? AND branch = ?", p.id, "rc"). |
| 131 | Find(&logins).Error; err != nil { |
| 132 | t.Fatal(err) |
| 133 | } |
| 134 | if len(logins) != 1 { |
| 135 | t.Fatalf("%d recovery login keys, want 1", len(logins)) |
| 136 | } |
| 137 | if hex.EncodeToString(logins[0].PublicKey) != hex.EncodeToString(fresh.publicKey) { |
| 138 | t.Error("the recovery login key was not replaced") |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | // The passphrase branch is not in the transaction and must not be touched. |
| 143 | // PROTOCOL.md: W_pw and W_rc are never removed, so nothing here may be able to |
| 144 | // strand an account. |
| 145 | func TestReissuingRecoveryLeavesThePassphraseBranchAlone(t *testing.T) { |
| 146 | h, gdb := newAppWithDB(t) |
| 147 | p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| 148 | |
| 149 | var before model.Wrap |
| 150 | if err := gdb.Where("account_id = ? AND kind = ?", |
| 151 | p.id, crypto.ObjectWrapPW).Take(&before).Error; err != nil { |
| 152 | t.Fatal(err) |
| 153 | } |
| 154 | |
| 155 | if code := mintRecovery(t, p.id, p.rk).post(t, h, p.cookie); code != http.StatusOK { |
| 156 | t.Fatalf("reissue: status %d", code) |
| 157 | } |
| 158 | |
| 159 | var after model.Wrap |
| 160 | if err := gdb.Where("account_id = ? AND kind = ?", |
| 161 | p.id, crypto.ObjectWrapPW).Take(&after).Error; err != nil { |
| 162 | t.Fatalf("the passphrase wrap is gone: %v", err) |
| 163 | } |
| 164 | if hex.EncodeToString(after.ID) != hex.EncodeToString(before.ID) || |
| 165 | hex.EncodeToString(after.Ciphertext) != hex.EncodeToString(before.Ciphertext) { |
| 166 | t.Error("the passphrase wrap changed during a recovery reissue") |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | // One account's reissue must not reach another's branch. This is a delete, so |
| 171 | // a dropped owner filter here would not return the wrong data — it would |
| 172 | // destroy somebody else's only spare key. |
| 173 | func TestReissuingRecoveryCannotReachAnotherAccount(t *testing.T) { |
| 174 | h, gdb := newAppWithDB(t) |
| 175 | mine := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| 176 | theirs := provision(t, h, "other@example.invalid", "a different generated one 4d2e9b") |
| 177 | |
| 178 | var before model.Wrap |
| 179 | if err := gdb.Where("account_id = ? AND kind = ?", |
| 180 | theirs.id, crypto.ObjectWrapRC).Take(&before).Error; err != nil { |
| 181 | t.Fatal(err) |
| 182 | } |
| 183 | |
| 184 | if code := mintRecovery(t, mine.id, mine.rk).post(t, h, mine.cookie); code != http.StatusOK { |
| 185 | t.Fatalf("reissue: status %d", code) |
| 186 | } |
| 187 | |
| 188 | var after model.Wrap |
| 189 | if err := gdb.Where("account_id = ? AND kind = ?", |
| 190 | theirs.id, crypto.ObjectWrapRC).Take(&after).Error; err != nil { |
| 191 | t.Fatalf("another account's recovery wrap was destroyed: %v", err) |
| 192 | } |
| 193 | if hex.EncodeToString(after.ID) != hex.EncodeToString(before.ID) { |
| 194 | t.Error("another account's recovery wrap changed") |
| 195 | } |
| 196 | } |
| 197 | |
| 198 | func TestReissuingRecoveryRefusesAStranger(t *testing.T) { |
| 199 | h := newApp(t) |
| 200 | rec := as(t, h, "", http.MethodPost, "/api/recovery", map[string]string{}) |
| 201 | if rec.Code == http.StatusOK { |
| 202 | t.Error("a signed-out browser reissued a recovery code") |
| 203 | } |
| 204 | } |
| 205 | |
| 206 | // The kit page has to say what is about to happen BEFORE the control that |
| 207 | // does it. This is the one screen where wording is a safety feature. |
| 208 | func TestKitPageWarnsBeforeItOffersToReissue(t *testing.T) { |
| 209 | h := newApp(t) |
| 210 | p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| 211 | |
| 212 | body := as(t, h, p.cookie, http.MethodGet, "/kit", nil).Body.String() |
| 213 | warn := strings.Index(body, "stops working immediately") |
| 214 | control := strings.Index(body, "data-regenerate") |
| 215 | if warn < 0 { |
| 216 | t.Fatal("the kit page does not say the old sheet stops working") |
| 217 | } |
| 218 | if control < 0 { |
| 219 | t.Fatal("the kit page has no reissue control") |
| 220 | } |
| 221 | if warn > control { |
| 222 | t.Error("the warning comes after the button that acts on it") |
| 223 | } |
| 224 | if !strings.Contains(body, "data-confirm-recovery") { |
| 225 | t.Error("the reissue has no explicit confirmation") |
| 226 | } |
| 227 | } |
| 228 | |