| 1 | package contratest |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "crypto/hmac" |
| 7 | "crypto/rand" |
| 8 | "crypto/sha256" |
| 9 | "encoding/hex" |
| 10 | "fmt" |
| 11 | "io" |
| 12 | "log/slog" |
| 13 | "os" |
| 14 | "path/filepath" |
| 15 | "strings" |
| 16 | "testing" |
| 17 | "time" |
| 18 | |
| 19 | "github.com/carlosframework/rastrillo/db" |
| 20 | |
| 21 | "github.com/carlosframework/contra/internal/contra" |
| 22 | "github.com/carlosframework/contra/internal/contra/crypto" |
| 23 | "github.com/carlosframework/contra/internal/contra/store" |
| 24 | ) |
| 25 | |
| 26 | // The merciless greps. |
| 27 | // |
| 28 | // # The standing rule |
| 29 | // |
| 30 | // **A new storage surface lands with its canary or it does not land.** If |
| 31 | // you are here because you added a table, a column or a write path, add it |
| 32 | // to writeEverything below on the same commit. That is not a convention to |
| 33 | // weigh against a deadline; it is the only thing standing between CONTRA's |
| 34 | // one claim and a quiet plaintext column. |
| 35 | // |
| 36 | // # What this proves, and what it does not |
| 37 | // |
| 38 | // CONTRA is CARLOS A1 strict: nothing is revealed. The instance's whole |
| 39 | // SQLite file is continuously replicated to object storage, so a plaintext |
| 40 | // column is not a bug that shows up in a code review — it is a plaintext |
| 41 | // leak, in a bucket, with versioned history, forever. Item names, URLs, |
| 42 | // usernames and vault names all seal client-side exactly like passwords, |
| 43 | // because a name is a secret too. |
| 44 | // |
| 45 | // So this test pushes canary strings through every write path the app has, |
| 46 | // then searches the raw bytes of the database for them. Not a query — the |
| 47 | // bytes, including the write-ahead log, including freed pages, including |
| 48 | // the table and column names in sqlite_master. Zero matches, or it fails. |
| 49 | // |
| 50 | // It searches for two kinds of thing, and the second matters as much as the |
| 51 | // first: the canary strings a user would have typed, and the raw key |
| 52 | // material — RK, the vault key, every KEK. A build that stored a key would |
| 53 | // pass every "is it encrypted?" review ever written and lose everything. |
| 54 | // |
| 55 | // What it does not prove: that the ciphertext is any good. That is the |
| 56 | // golden vectors' job (vectors/README.md). This proves only that what |
| 57 | // reaches the disk is not the plaintext — which is the claim the product is |
| 58 | // sold on, and the one an ordinary refactor can break by accident. |
| 59 | // |
| 60 | // # About the bucket |
| 61 | // |
| 62 | // Discussion 8 asks for the bucket objects to be grepped too. The bucket |
| 63 | // holds byte-for-byte copies of exactly these files — that is what |
| 64 | // continuous replication is — so grepping them is grepping the bucket, and |
| 65 | // the copy step below is written out rather than assumed so the claim is |
| 66 | // visible instead of implied. What a test cannot reach is the *history*: the |
| 67 | // bucket keeps superseded object versions, which is why PROTOCOL.md pins a |
| 68 | // lifecycle policy expiring them. A leak here would already be in versions |
| 69 | // no test can delete. |
| 70 | |
| 71 | // canaries are what a person would have typed. Generated, never real |
| 72 | // (AGENTS.md decision 8), and distinctive enough that a match cannot be a |
| 73 | // coincidence. |
| 74 | // |
| 75 | // The addresses are lower case on purpose: they are normalised before they |
| 76 | // are indexed, so an upper-case canary would be searching for a form that |
| 77 | // could never have been stored — a grep that cannot fail. |
| 78 | var canaries = map[string]string{ |
| 79 | "item title": "CANARY-title-Jamaica-Bakery-8f3a1c", |
| 80 | "item url": "https://CANARY-host-4d2e9b.example.invalid/login", |
| 81 | "item username": "CANARY-user-4d2e9b@example.invalid", |
| 82 | "item password": "CANARY-password-Q7wz-2m4K-9xLp", |
| 83 | "item notes": "CANARY-notes-the-spare-key-is-under-the-pot", |
| 84 | "item totp": "CANARYTOTPSEEDJBSWY3DPEHPK3PXP", |
| 85 | "vault name": "CANARY-vault-Household-1a7c5e", |
| 86 | "email address": "canary-person-1a7c5e@example.invalid", |
| 87 | "second email": "canary-abandoned-6f2b8d@example.invalid", |
| 88 | "passphrase": "CANARY-passphrase-correct-horse-2b9d4f", |
| 89 | |
| 90 | // The handoff code a person reads from one screen and types into |
| 91 | // another. The server is given only its SHA-256, so this string must |
| 92 | // never reach the disk — and it is exactly what a later "let's store the |
| 93 | // code so we can show it in a list of pending devices" would put there. |
| 94 | "handoff code": "CANARYHF", |
| 95 | } |
| 96 | |
| 97 | // secrets is key material. None of it may ever reach the disk. |
| 98 | type secrets struct { |
| 99 | rk []byte // the root key |
| 100 | kVault []byte // the vault bulk key |
| 101 | kekPW []byte |
| 102 | kekRC []byte |
| 103 | kekPRF []byte |
| 104 | accountK []byte // the printed Account Key |
| 105 | recovery []byte // the printed recovery code |
| 106 | } |
| 107 | |
| 108 | func randBytes(t *testing.T, n int) []byte { |
| 109 | t.Helper() |
| 110 | b := make([]byte, n) |
| 111 | if _, err := rand.Read(b); err != nil { |
| 112 | t.Fatalf("rand: %v", err) |
| 113 | } |
| 114 | return b |
| 115 | } |
| 116 | |
| 117 | // writeEverything drives every write path the app has, carrying canaries |
| 118 | // through the client-shape sealing each one really uses. |
| 119 | // |
| 120 | // ADD YOUR NEW WRITE PATH HERE. A path not called here is a path this test |
| 121 | // says nothing about, and a green run would be a lie about it. |
| 122 | func writeEverything(t *testing.T, s *store.Store) secrets { |
| 123 | t.Helper() |
| 124 | ctx := context.Background() |
| 125 | |
| 126 | sec := secrets{ |
| 127 | rk: randBytes(t, 32), |
| 128 | kVault: randBytes(t, 32), |
| 129 | kekPW: randBytes(t, 32), |
| 130 | kekRC: randBytes(t, 32), |
| 131 | kekPRF: randBytes(t, 32), |
| 132 | accountK: randBytes(t, 16), |
| 133 | recovery: randBytes(t, 16), |
| 134 | } |
| 135 | |
| 136 | // An account. The address never reaches the store: what goes in is the |
| 137 | // blind index, an HMAC under a pepper the instance should not even hold |
| 138 | // — it belongs at the routing layer or in a KMS, so a database-plus- |
| 139 | // bucket leak alone cannot unblind anybody. |
| 140 | pepper := randBytes(t, 32) |
| 141 | mac := hmac.New(sha256.New, pepper) |
| 142 | mac.Write([]byte(canaries["email address"])) |
| 143 | emailIndex := mac.Sum(nil) |
| 144 | |
| 145 | acct, err := s.CreateAccount(ctx, randBytes(t, 16), emailIndex, randBytes(t, 16)) |
| 146 | if err != nil { |
| 147 | t.Fatalf("CreateAccount: %v", err) |
| 148 | } |
| 149 | |
| 150 | // A login public key per branch. The private halves stay in the client. |
| 151 | for _, branch := range []string{"pw", "rc"} { |
| 152 | seed := randBytes(t, 32) |
| 153 | key, err := crypto.DeriveP256(seed) |
| 154 | if err != nil { |
| 155 | t.Fatalf("DeriveP256: %v", err) |
| 156 | } |
| 157 | if err := s.PutLoginKey(ctx, acct.ID, branch, key.PublicKey().Bytes()); err != nil { |
| 158 | t.Fatalf("PutLoginKey(%s): %v", branch, err) |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | // The three peer wraps of RK. |
| 163 | params := `{"alg":"argon2id","m_kib":65536,"t":3,"p":1}` |
| 164 | for _, w := range []struct { |
| 165 | kind string |
| 166 | kek []byte |
| 167 | credSalt []byte |
| 168 | }{ |
| 169 | {crypto.ObjectWrapPW, sec.kekPW, nil}, |
| 170 | {crypto.ObjectWrapRC, sec.kekRC, nil}, |
| 171 | {crypto.ObjectWrapPRF, sec.kekPRF, randBytes(t, 32)}, |
| 172 | } { |
| 173 | wrapID, err := store.NewID() |
| 174 | if err != nil { |
| 175 | t.Fatal(err) |
| 176 | } |
| 177 | aad := crypto.AAD(acct.ID, w.kind, wrapID, 1) |
| 178 | nonce := randBytes(t, 12) |
| 179 | ct, err := crypto.Seal(w.kek, nonce, aad, sec.rk) |
| 180 | if err != nil { |
| 181 | t.Fatalf("Seal(%s): %v", w.kind, err) |
| 182 | } |
| 183 | if err := s.PutWrap(ctx, acct.ID, wrapID, w.kind, params, 1, crypto.KCV(w.kek), |
| 184 | w.credSalt, store.Sealed{Nonce: nonce, Ciphertext: ct}); err != nil { |
| 185 | t.Fatalf("PutWrap(%s): %v", w.kind, err) |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | // A vault. Its NAME is a canary, and it never appears as an argument — |
| 190 | // it seals into the keyset the members hold, below. |
| 191 | dropBox, err := crypto.DeriveP256(randBytes(t, 32)) |
| 192 | if err != nil { |
| 193 | t.Fatal(err) |
| 194 | } |
| 195 | vaultID, err := store.NewID() |
| 196 | if err != nil { |
| 197 | t.Fatal(err) |
| 198 | } |
| 199 | // The vault keyset carries the vault's NAME and its bulk key, which is |
| 200 | // where both are supposed to live — so both are canaries here. |
| 201 | keysetNonce := randBytes(t, 12) |
| 202 | keysetCT, err := crypto.Seal(sec.rk, keysetNonce, |
| 203 | crypto.AAD(acct.ID, "vault.keyset", vaultID, 1), |
| 204 | []byte(fmt.Sprintf(`{"name":%q,"k_vault":%q}`, |
| 205 | canaries["vault name"], hex.EncodeToString(sec.kVault)))) |
| 206 | if err != nil { |
| 207 | t.Fatalf("Seal(vault keyset): %v", err) |
| 208 | } |
| 209 | vault, err := s.CreateVault(ctx, vaultID, acct.ID, dropBox.PublicKey().Bytes(), |
| 210 | store.Sealed{Nonce: keysetNonce, Ciphertext: keysetCT}) |
| 211 | if err != nil { |
| 212 | t.Fatalf("CreateVault: %v", err) |
| 213 | } |
| 214 | |
| 215 | // The vault keyset sealed to a member — carrying the vault's name and |
| 216 | // its bulk key, which is where both are supposed to live. |
| 217 | keyset := fmt.Sprintf(`{"name":%q,"k_vault":%q}`, |
| 218 | canaries["vault name"], hex.EncodeToString(sec.kVault)) |
| 219 | ephemeral, err := crypto.DeriveP256(randBytes(t, 32)) |
| 220 | if err != nil { |
| 221 | t.Fatal(err) |
| 222 | } |
| 223 | memberID, err := store.NewID() |
| 224 | if err != nil { |
| 225 | t.Fatal(err) |
| 226 | } |
| 227 | memberNonce := randBytes(t, 12) |
| 228 | memberCT, err := crypto.Seal(randBytes(t, 32), memberNonce, |
| 229 | crypto.AAD(vault.ID, "vault.keyset", memberID, 1), []byte(keyset)) |
| 230 | if err != nil { |
| 231 | t.Fatalf("Seal(keyset): %v", err) |
| 232 | } |
| 233 | if err := s.PutVaultMember(ctx, vault.ID, acct.ID, ephemeral.PublicKey().Bytes(), |
| 234 | store.Sealed{Nonce: memberNonce, Ciphertext: memberCT}); err != nil { |
| 235 | t.Fatalf("PutVaultMember: %v", err) |
| 236 | } |
| 237 | |
| 238 | // An item: title, URL, username, password, notes and TOTP seed, all in |
| 239 | // the one blob, padded into 256-byte buckets so the bucket cannot be |
| 240 | // read for lengths. |
| 241 | body := fmt.Sprintf(`{"title":%q,"url":%q,"username":%q,"password":%q,"notes":%q,"totp":%q}`, |
| 242 | canaries["item title"], canaries["item url"], canaries["item username"], |
| 243 | canaries["item password"], canaries["item notes"], canaries["item totp"]) |
| 244 | |
| 245 | itemID, err := store.NewID() |
| 246 | if err != nil { |
| 247 | t.Fatal(err) |
| 248 | } |
| 249 | itemNonce := randBytes(t, 12) |
| 250 | itemCT, err := crypto.Seal(sec.kVault, itemNonce, |
| 251 | crypto.AAD(vault.ID, crypto.ObjectItem, itemID, 1), crypto.Pad([]byte(body))) |
| 252 | if err != nil { |
| 253 | t.Fatalf("Seal(item): %v", err) |
| 254 | } |
| 255 | item, err := s.PutItem(ctx, acct.ID, vault.ID, itemID, 1, |
| 256 | store.Sealed{Nonce: itemNonce, Ciphertext: itemCT}) |
| 257 | if err != nil { |
| 258 | t.Fatalf("PutItem: %v", err) |
| 259 | } |
| 260 | |
| 261 | // And an edit, because an update is its own write path and a rewritten |
| 262 | // row leaves the old page behind in the file. |
| 263 | editNonce := randBytes(t, 12) |
| 264 | editCT, err := crypto.Seal(sec.kVault, editNonce, |
| 265 | crypto.AAD(vault.ID, crypto.ObjectItem, item.ID, 2), crypto.Pad([]byte(body))) |
| 266 | if err != nil { |
| 267 | t.Fatalf("Seal(edit): %v", err) |
| 268 | } |
| 269 | if err := s.UpdateItem(ctx, acct.ID, vault.ID, item.ID, 1, 2, |
| 270 | store.Sealed{Nonce: editNonce, Ciphertext: editCT}); err != nil { |
| 271 | t.Fatalf("UpdateItem: %v", err) |
| 272 | } |
| 273 | |
| 274 | // A device handoff, both halves. This is the one write path in the app |
| 275 | // that carries a secret between devices, so it gets the full canary |
| 276 | // treatment: the code the person reads off a screen, and the Account Key |
| 277 | // inside the envelope. |
| 278 | // |
| 279 | // The store is given SHA-256 of the code and never the code, which is |
| 280 | // the property this proves — and the one a later "show a list of pending |
| 281 | // devices with their codes" would quietly take away. |
| 282 | device, err := crypto.DeriveP256(randBytes(t, 32)) |
| 283 | if err != nil { |
| 284 | t.Fatal(err) |
| 285 | } |
| 286 | devicePublic := device.PublicKey().Bytes() |
| 287 | linkID, err := store.NewLinkID() |
| 288 | if err != nil { |
| 289 | t.Fatal(err) |
| 290 | } |
| 291 | codeHash := store.LinkCodeHash(canaries["handoff code"]) |
| 292 | now := time.Now().UTC() |
| 293 | if _, err := s.StartLink(ctx, linkID, codeHash, acct.ID, devicePublic, now); err != nil { |
| 294 | t.Fatalf("StartLink: %v", err) |
| 295 | } |
| 296 | |
| 297 | envelope, err := crypto.SealTo(devicePublic, |
| 298 | crypto.AAD(acct.ID, crypto.ObjectHandoff, devicePublic, 1), sec.accountK) |
| 299 | if err != nil { |
| 300 | t.Fatalf("SealTo: %v", err) |
| 301 | } |
| 302 | if err := s.ApproveLink(ctx, acct.ID, codeHash, envelope, now); err != nil { |
| 303 | t.Fatalf("ApproveLink: %v", err) |
| 304 | } |
| 305 | |
| 306 | // And a second, left unapproved, because a row that never reaches the |
| 307 | // claim path is a row the sweeper is responsible for and the grep should |
| 308 | // still see clean. |
| 309 | strandedID, err := store.NewLinkID() |
| 310 | if err != nil { |
| 311 | t.Fatal(err) |
| 312 | } |
| 313 | if _, err := s.StartLink(ctx, strandedID, |
| 314 | store.LinkCodeHash("CANARYH2"), acct.ID, devicePublic, now); err != nil { |
| 315 | t.Fatalf("StartLink(stranded): %v", err) |
| 316 | } |
| 317 | |
| 318 | // Sharing, and the rotation that revocation is. Both carry the vault |
| 319 | // keyset — which holds the vault's NAME and its bulk key — so both are |
| 320 | // canary-bearing write paths and the rotation is the one most likely to |
| 321 | // leave the superseded ciphertext behind in a freed page. |
| 322 | member, err := crypto.DeriveP256(randBytes(t, 32)) |
| 323 | if err != nil { |
| 324 | t.Fatal(err) |
| 325 | } |
| 326 | memberAccountID := randBytes(t, 16) |
| 327 | shareNonce := randBytes(t, 12) |
| 328 | shareCT, err := crypto.Seal(randBytes(t, 32), shareNonce, |
| 329 | crypto.AAD(vault.ID, "vault.keyset", memberAccountID, 1), []byte(keyset)) |
| 330 | if err != nil { |
| 331 | t.Fatalf("Seal(member keyset): %v", err) |
| 332 | } |
| 333 | if err := s.AddMember(ctx, acct.ID, vault.ID, store.MemberWrap{ |
| 334 | AccountID: memberAccountID, |
| 335 | EphemeralPublicKey: member.PublicKey().Bytes(), |
| 336 | Sealed: store.Sealed{Nonce: shareNonce, Ciphertext: shareCT}, |
| 337 | }); err != nil { |
| 338 | t.Fatalf("AddMember: %v", err) |
| 339 | } |
| 340 | |
| 341 | // Then rotate them straight back out, which re-seals every item under a |
| 342 | // new key and reissues every remaining wrap in one transaction. |
| 343 | rotatedNonce := randBytes(t, 12) |
| 344 | rotatedKVault := randBytes(t, 32) |
| 345 | rotatedKeysetCT, err := crypto.Seal(sec.rk, rotatedNonce, |
| 346 | crypto.AAD(acct.ID, "vault.keyset", vault.ID, 1), []byte(keyset)) |
| 347 | if err != nil { |
| 348 | t.Fatalf("Seal(rotated keyset): %v", err) |
| 349 | } |
| 350 | rotatedItemNonce := randBytes(t, 12) |
| 351 | rotatedItemCT, err := crypto.Seal(rotatedKVault, rotatedItemNonce, |
| 352 | crypto.AAD(vault.ID, crypto.ObjectItem, item.ID, 3), crypto.Pad([]byte(body))) |
| 353 | if err != nil { |
| 354 | t.Fatalf("Seal(rotated item): %v", err) |
| 355 | } |
| 356 | // One member is retained and one is removed, which is the realistic shape |
| 357 | // and keeps both halves of the transaction under the grep: a reissued |
| 358 | // wrap of the NEW keyset, and the absence of the removed one. |
| 359 | retainedNonce := randBytes(t, 12) |
| 360 | retainedCT, err := crypto.Seal(randBytes(t, 32), retainedNonce, |
| 361 | crypto.AAD(vault.ID, "vault.keyset", acct.ID, 1), []byte(keyset)) |
| 362 | if err != nil { |
| 363 | t.Fatalf("Seal(retained keyset): %v", err) |
| 364 | } |
| 365 | if err := s.RotateVault(ctx, acct.ID, vault.ID, 1, store.Rotation{ |
| 366 | Keyset: store.Sealed{Nonce: rotatedNonce, Ciphertext: rotatedKeysetCT}, |
| 367 | Items: []store.RotatedItem{{ |
| 368 | ID: item.ID, Version: 3, |
| 369 | Sealed: store.Sealed{Nonce: rotatedItemNonce, Ciphertext: rotatedItemCT}, |
| 370 | }}, |
| 371 | Members: []store.MemberWrap{{ |
| 372 | AccountID: acct.ID, |
| 373 | EphemeralPublicKey: ephemeral.PublicKey().Bytes(), |
| 374 | Sealed: store.Sealed{Nonce: retainedNonce, Ciphertext: retainedCT}, |
| 375 | }}, |
| 376 | }); err != nil { |
| 377 | t.Fatalf("RotateVault: %v", err) |
| 378 | } |
| 379 | |
| 380 | // Reissuing the recovery code: the one path in the app that DELETES, so |
| 381 | // the one most likely to leave the old row's bytes behind in a freed |
| 382 | // page. The grep reads the whole file including free space, which is the |
| 383 | // only reason that is worth saying out loud. |
| 384 | reissued := randBytes(t, 16) |
| 385 | reissuedKEK, err := crypto.Derive(reissued, nil, crypto.LabelRCKEK, 32) |
| 386 | if err != nil { |
| 387 | t.Fatal(err) |
| 388 | } |
| 389 | reissuedSeed, err := crypto.Derive(reissued, nil, crypto.LabelRCLogin, 32) |
| 390 | if err != nil { |
| 391 | t.Fatal(err) |
| 392 | } |
| 393 | reissuedLogin, err := crypto.DeriveP256(reissuedSeed) |
| 394 | if err != nil { |
| 395 | t.Fatal(err) |
| 396 | } |
| 397 | reissuedWrapID, err := store.NewID() |
| 398 | if err != nil { |
| 399 | t.Fatal(err) |
| 400 | } |
| 401 | reissuedNonce := randBytes(t, 12) |
| 402 | reissuedCT, err := crypto.Seal(reissuedKEK, reissuedNonce, |
| 403 | crypto.AAD(acct.ID, crypto.ObjectWrapRC, reissuedWrapID, 1), sec.rk) |
| 404 | if err != nil { |
| 405 | t.Fatalf("Seal(reissued rc): %v", err) |
| 406 | } |
| 407 | if err := s.ReplaceRecoveryWrap(ctx, acct.ID, reissuedWrapID, params, 1, |
| 408 | crypto.KCV(reissuedKEK), |
| 409 | store.Sealed{Nonce: reissuedNonce, Ciphertext: reissuedCT}, |
| 410 | reissuedLogin.PublicKey().Bytes()); err != nil { |
| 411 | t.Fatalf("ReplaceRecoveryWrap: %v", err) |
| 412 | } |
| 413 | |
| 414 | return sec |
| 415 | } |
| 416 | |
| 417 | // needle is one thing that must not appear on disk, with a name for the |
| 418 | // failure message. |
| 419 | type needle struct { |
| 420 | what string |
| 421 | bytes []byte |
| 422 | } |
| 423 | |
| 424 | func needles(sec secrets) []needle { |
| 425 | var out []needle |
| 426 | for what, s := range canaries { |
| 427 | out = append(out, needle{what, []byte(s)}) |
| 428 | } |
| 429 | for what, key := range map[string][]byte{ |
| 430 | "root key RK": sec.rk, |
| 431 | "vault key K_vault": sec.kVault, |
| 432 | "KEK_pw": sec.kekPW, |
| 433 | "KEK_rc": sec.kekRC, |
| 434 | "KEK_prf": sec.kekPRF, |
| 435 | "the Account Key": sec.accountK, |
| 436 | "the recovery code": sec.recovery, |
| 437 | } { |
| 438 | out = append(out, needle{what, key}) |
| 439 | } |
| 440 | return out |
| 441 | } |
| 442 | |
| 443 | // grepFile is the grep itself: raw bytes, no SQL, no assumptions about |
| 444 | // where SQLite chose to put anything. |
| 445 | func grepFile(t *testing.T, path string, ns []needle) []string { |
| 446 | t.Helper() |
| 447 | raw, err := os.ReadFile(path) |
| 448 | if os.IsNotExist(err) { |
| 449 | return nil |
| 450 | } |
| 451 | if err != nil { |
| 452 | t.Fatalf("read %s: %v", path, err) |
| 453 | } |
| 454 | var hits []string |
| 455 | for _, n := range ns { |
| 456 | if i := bytes.Index(raw, n.bytes); i >= 0 { |
| 457 | hits = append(hits, fmt.Sprintf("%s: %s in plaintext at offset %d", |
| 458 | filepath.Base(path), n.what, i)) |
| 459 | } |
| 460 | } |
| 461 | return hits |
| 462 | } |
| 463 | |
| 464 | // grepEverything searches the database and every file replication carries |
| 465 | // with it — the write-ahead log included, because the WAL is replicated too |
| 466 | // and a leak that is only ever in the WAL is still in the bucket. |
| 467 | func grepEverything(t *testing.T, dbPath string, ns []needle) []string { |
| 468 | t.Helper() |
| 469 | var hits []string |
| 470 | for _, suffix := range []string{"", "-wal", "-shm", "-journal"} { |
| 471 | hits = append(hits, grepFile(t, dbPath+suffix, ns)...) |
| 472 | } |
| 473 | return hits |
| 474 | } |
| 475 | |
| 476 | func TestMercilessGreps(t *testing.T) { |
| 477 | dir := t.TempDir() |
| 478 | dbPath := filepath.Join(dir, "contra.db") |
| 479 | |
| 480 | // The real ceremony, over the real handlers: the staged signup and a |
| 481 | // sign-in, with a canary address and a canary passphrase. This is the |
| 482 | // path a person actually takes, and the passphrase is the value that |
| 483 | // would be most catastrophic to find on disk. |
| 484 | mux, d, _ := newAppAt(t, dbPath) |
| 485 | doSignup(t, mux, canaries["email address"], canaries["passphrase"]) |
| 486 | if rec := doSignIn(t, mux, canaries["email address"], canaries["passphrase"]); rec.Code != 200 { |
| 487 | t.Fatalf("sign in during the greps: status %d", rec.Code) |
| 488 | } |
| 489 | // A live challenge row as well, so the table is grepped with something in |
| 490 | // it rather than empty. |
| 491 | postJSON(t, mux, "/auth/challenge", |
| 492 | map[string]string{"email": canaries["email address"], "branch": "pw"}) |
| 493 | |
| 494 | // And a signup that was started and never finished, so signup_sessions is |
| 495 | // grepped holding a row for an address nobody confirmed — which is the |
| 496 | // state that table spends most of its life in. |
| 497 | postJSON(t, mux, "/auth/begin", |
| 498 | map[string]string{"email": canaries["second email"]}) |
| 499 | |
| 500 | sec := writeEverything(t, store.New(d.G)) |
| 501 | ns := needles(sec) |
| 502 | |
| 503 | // A grep over an empty database is green and means nothing. This is what |
| 504 | // stops the whole test degrading into that quietly — a write path that |
| 505 | // silently stopped writing would otherwise look exactly like a pass. |
| 506 | for table, want := range map[string]int64{ |
| 507 | // One account from the ceremony plus one from the store paths; the |
| 508 | // ceremony's two login keys plus two more; its two wraps plus three. |
| 509 | "accounts": 2, "login_keys": 4, "wraps": 5, |
| 510 | "vaults": 1, "vault_members": 1, "items": 1, |
| 511 | "login_challenges": 1, |
| 512 | // One abandoned signup: the row exists, and holds nothing readable. |
| 513 | "signup_sessions": 1, |
| 514 | } { |
| 515 | var n int64 |
| 516 | if err := d.G.Raw("SELECT count(*) FROM " + table).Scan(&n).Error; err != nil { |
| 517 | t.Fatalf("count %s: %v", table, err) |
| 518 | } |
| 519 | if n != want { |
| 520 | t.Fatalf("%s holds %d rows, want %d: the greps would be searching a "+ |
| 521 | "database the write paths never filled", table, n, want) |
| 522 | } |
| 523 | } |
| 524 | |
| 525 | // Hot: the WAL still holds recent pages, which is the state the bucket |
| 526 | // sees most of the time. |
| 527 | if hits := grepEverything(t, dbPath, ns); len(hits) > 0 { |
| 528 | t.Errorf("plaintext on disk with the WAL hot:\n %s", strings.Join(hits, "\n ")) |
| 529 | } |
| 530 | |
| 531 | // Checkpointed: pages moved into the main file, and freed pages keep |
| 532 | // whatever they used to hold until something overwrites them. |
| 533 | if err := d.G.Exec("PRAGMA wal_checkpoint(TRUNCATE)").Error; err != nil { |
| 534 | t.Fatalf("checkpoint: %v", err) |
| 535 | } |
| 536 | if hits := grepEverything(t, dbPath, ns); len(hits) > 0 { |
| 537 | t.Errorf("plaintext on disk after checkpoint:\n %s", strings.Join(hits, "\n ")) |
| 538 | } |
| 539 | |
| 540 | // The schema itself. A column named after content leaks what an item |
| 541 | // holds without ever storing a value — `has_totp` tells the operator |
| 542 | // something true about you — and sqlite_master is where that shows. |
| 543 | // |
| 544 | // Scoped to CONTRA's own tables: the framework's are not ours to name, |
| 545 | // and folding them in would only teach the next person to add exceptions |
| 546 | // until the check means nothing. |
| 547 | var ddl []string |
| 548 | if err := d.G.Raw(`SELECT COALESCE(sql, '') FROM sqlite_master |
| 549 | WHERE tbl_name IN (?, ?, ?, ?, ?, ?, ?, ?)`, |
| 550 | "accounts", "login_keys", "wraps", "vaults", "vault_members", "items", |
| 551 | "login_challenges", "signup_sessions"). |
| 552 | Scan(&ddl).Error; err != nil { |
| 553 | t.Fatalf("read sqlite_master: %v", err) |
| 554 | } |
| 555 | if len(ddl) == 0 { |
| 556 | t.Fatal("no CONTRA tables found: this test just stopped checking anything") |
| 557 | } |
| 558 | schema := strings.ToLower(strings.Join(ddl, "\n")) |
| 559 | for _, banned := range []string{ |
| 560 | "title", "url", "username", "password", "notes", "totp", "hostname", |
| 561 | "search", "label", "hint", "display", "preview", |
| 562 | } { |
| 563 | if strings.Contains(schema, banned) { |
| 564 | t.Errorf("a CONTRA column or table is named %q: what an item holds is "+ |
| 565 | "itself metadata, and metadata seals client-side like everything "+ |
| 566 | "else does", banned) |
| 567 | } |
| 568 | } |
| 569 | |
| 570 | d.Close() |
| 571 | |
| 572 | // And what replication actually copies. Same bytes — the point of doing |
| 573 | // it here is that the claim is written down rather than assumed. |
| 574 | bucket := filepath.Join(dir, "bucket") |
| 575 | if err := os.MkdirAll(bucket, 0o755); err != nil { |
| 576 | t.Fatal(err) |
| 577 | } |
| 578 | for _, suffix := range []string{"", "-wal", "-shm"} { |
| 579 | raw, err := os.ReadFile(dbPath + suffix) |
| 580 | if os.IsNotExist(err) { |
| 581 | continue |
| 582 | } |
| 583 | if err != nil { |
| 584 | t.Fatal(err) |
| 585 | } |
| 586 | if err := os.WriteFile(filepath.Join(bucket, "contra.db"+suffix), raw, 0o600); err != nil { |
| 587 | t.Fatal(err) |
| 588 | } |
| 589 | } |
| 590 | if hits := grepEverything(t, filepath.Join(bucket, "contra.db"), ns); len(hits) > 0 { |
| 591 | t.Errorf("plaintext in the replicated objects:\n %s", strings.Join(hits, "\n ")) |
| 592 | } |
| 593 | } |
| 594 | |
| 595 | // A canary nobody has seen fail is a canary nobody should trust. |
| 596 | // |
| 597 | // Discussion 8 asks for proof that a deliberately introduced plaintext |
| 598 | // column makes the greps fail. This is that proof, and it runs on every CI |
| 599 | // pass rather than having been done once by hand: it adds the column a |
| 600 | // future refactor would add — "just the title, so the list renders" — writes |
| 601 | // a canary into it, and asserts the same grep the real test uses finds it. |
| 602 | // |
| 603 | // If this test ever goes green, the greps have stopped working and every |
| 604 | // other result in this file is worthless. |
| 605 | func TestGrepsCatchAPlaintextColumn(t *testing.T) { |
| 606 | dir := t.TempDir() |
| 607 | dbPath := filepath.Join(dir, "leaky.db") |
| 608 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 609 | |
| 610 | d, err := db.Open(dbPath, logger) |
| 611 | if err != nil { |
| 612 | t.Fatalf("db.Open: %v", err) |
| 613 | } |
| 614 | defer d.Close() |
| 615 | if _, err := contra.App(d, testConfig(), logger); err != nil { |
| 616 | t.Fatalf("App: %v", err) |
| 617 | } |
| 618 | |
| 619 | // The tempting column, and the write that fills it. |
| 620 | if err := d.G.Exec( |
| 621 | "CREATE TABLE item_titles (item_id BLOB PRIMARY KEY, title TEXT)").Error; err != nil { |
| 622 | t.Fatalf("create leaky table: %v", err) |
| 623 | } |
| 624 | if err := d.G.Exec("INSERT INTO item_titles VALUES (?, ?)", |
| 625 | "0123456789abcdef", canaries["item title"]).Error; err != nil { |
| 626 | t.Fatalf("insert canary: %v", err) |
| 627 | } |
| 628 | if err := d.G.Exec("PRAGMA wal_checkpoint(TRUNCATE)").Error; err != nil { |
| 629 | t.Fatalf("checkpoint: %v", err) |
| 630 | } |
| 631 | |
| 632 | ns := []needle{{"item title", []byte(canaries["item title"])}} |
| 633 | hits := grepEverything(t, dbPath, ns) |
| 634 | if len(hits) == 0 { |
| 635 | t.Fatal("the greps did not notice a plaintext title column. " + |
| 636 | "Every other result in this file is now worthless: fix the grep, " + |
| 637 | "do not weaken this test.") |
| 638 | } |
| 639 | t.Logf("greps caught it, as they must: %s", strings.Join(hits, "; ")) |
| 640 | } |
| 641 | |
| 642 | // The store's API is the first line of the defence: there is nowhere to pass |
| 643 | // a title, so a plaintext column is not something you can reach by accident. |
| 644 | // This pins the other half — that it refuses an item whose ciphertext was |
| 645 | // never quantised, so "the client pads" stays a control rather than a hope. |
| 646 | func TestStoreRefusesUnquantisedItem(t *testing.T) { |
| 647 | dir := t.TempDir() |
| 648 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 649 | d, err := db.Open(filepath.Join(dir, "contra.db"), logger) |
| 650 | if err != nil { |
| 651 | t.Fatalf("db.Open: %v", err) |
| 652 | } |
| 653 | defer d.Close() |
| 654 | if _, err := contra.App(d, testConfig(), logger); err != nil { |
| 655 | t.Fatalf("App: %v", err) |
| 656 | } |
| 657 | s := store.New(d.G) |
| 658 | |
| 659 | vaultID, err := store.NewID() |
| 660 | if err != nil { |
| 661 | t.Fatal(err) |
| 662 | } |
| 663 | // 100 bytes plus a tag: a real ciphertext of an unpadded plaintext. |
| 664 | unpadded := store.Sealed{Nonce: randBytes(t, 12), Ciphertext: randBytes(t, 116)} |
| 665 | if _, err := s.PutItem(context.Background(), randBytes(t, 16), vaultID, |
| 666 | randBytes(t, 16), 1, unpadded); err == nil { |
| 667 | t.Error("PutItem accepted an item that leaks its own length") |
| 668 | } |
| 669 | } |
| 670 | |