package contratest import ( "bytes" "context" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/hex" "fmt" "io" "log/slog" "os" "path/filepath" "strings" "testing" "time" "github.com/carlosframework/rastrillo/db" "github.com/carlosframework/contra/internal/contra" "github.com/carlosframework/contra/internal/contra/crypto" "github.com/carlosframework/contra/internal/contra/store" ) // The merciless greps. // // # The standing rule // // **A new storage surface lands with its canary or it does not land.** If // you are here because you added a table, a column or a write path, add it // to writeEverything below on the same commit. That is not a convention to // weigh against a deadline; it is the only thing standing between CONTRA's // one claim and a quiet plaintext column. // // # What this proves, and what it does not // // CONTRA is CARLOS A1 strict: nothing is revealed. The instance's whole // SQLite file is continuously replicated to object storage, so a plaintext // column is not a bug that shows up in a code review — it is a plaintext // leak, in a bucket, with versioned history, forever. Item names, URLs, // usernames and vault names all seal client-side exactly like passwords, // because a name is a secret too. // // So this test pushes canary strings through every write path the app has, // then searches the raw bytes of the database for them. Not a query — the // bytes, including the write-ahead log, including freed pages, including // the table and column names in sqlite_master. Zero matches, or it fails. // // It searches for two kinds of thing, and the second matters as much as the // first: the canary strings a user would have typed, and the raw key // material — RK, the vault key, every KEK. A build that stored a key would // pass every "is it encrypted?" review ever written and lose everything. // // What it does not prove: that the ciphertext is any good. That is the // golden vectors' job (vectors/README.md). This proves only that what // reaches the disk is not the plaintext — which is the claim the product is // sold on, and the one an ordinary refactor can break by accident. // // # About the bucket // // Discussion 8 asks for the bucket objects to be grepped too. The bucket // holds byte-for-byte copies of exactly these files — that is what // continuous replication is — so grepping them is grepping the bucket, and // the copy step below is written out rather than assumed so the claim is // visible instead of implied. What a test cannot reach is the *history*: the // bucket keeps superseded object versions, which is why PROTOCOL.md pins a // lifecycle policy expiring them. A leak here would already be in versions // no test can delete. // canaries are what a person would have typed. Generated, never real // (AGENTS.md decision 8), and distinctive enough that a match cannot be a // coincidence. // // The addresses are lower case on purpose: they are normalised before they // are indexed, so an upper-case canary would be searching for a form that // could never have been stored — a grep that cannot fail. var canaries = map[string]string{ "item title": "CANARY-title-Jamaica-Bakery-8f3a1c", "item url": "https://CANARY-host-4d2e9b.example.invalid/login", "item username": "CANARY-user-4d2e9b@example.invalid", "item password": "CANARY-password-Q7wz-2m4K-9xLp", "item notes": "CANARY-notes-the-spare-key-is-under-the-pot", "item totp": "CANARYTOTPSEEDJBSWY3DPEHPK3PXP", "vault name": "CANARY-vault-Household-1a7c5e", "email address": "canary-person-1a7c5e@example.invalid", "second email": "canary-abandoned-6f2b8d@example.invalid", "passphrase": "CANARY-passphrase-correct-horse-2b9d4f", // The handoff code a person reads from one screen and types into // another. The server is given only its SHA-256, so this string must // never reach the disk — and it is exactly what a later "let's store the // code so we can show it in a list of pending devices" would put there. "handoff code": "CANARYHF", } // secrets is key material. None of it may ever reach the disk. type secrets struct { rk []byte // the root key kVault []byte // the vault bulk key kekPW []byte kekRC []byte kekPRF []byte accountK []byte // the printed Account Key recovery []byte // the printed recovery code } func randBytes(t *testing.T, n int) []byte { t.Helper() b := make([]byte, n) if _, err := rand.Read(b); err != nil { t.Fatalf("rand: %v", err) } return b } // writeEverything drives every write path the app has, carrying canaries // through the client-shape sealing each one really uses. // // ADD YOUR NEW WRITE PATH HERE. A path not called here is a path this test // says nothing about, and a green run would be a lie about it. func writeEverything(t *testing.T, s *store.Store) secrets { t.Helper() ctx := context.Background() sec := secrets{ rk: randBytes(t, 32), kVault: randBytes(t, 32), kekPW: randBytes(t, 32), kekRC: randBytes(t, 32), kekPRF: randBytes(t, 32), accountK: randBytes(t, 16), recovery: randBytes(t, 16), } // An account. The address never reaches the store: what goes in is the // blind index, an HMAC under a pepper the instance should not even hold // — it belongs at the routing layer or in a KMS, so a database-plus- // bucket leak alone cannot unblind anybody. pepper := randBytes(t, 32) mac := hmac.New(sha256.New, pepper) mac.Write([]byte(canaries["email address"])) emailIndex := mac.Sum(nil) acct, err := s.CreateAccount(ctx, randBytes(t, 16), emailIndex, randBytes(t, 16)) if err != nil { t.Fatalf("CreateAccount: %v", err) } // A login public key per branch. The private halves stay in the client. for _, branch := range []string{"pw", "rc"} { seed := randBytes(t, 32) key, err := crypto.DeriveP256(seed) if err != nil { t.Fatalf("DeriveP256: %v", err) } if err := s.PutLoginKey(ctx, acct.ID, branch, key.PublicKey().Bytes()); err != nil { t.Fatalf("PutLoginKey(%s): %v", branch, err) } } // The three peer wraps of RK. params := `{"alg":"argon2id","m_kib":65536,"t":3,"p":1}` for _, w := range []struct { kind string kek []byte credSalt []byte }{ {crypto.ObjectWrapPW, sec.kekPW, nil}, {crypto.ObjectWrapRC, sec.kekRC, nil}, {crypto.ObjectWrapPRF, sec.kekPRF, randBytes(t, 32)}, } { wrapID, err := store.NewID() if err != nil { t.Fatal(err) } aad := crypto.AAD(acct.ID, w.kind, wrapID, 1) nonce := randBytes(t, 12) ct, err := crypto.Seal(w.kek, nonce, aad, sec.rk) if err != nil { t.Fatalf("Seal(%s): %v", w.kind, err) } if err := s.PutWrap(ctx, acct.ID, wrapID, w.kind, params, 1, crypto.KCV(w.kek), w.credSalt, store.Sealed{Nonce: nonce, Ciphertext: ct}); err != nil { t.Fatalf("PutWrap(%s): %v", w.kind, err) } } // A vault. Its NAME is a canary, and it never appears as an argument — // it seals into the keyset the members hold, below. dropBox, err := crypto.DeriveP256(randBytes(t, 32)) if err != nil { t.Fatal(err) } vaultID, err := store.NewID() if err != nil { t.Fatal(err) } // The vault keyset carries the vault's NAME and its bulk key, which is // where both are supposed to live — so both are canaries here. keysetNonce := randBytes(t, 12) keysetCT, err := crypto.Seal(sec.rk, keysetNonce, crypto.AAD(acct.ID, "vault.keyset", vaultID, 1), []byte(fmt.Sprintf(`{"name":%q,"k_vault":%q}`, canaries["vault name"], hex.EncodeToString(sec.kVault)))) if err != nil { t.Fatalf("Seal(vault keyset): %v", err) } vault, err := s.CreateVault(ctx, vaultID, acct.ID, dropBox.PublicKey().Bytes(), store.Sealed{Nonce: keysetNonce, Ciphertext: keysetCT}) if err != nil { t.Fatalf("CreateVault: %v", err) } // The vault keyset sealed to a member — carrying the vault's name and // its bulk key, which is where both are supposed to live. keyset := fmt.Sprintf(`{"name":%q,"k_vault":%q}`, canaries["vault name"], hex.EncodeToString(sec.kVault)) ephemeral, err := crypto.DeriveP256(randBytes(t, 32)) if err != nil { t.Fatal(err) } memberID, err := store.NewID() if err != nil { t.Fatal(err) } memberNonce := randBytes(t, 12) memberCT, err := crypto.Seal(randBytes(t, 32), memberNonce, crypto.AAD(vault.ID, "vault.keyset", memberID, 1), []byte(keyset)) if err != nil { t.Fatalf("Seal(keyset): %v", err) } if err := s.PutVaultMember(ctx, vault.ID, acct.ID, ephemeral.PublicKey().Bytes(), store.Sealed{Nonce: memberNonce, Ciphertext: memberCT}); err != nil { t.Fatalf("PutVaultMember: %v", err) } // An item: title, URL, username, password, notes and TOTP seed, all in // the one blob, padded into 256-byte buckets so the bucket cannot be // read for lengths. body := fmt.Sprintf(`{"title":%q,"url":%q,"username":%q,"password":%q,"notes":%q,"totp":%q}`, canaries["item title"], canaries["item url"], canaries["item username"], canaries["item password"], canaries["item notes"], canaries["item totp"]) itemID, err := store.NewID() if err != nil { t.Fatal(err) } itemNonce := randBytes(t, 12) itemCT, err := crypto.Seal(sec.kVault, itemNonce, crypto.AAD(vault.ID, crypto.ObjectItem, itemID, 1), crypto.Pad([]byte(body))) if err != nil { t.Fatalf("Seal(item): %v", err) } item, err := s.PutItem(ctx, acct.ID, vault.ID, itemID, 1, store.Sealed{Nonce: itemNonce, Ciphertext: itemCT}) if err != nil { t.Fatalf("PutItem: %v", err) } // And an edit, because an update is its own write path and a rewritten // row leaves the old page behind in the file. editNonce := randBytes(t, 12) editCT, err := crypto.Seal(sec.kVault, editNonce, crypto.AAD(vault.ID, crypto.ObjectItem, item.ID, 2), crypto.Pad([]byte(body))) if err != nil { t.Fatalf("Seal(edit): %v", err) } if err := s.UpdateItem(ctx, acct.ID, vault.ID, item.ID, 1, 2, store.Sealed{Nonce: editNonce, Ciphertext: editCT}); err != nil { t.Fatalf("UpdateItem: %v", err) } // A device handoff, both halves. This is the one write path in the app // that carries a secret between devices, so it gets the full canary // treatment: the code the person reads off a screen, and the Account Key // inside the envelope. // // The store is given SHA-256 of the code and never the code, which is // the property this proves — and the one a later "show a list of pending // devices with their codes" would quietly take away. device, err := crypto.DeriveP256(randBytes(t, 32)) if err != nil { t.Fatal(err) } devicePublic := device.PublicKey().Bytes() linkID, err := store.NewLinkID() if err != nil { t.Fatal(err) } codeHash := store.LinkCodeHash(canaries["handoff code"]) now := time.Now().UTC() if _, err := s.StartLink(ctx, linkID, codeHash, acct.ID, devicePublic, now); err != nil { t.Fatalf("StartLink: %v", err) } envelope, err := crypto.SealTo(devicePublic, crypto.AAD(acct.ID, crypto.ObjectHandoff, devicePublic, 1), sec.accountK) if err != nil { t.Fatalf("SealTo: %v", err) } if err := s.ApproveLink(ctx, acct.ID, codeHash, envelope, now); err != nil { t.Fatalf("ApproveLink: %v", err) } // And a second, left unapproved, because a row that never reaches the // claim path is a row the sweeper is responsible for and the grep should // still see clean. strandedID, err := store.NewLinkID() if err != nil { t.Fatal(err) } if _, err := s.StartLink(ctx, strandedID, store.LinkCodeHash("CANARYH2"), acct.ID, devicePublic, now); err != nil { t.Fatalf("StartLink(stranded): %v", err) } // Sharing, and the rotation that revocation is. Both carry the vault // keyset — which holds the vault's NAME and its bulk key — so both are // canary-bearing write paths and the rotation is the one most likely to // leave the superseded ciphertext behind in a freed page. member, err := crypto.DeriveP256(randBytes(t, 32)) if err != nil { t.Fatal(err) } memberAccountID := randBytes(t, 16) shareNonce := randBytes(t, 12) shareCT, err := crypto.Seal(randBytes(t, 32), shareNonce, crypto.AAD(vault.ID, "vault.keyset", memberAccountID, 1), []byte(keyset)) if err != nil { t.Fatalf("Seal(member keyset): %v", err) } if err := s.AddMember(ctx, acct.ID, vault.ID, store.MemberWrap{ AccountID: memberAccountID, EphemeralPublicKey: member.PublicKey().Bytes(), Sealed: store.Sealed{Nonce: shareNonce, Ciphertext: shareCT}, }); err != nil { t.Fatalf("AddMember: %v", err) } // Then rotate them straight back out, which re-seals every item under a // new key and reissues every remaining wrap in one transaction. rotatedNonce := randBytes(t, 12) rotatedKVault := randBytes(t, 32) rotatedKeysetCT, err := crypto.Seal(sec.rk, rotatedNonce, crypto.AAD(acct.ID, "vault.keyset", vault.ID, 1), []byte(keyset)) if err != nil { t.Fatalf("Seal(rotated keyset): %v", err) } rotatedItemNonce := randBytes(t, 12) rotatedItemCT, err := crypto.Seal(rotatedKVault, rotatedItemNonce, crypto.AAD(vault.ID, crypto.ObjectItem, item.ID, 3), crypto.Pad([]byte(body))) if err != nil { t.Fatalf("Seal(rotated item): %v", err) } // One member is retained and one is removed, which is the realistic shape // and keeps both halves of the transaction under the grep: a reissued // wrap of the NEW keyset, and the absence of the removed one. retainedNonce := randBytes(t, 12) retainedCT, err := crypto.Seal(randBytes(t, 32), retainedNonce, crypto.AAD(vault.ID, "vault.keyset", acct.ID, 1), []byte(keyset)) if err != nil { t.Fatalf("Seal(retained keyset): %v", err) } if err := s.RotateVault(ctx, acct.ID, vault.ID, 1, store.Rotation{ Keyset: store.Sealed{Nonce: rotatedNonce, Ciphertext: rotatedKeysetCT}, Items: []store.RotatedItem{{ ID: item.ID, Version: 3, Sealed: store.Sealed{Nonce: rotatedItemNonce, Ciphertext: rotatedItemCT}, }}, Members: []store.MemberWrap{{ AccountID: acct.ID, EphemeralPublicKey: ephemeral.PublicKey().Bytes(), Sealed: store.Sealed{Nonce: retainedNonce, Ciphertext: retainedCT}, }}, }); err != nil { t.Fatalf("RotateVault: %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 } // needle is one thing that must not appear on disk, with a name for the // failure message. type needle struct { what string bytes []byte } func needles(sec secrets) []needle { var out []needle for what, s := range canaries { out = append(out, needle{what, []byte(s)}) } for what, key := range map[string][]byte{ "root key RK": sec.rk, "vault key K_vault": sec.kVault, "KEK_pw": sec.kekPW, "KEK_rc": sec.kekRC, "KEK_prf": sec.kekPRF, "the Account Key": sec.accountK, "the recovery code": sec.recovery, } { out = append(out, needle{what, key}) } return out } // grepFile is the grep itself: raw bytes, no SQL, no assumptions about // where SQLite chose to put anything. func grepFile(t *testing.T, path string, ns []needle) []string { t.Helper() raw, err := os.ReadFile(path) if os.IsNotExist(err) { return nil } if err != nil { t.Fatalf("read %s: %v", path, err) } var hits []string for _, n := range ns { if i := bytes.Index(raw, n.bytes); i >= 0 { hits = append(hits, fmt.Sprintf("%s: %s in plaintext at offset %d", filepath.Base(path), n.what, i)) } } return hits } // grepEverything searches the database and every file replication carries // with it — the write-ahead log included, because the WAL is replicated too // and a leak that is only ever in the WAL is still in the bucket. func grepEverything(t *testing.T, dbPath string, ns []needle) []string { t.Helper() var hits []string for _, suffix := range []string{"", "-wal", "-shm", "-journal"} { hits = append(hits, grepFile(t, dbPath+suffix, ns)...) } return hits } func TestMercilessGreps(t *testing.T) { dir := t.TempDir() dbPath := filepath.Join(dir, "contra.db") // The real ceremony, over the real handlers: the staged signup and a // sign-in, with a canary address and a canary passphrase. This is the // path a person actually takes, and the passphrase is the value that // would be most catastrophic to find on disk. mux, d, _ := newAppAt(t, dbPath) doSignup(t, mux, canaries["email address"], canaries["passphrase"]) if rec := doSignIn(t, mux, canaries["email address"], canaries["passphrase"]); rec.Code != 200 { t.Fatalf("sign in during the greps: status %d", rec.Code) } // A live challenge row as well, so the table is grepped with something in // it rather than empty. postJSON(t, mux, "/auth/challenge", map[string]string{"email": canaries["email address"], "branch": "pw"}) // And a signup that was started and never finished, so signup_sessions is // grepped holding a row for an address nobody confirmed — which is the // state that table spends most of its life in. postJSON(t, mux, "/auth/begin", map[string]string{"email": canaries["second email"]}) sec := writeEverything(t, store.New(d.G)) ns := needles(sec) // A grep over an empty database is green and means nothing. This is what // stops the whole test degrading into that quietly — a write path that // silently stopped writing would otherwise look exactly like a pass. for table, want := range map[string]int64{ // One account from the ceremony plus one from the store paths; the // ceremony's two login keys plus two more; its two wraps plus three. "accounts": 2, "login_keys": 4, "wraps": 5, "vaults": 1, "vault_members": 1, "items": 1, "login_challenges": 1, // One abandoned signup: the row exists, and holds nothing readable. "signup_sessions": 1, } { var n int64 if err := d.G.Raw("SELECT count(*) FROM " + table).Scan(&n).Error; err != nil { t.Fatalf("count %s: %v", table, err) } if n != want { t.Fatalf("%s holds %d rows, want %d: the greps would be searching a "+ "database the write paths never filled", table, n, want) } } // Hot: the WAL still holds recent pages, which is the state the bucket // sees most of the time. if hits := grepEverything(t, dbPath, ns); len(hits) > 0 { t.Errorf("plaintext on disk with the WAL hot:\n %s", strings.Join(hits, "\n ")) } // Checkpointed: pages moved into the main file, and freed pages keep // whatever they used to hold until something overwrites them. if err := d.G.Exec("PRAGMA wal_checkpoint(TRUNCATE)").Error; err != nil { t.Fatalf("checkpoint: %v", err) } if hits := grepEverything(t, dbPath, ns); len(hits) > 0 { t.Errorf("plaintext on disk after checkpoint:\n %s", strings.Join(hits, "\n ")) } // The schema itself. A column named after content leaks what an item // holds without ever storing a value — `has_totp` tells the operator // something true about you — and sqlite_master is where that shows. // // Scoped to CONTRA's own tables: the framework's are not ours to name, // and folding them in would only teach the next person to add exceptions // until the check means nothing. var ddl []string if err := d.G.Raw(`SELECT COALESCE(sql, '') FROM sqlite_master WHERE tbl_name IN (?, ?, ?, ?, ?, ?, ?, ?)`, "accounts", "login_keys", "wraps", "vaults", "vault_members", "items", "login_challenges", "signup_sessions"). Scan(&ddl).Error; err != nil { t.Fatalf("read sqlite_master: %v", err) } if len(ddl) == 0 { t.Fatal("no CONTRA tables found: this test just stopped checking anything") } schema := strings.ToLower(strings.Join(ddl, "\n")) for _, banned := range []string{ "title", "url", "username", "password", "notes", "totp", "hostname", "search", "label", "hint", "display", "preview", } { if strings.Contains(schema, banned) { t.Errorf("a CONTRA column or table is named %q: what an item holds is "+ "itself metadata, and metadata seals client-side like everything "+ "else does", banned) } } d.Close() // And what replication actually copies. Same bytes — the point of doing // it here is that the claim is written down rather than assumed. bucket := filepath.Join(dir, "bucket") if err := os.MkdirAll(bucket, 0o755); err != nil { t.Fatal(err) } for _, suffix := range []string{"", "-wal", "-shm"} { raw, err := os.ReadFile(dbPath + suffix) if os.IsNotExist(err) { continue } if err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(bucket, "contra.db"+suffix), raw, 0o600); err != nil { t.Fatal(err) } } if hits := grepEverything(t, filepath.Join(bucket, "contra.db"), ns); len(hits) > 0 { t.Errorf("plaintext in the replicated objects:\n %s", strings.Join(hits, "\n ")) } } // A canary nobody has seen fail is a canary nobody should trust. // // Discussion 8 asks for proof that a deliberately introduced plaintext // column makes the greps fail. This is that proof, and it runs on every CI // pass rather than having been done once by hand: it adds the column a // future refactor would add — "just the title, so the list renders" — writes // a canary into it, and asserts the same grep the real test uses finds it. // // If this test ever goes green, the greps have stopped working and every // other result in this file is worthless. func TestGrepsCatchAPlaintextColumn(t *testing.T) { dir := t.TempDir() dbPath := filepath.Join(dir, "leaky.db") logger := slog.New(slog.NewTextHandler(io.Discard, nil)) d, err := db.Open(dbPath, logger) if err != nil { t.Fatalf("db.Open: %v", err) } defer d.Close() if _, err := contra.App(d, testConfig(), logger); err != nil { t.Fatalf("App: %v", err) } // The tempting column, and the write that fills it. if err := d.G.Exec( "CREATE TABLE item_titles (item_id BLOB PRIMARY KEY, title TEXT)").Error; err != nil { t.Fatalf("create leaky table: %v", err) } if err := d.G.Exec("INSERT INTO item_titles VALUES (?, ?)", "0123456789abcdef", canaries["item title"]).Error; err != nil { t.Fatalf("insert canary: %v", err) } if err := d.G.Exec("PRAGMA wal_checkpoint(TRUNCATE)").Error; err != nil { t.Fatalf("checkpoint: %v", err) } ns := []needle{{"item title", []byte(canaries["item title"])}} hits := grepEverything(t, dbPath, ns) if len(hits) == 0 { t.Fatal("the greps did not notice a plaintext title column. " + "Every other result in this file is now worthless: fix the grep, " + "do not weaken this test.") } t.Logf("greps caught it, as they must: %s", strings.Join(hits, "; ")) } // The store's API is the first line of the defence: there is nowhere to pass // a title, so a plaintext column is not something you can reach by accident. // This pins the other half — that it refuses an item whose ciphertext was // never quantised, so "the client pads" stays a control rather than a hope. func TestStoreRefusesUnquantisedItem(t *testing.T) { dir := t.TempDir() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) d, err := db.Open(filepath.Join(dir, "contra.db"), logger) if err != nil { t.Fatalf("db.Open: %v", err) } defer d.Close() if _, err := contra.App(d, testConfig(), logger); err != nil { t.Fatalf("App: %v", err) } s := store.New(d.G) vaultID, err := store.NewID() if err != nil { t.Fatal(err) } // 100 bytes plus a tag: a real ciphertext of an unpadded plaintext. unpadded := store.Sealed{Nonce: randBytes(t, 12), Ciphertext: randBytes(t, 116)} if _, err := s.PutItem(context.Background(), randBytes(t, 16), vaultID, randBytes(t, 16), 1, unpadded); err == nil { t.Error("PutItem accepted an item that leaks its own length") } }