carlos / contra Public

Clone
git clone https://amadan.net/carlos/contra

Plain git — no account needed to clone.

Download

Download this file

1package contratest
2
3import (
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.
78var 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.
98type 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
108func 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.
122func 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.
419type needle struct {
420 what string
421 bytes []byte
422}
423
424func 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.
445func 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.
467func 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
476func 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.
605func 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.
646func 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