web: the sealed schema, and the test that keeps us honest about it
The schema is the easy half. The whole SQLite file replicates to object storage, so a plaintext column is not a review comment — it is a leak, in a bucket, with versioned history, forever. What actually protects that is a test somebody can watch fail, so most of this commit is that test. Six tables, and every one of them holds ciphertext, public keys, opaque random ids, or the parameters needed to hand the right blob back. No column for a searchable title, no column for a display name. Item title, URL, username, notes, TOTP seed and password all live inside a single blob: splitting them into columns, even sealed ones, would publish which fields an item has and how long each one is, and would invite the plaintext "just for the list view" column this schema exists to make unreachable. The store package is shaped as the first line of that defence rather than as a data-access layer. None of its functions takes a title, a URL, a username or an email address, and there is nowhere to pass one. The failure being designed against is not a cryptographer's mistake; it is somebody adding a title column next spring because the API made it easy. The greps push canary strings through every write path and search the raw bytes — not a query: the bytes, the write-ahead log included, because the WAL replicates too and a leak that only ever lands there is still in the bucket. They search for two kinds of thing, and the second matters as much as the first: the strings a person would have typed, and the raw key material. A build that stored RK would pass every "is it encrypted?" review ever written. Two things stop the greps degrading into a green light that means nothing. A row-count assertion, because a grep over an empty database passes and proves nothing — a write path that silently stopped writing would otherwise look exactly like success. And TestGrepsCatchAPlaintextColumn, which adds the tempting column, writes a canary into it and asserts the same grep finds it. That runs on every CI pass rather than having been done once by hand: if it ever goes green, every other result in the file is worthless. Proved separately, by mutation, that the real schema leaking makes the real test fail — a Title column on Item plus a store that writes it. Caught four ways: in the hot WAL, in the main file after checkpoint, in the replicated copy, and by name in sqlite_master. Items are quantised into 256-byte buckets before sealing, and PutItem refuses a ciphertext that is not. A1 tolerates size residue; that is no reason to hand over item-length fingerprints. The padding is the client's job, but "the client pads" is a hope and refusing here is a control — and the check is scheme-independent, so it holds for the JS and Swift clients without pinning a scheme the vectors do not yet cover. The bootstrap tripwire from web/1 is replaced rather than deleted, exactly as its own comment instructed. What survives it is the narrower question it could not ask: which tables exist, each named with its owner, so a table cannot arrive without somebody deciding it should.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hz8mMZYs75WuRC1hYpmapJ
8 files changed,
+1061
−54
internal/contra/crypto/pad.go+62 −0internal/contra/migrations.go+14 −14internal/contra/migrations/0001_sealed_schema.sql+28 −0internal/contra/migrations/schema.sql+3 −0internal/contra/models.go+193 −16internal/contra/store/store.go+241 −0internal/contratest/greps_test.go+481 −0internal/contratest/storage_test.go+39 −24
diff --git a/internal/contra/crypto/pad.go b/internal/contra/crypto/pad.go| new file mode 100644 |
| index 0000000..0b6c5ee |
| --- /dev/null |
| +++ b/internal/contra/crypto/pad.go |
| @@ -0,0 +1,62 @@ |
| +package crypto |
| + |
| +import "errors" |
| + |
| +// PadBucket is the size item plaintexts are quantised into before sealing. |
| +// |
| +// A1 tolerates size residue; that is no reason to hand over item-length |
| +// fingerprints for free. An unpadded vault publishes the length of every |
| +// password and every note to anyone holding the bucket, and lengths are |
| +// enough to pick the interesting rows out of a pile of ciphertext. |
| +const PadBucket = 256 |
| + |
| +var ( |
| + ErrPadLength = errors.New("crypto: padded length is not a whole number of buckets") |
| + ErrPadBytes = errors.New("crypto: malformed padding bytes") |
| +) |
| + |
| +// Pad grows b to the next whole bucket using ISO/IEC 7816-4 padding: one |
| +// 0x80 byte, then zeros. Always adds at least one byte, so the boundary case |
| +// (input already bucket-aligned) is unambiguous rather than a special case |
| +// somebody's second implementation gets wrong. |
| +func Pad(b []byte) []byte { |
| + n := len(b) + 1 |
| + if r := n % PadBucket; r != 0 { |
| + n += PadBucket - r |
| + } |
| + out := make([]byte, n) |
| + copy(out, b) |
| + out[len(b)] = 0x80 |
| + return out |
| +} |
| + |
| +// Unpad reverses Pad, rejecting anything that is not exactly what Pad |
| +// produces — a wrong length, a missing marker, or a non-zero byte after it. |
| +func Unpad(b []byte) ([]byte, error) { |
| + if len(b) == 0 || len(b)%PadBucket != 0 { |
| + return nil, ErrPadLength |
| + } |
| + for i := len(b) - 1; i >= 0; i-- { |
| + switch b[i] { |
| + case 0x00: |
| + continue |
| + case 0x80: |
| + return b[:i], nil |
| + default: |
| + return nil, ErrPadBytes |
| + } |
| + } |
| + return nil, ErrPadBytes |
| +} |
| + |
| +// IsBucketAligned reports whether a sealed blob could have come from Pad. |
| +// Sealed length is the padded plaintext plus AES-GCM's 128-bit tag, so the |
| +// storage layer can check the invariant without holding a key. |
| +// |
| +// Scheme-independent on purpose: it says the plaintext was quantised, not |
| +// how. The exact padding bytes are pinned by the golden vectors when the |
| +// item format lands. |
| +func IsBucketAligned(sealed []byte) bool { |
| + const tagLen = 16 |
| + return len(sealed) > tagLen && (len(sealed)-tagLen)%PadBucket == 0 |
| +} |
diff --git a/internal/contra/migrations.go b/internal/contra/migrations.go| index e12ccde..dfd9685 100644 |
| --- a/internal/contra/migrations.go |
| +++ b/internal/contra/migrations.go |
| @@ -1,10 +1,15 @@ |
| package contra |
| import ( |
| + "embed" |
| + |
| "github.com/carlosframework/rastrillo/migrate" |
| "github.com/carlosframework/rastrillo/sessions" |
| ) |
| +//go:embed migrations/*.sql |
| +var migrationFS embed.FS |
| + |
| // Schema is this app's own migrations, in order — nothing else. It is what |
| // `rastrillo migration generate` and `rastrillo migration check` read and |
| // write: both replay a set into an in-memory database and diff the result |
| @@ -12,27 +17,22 @@ import ( |
| // migrations too. Mixed in, the diff would see tables Models knows nothing |
| // about and want to drop every one of them. |
| // |
| -// Empty at bootstrap, and that is the point: CONTRA's own tables are the |
| -// sealed schema, and they land with the merciless greps in web/2 |
| -// (discussion 8) or they do not land. See models.go. |
| -// |
| // Add to it with `rastrillo migration generate` after changing a model, or |
| -// `rastrillo migration new <name>` to write one by hand. Migrations apply |
| -// at boot and are never re-run or reversed: never edit one that has |
| -// shipped, add a new one instead. |
| -var Schema = new(migrate.Set) |
| +// `rastrillo migration new <name>` to write one by hand. Migrations apply at |
| +// boot and are never re-run or reversed: never edit one that has shipped, |
| +// add a new one instead, and never delete data to update. |
| +// |
| +// A migration that adds a table also adds it to the greps |
| +// (internal/contratest/greps_test.go), on the same commit. That is not a |
| +// convention, it is the standing rule: a new storage surface lands with its |
| +// canary or it does not land. |
| +var Schema = migrate.MustFromFS(migrationFS, "contra") |
| // BootSchema is everything applied at boot, in order, and it is what App() |
| // hands to migrate.Apply. Separate from Schema for the reason above: |
| // generate and check would misread a subsystem's tables as ones Models |
| // forgot. |
| // |
| -// sessions is here from the first migration rather than arriving with |
| -// web/3's sign-in, because migrations are additive-only and the first |
| -// deploy of a version carrying them has to be schema-neutral. Getting the |
| -// session tables into 0001 costs nothing now; adding them to a database |
| -// that has already booted is the case the framework refuses. |
| -// |
| // Merge's argument order is apply order, so a subsystem another one reads |
| // goes first. |
| var BootSchema = migrate.Merge(sessions.Schema, Schema) |
diff --git a/internal/contra/migrations/0001_sealed_schema.sql b/internal/contra/migrations/0001_sealed_schema.sql| new file mode 100644 |
| index 0000000..4d18f98 |
| --- /dev/null |
| +++ b/internal/contra/migrations/0001_sealed_schema.sql |
| @@ -0,0 +1,28 @@ |
| +CREATE TABLE `accounts` (`id` blob,`email_index` blob NOT NULL,`kdf_salt` blob NOT NULL,`created_at` datetime,PRIMARY KEY (`id`)); |
| + |
| +CREATE UNIQUE INDEX `idx_accounts_email_index` ON `accounts`(`email_index`); |
| + |
| +CREATE TABLE `login_keys` (`id` blob,`account_id` blob NOT NULL,`branch` text NOT NULL,`public_key` blob NOT NULL,`created_at` datetime,PRIMARY KEY (`id`)); |
| + |
| +CREATE INDEX `idx_login_keys_account_id` ON `login_keys`(`account_id`); |
| + |
| +CREATE TABLE `wraps` (`id` blob,`account_id` blob NOT NULL,`kind` text NOT NULL,`format_version` integer NOT NULL,`version` integer NOT NULL,`kdf_params` text NOT NULL,`cred_salt` blob,`kcv` blob NOT NULL,`nonce` blob NOT NULL,`ciphertext` blob NOT NULL,`created_at` datetime,PRIMARY KEY (`id`)); |
| + |
| +CREATE INDEX `idx_wraps_kind` ON `wraps`(`kind`); |
| + |
| +CREATE INDEX `idx_wraps_account_id` ON `wraps`(`account_id`); |
| + |
| +CREATE TABLE `vaults` (`id` blob,`account_id` blob NOT NULL,`drop_box_public_key` blob NOT NULL,`created_at` datetime,PRIMARY KEY (`id`)); |
| + |
| +CREATE INDEX `idx_vaults_account_id` ON `vaults`(`account_id`); |
| + |
| +CREATE TABLE `vault_members` (`id` blob,`vault_id` blob NOT NULL,`account_id` blob NOT NULL,`ephemeral_public_key` blob NOT NULL,`nonce` blob NOT NULL,`ciphertext` blob NOT NULL,`created_at` datetime,PRIMARY KEY (`id`)); |
| + |
| +CREATE INDEX `idx_vault_members_account_id` ON `vault_members`(`account_id`); |
| + |
| +CREATE INDEX `idx_vault_members_vault_id` ON `vault_members`(`vault_id`); |
| + |
| +CREATE TABLE `items` (`id` blob,`vault_id` blob NOT NULL,`version` integer NOT NULL,`nonce` blob NOT NULL,`ciphertext` blob NOT NULL,`created_at` datetime,`updated_at` datetime,PRIMARY KEY (`id`)); |
| + |
| +CREATE INDEX `idx_items_vault_id` ON `items`(`vault_id`); |
| + |
diff --git a/internal/contra/migrations/schema.sql b/internal/contra/migrations/schema.sql| new file mode 100644 |
| index 0000000..d3807d3 |
| --- /dev/null |
| +++ b/internal/contra/migrations/schema.sql |
| @@ -0,0 +1,3 @@ |
| +-- Generated by rastrillo migration generate; DO NOT EDIT. |
| +-- The schema every migration in this directory adds up to. |
| + |
diff --git a/internal/contra/models.go b/internal/contra/models.go| index 1a72c41..8dc16d5 100644 |
| --- a/internal/contra/models.go |
| +++ b/internal/contra/models.go |
| @@ -1,18 +1,195 @@ |
| package contra |
| -// Models is every model the schema generator manages: `rastrillo |
| -// migration generate` reads it to work out what the database should look |
| -// like, and `rastrillo migration check` fails CI when it and the |
| -// migrations disagree. |
| -// |
| -// Deliberately empty. The scaffold's example model was a plaintext |
| -// `notes(title, body)` table, and in this repo that is not a starting |
| -// point to edit — it is the exact shape the one rule forbids. CONTRA is |
| -// CARLOS A1 strict: the whole SQLite file replicates to object storage, so |
| -// a plaintext column is a plaintext leak, and item names, URLs, usernames |
| -// and vault names all seal client-side exactly like values do. |
| -// |
| -// The sealed schema is web/2's item (discussion 8), and it lands with the |
| -// merciless greps or it does not land. Adding a table here without them |
| -// would be adding a storage surface with no canary over it. |
| -var Models = []any{} |
| +import "time" |
| + |
| +// Models is every model the schema generator manages: `rastrillo migration |
| +// generate` reads it to work out what the database should look like, and |
| +// `rastrillo migration check` fails CI when it and the migrations disagree. |
| +// |
| +// # The rule these tables are shaped by |
| +// |
| +// CONTRA is CARLOS A1 strict, and the whole SQLite file is continuously |
| +// replicated to object storage — so a plaintext column is a plaintext leak, |
| +// in a bucket, forever. Item names, URLs, usernames, folder structure and |
| +// vault names seal client-side exactly like passwords do, because a name is |
| +// a secret too. |
| +// |
| +// What that means for anyone adding a column here: the server stores |
| +// ciphertext, public keys, opaque random ids, and the parameters it needs to |
| +// hand the right blob back. Nothing else. There is no column for a |
| +// searchable title, no column for a display name, and no "just for the |
| +// admin page" column. |
| +// |
| +// **A new storage surface lands with its canary or it does not land.** The |
| +// greps in internal/contratest/greps_test.go push canary strings through |
| +// every write path and search the raw database bytes for them; a new table |
| +// belongs in that test's write list on the same commit that adds it. |
| +// |
| +// Every id is a random UUID, never a content hash: a content-derived |
| +// identifier at rest is both an A1 violation and an equality oracle. |
| +var Models = []any{ |
| + &Account{}, |
| + &LoginKey{}, |
| + &Wrap{}, |
| + &Vault{}, |
| + &VaultMember{}, |
| + &Item{}, |
| +} |
| + |
| +// Account is an identity on this instance. |
| +// |
| +// There is no address column and no provider user-id. EmailIndex is Kass's |
| +// blind index — an HMAC under a server-held pepper — which proves identity |
| +// for sign-in and can never decrypt anything (AGENTS.md decision 3). The |
| +// pepper belongs off the instance, at the routing layer or in a KMS, so that |
| +// a database-plus-bucket leak alone cannot unblind addresses. |
| +type Account struct { |
| + // ID is 16 random bytes. Not derived from the email, not derived from |
| + // anything: an id you can recompute from content is an equality oracle. |
| + ID []byte `gorm:"primaryKey;type:blob"` |
| + |
| + // EmailIndex is HMAC-SHA-256(pepper, normalized_email), 32 bytes. |
| + EmailIndex []byte `gorm:"uniqueIndex;type:blob;not null"` |
| + |
| + // KDFSalt is the 16 random bytes the passphrase branch stretches |
| + // against, served pre-auth. The salt endpoint answers an unknown email |
| + // with a deterministic fake so account existence does not leak from it. |
| + KDFSalt []byte `gorm:"type:blob;not null"` |
| + |
| + CreatedAt time.Time |
| +} |
| + |
| +// LoginKey is a public key the server authenticates a signed challenge |
| +// against — the whole of what it stores about a credential. |
| +// |
| +// Not a password verifier, hashed or otherwise. A fully compromised server |
| +// holds something that cannot decrypt, cannot be replayed to impersonate, |
| +// and adds no offline attack surface it did not already have via the wraps. |
| +type LoginKey struct { |
| + ID []byte `gorm:"primaryKey;type:blob"` |
| + AccountID []byte `gorm:"index;type:blob;not null"` |
| + |
| + // Branch is "pw" or "rc" — which peer credential this key belongs to. |
| + // A label of ours, not derived from anything the user typed. |
| + Branch string `gorm:"not null"` |
| + |
| + // PublicKey is the SEC1 uncompressed P-256 point, 65 bytes. |
| + PublicKey []byte `gorm:"type:blob;not null"` |
| + |
| + CreatedAt time.Time |
| +} |
| + |
| +// Wrap is one of the three peer wraps of the root key: passphrase, recovery |
| +// code, passkey PRF. Peers, not a chain — each is independently sufficient |
| +// to reach RK and none can be derived from another. |
| +// |
| +// DESIGN.md requires every wrap row to carry a format version, the KDF |
| +// parameters (authenticated as AAD), a 16-byte KCV, a 96-bit nonce and the |
| +// ciphertext. All six are here, and the KCV is load-bearing twice over: |
| +// AES-GCM is not key-committing, so it is the commitment check, and it is |
| +// what tells "wrong key" from "corrupted blob" — the distinction that lets a |
| +// passkey provider returning a mismatched PRF be read as "fast unlock |
| +// unavailable" rather than an authentication failure. |
| +type Wrap struct { |
| + ID []byte `gorm:"primaryKey;type:blob"` |
| + AccountID []byte `gorm:"index;type:blob;not null"` |
| + |
| + // Kind is the AAD's object type: "wrap.pw", "wrap.rc" or "wrap.prf". |
| + Kind string `gorm:"index;not null"` |
| + |
| + // FormatVersion and Version are the wrap header. Version is bound into |
| + // the AAD, so a malicious server cannot roll a row back to an older |
| + // ciphertext and have it open. |
| + FormatVersion int `gorm:"not null"` |
| + Version uint64 `gorm:"not null"` |
| + |
| + // KDFParams is the canonical JSON of the parameters this wrap was made |
| + // under, authenticated as AAD. Stored so parameters can be raised later |
| + // with a transparent re-wrap on the next successful unlock — and so the |
| + // client can refuse a set below its floor. |
| + KDFParams string `gorm:"not null"` |
| + |
| + // CredSalt is the passkey credential's 32 non-secret random bytes, |
| + // empty on the other two branches. Several PRF wraps per credential are |
| + // harmless: each is an independent ciphertext of the same RK, which is |
| + // what lets a newly observed provider output be enrolled as an |
| + // additional wrap instead of stranding anyone. |
| + CredSalt []byte `gorm:"type:blob"` |
| + |
| + KCV []byte `gorm:"type:blob;not null"` // 16 bytes |
| + Nonce []byte `gorm:"type:blob;not null"` // 12 bytes |
| + Ciphertext []byte `gorm:"type:blob;not null"` // RK, sealed, tag appended |
| + |
| + CreatedAt time.Time |
| +} |
| + |
| +// Vault is a container of items. Its name is not here: a vault name is |
| +// metadata, metadata is a secret, and it seals into the vault keyset the |
| +// members hold. The server knows a vault exists and who it serves bytes to. |
| +type Vault struct { |
| + ID []byte `gorm:"primaryKey;type:blob"` |
| + |
| + // AccountID is the owner. A shared vault lives on its owner's instance; |
| + // other members are identities on that instance (AGENTS.md decision 1). |
| + AccountID []byte `gorm:"index;type:blob;not null"` |
| + |
| + // DropBoxPublicKey is vP_j, the SEC1 uncompressed P-256 point anything |
| + // can seal to without unlocking — how a daemon captures an item with no |
| + // key in memory. The private half never leaves the members. |
| + DropBoxPublicKey []byte `gorm:"type:blob;not null"` |
| + |
| + CreatedAt time.Time |
| +} |
| + |
| +// VaultMember is the vault keyset sealed to one member's account public key: |
| +// ephemeral-static ECDH over P-256, HKDF, AES-GCM. |
| +// |
| +// These rows are the sharing graph, and the sharing graph is at-rest |
| +// metadata that A1 tolerates — wrap rows reveal who shares with whom. That |
| +// is published in the model rather than hidden, and hiding it (per-vault |
| +// pseudonymous member slots) is a choice we have looked at and not made. |
| +// |
| +// Removing a member rotates the vault, because in a seal-to-public-key model |
| +// revocation *is* rotation: the removed member may have cached the key. |
| +// Nothing already downloaded is un-downloaded, and no copy pretends |
| +// otherwise. |
| +type VaultMember struct { |
| + ID []byte `gorm:"primaryKey;type:blob"` |
| + VaultID []byte `gorm:"index;type:blob;not null"` |
| + AccountID []byte `gorm:"index;type:blob;not null"` |
| + |
| + // EphemeralPublicKey is the sender's throwaway half of the ECDH. |
| + EphemeralPublicKey []byte `gorm:"type:blob;not null"` |
| + |
| + Nonce []byte `gorm:"type:blob;not null"` |
| + Ciphertext []byte `gorm:"type:blob;not null"` |
| + |
| + CreatedAt time.Time |
| +} |
| + |
| +// Item is one entry in a vault, and it is a single opaque blob on purpose. |
| +// |
| +// Title, URL, username, notes, TOTP seed and password are all inside the one |
| +// ciphertext. Splitting them into columns — even sealed ones — would publish |
| +// which fields an item has and how long each is, and would invite exactly |
| +// the plaintext "just the title, for the list" column this whole file exists |
| +// to refuse. The client seals the item; the list view is built from items |
| +// the client has already opened. |
| +// |
| +// Ciphertext lengths are quantised into 256-byte buckets before sealing, so |
| +// the bucket cannot be read for item-length fingerprints. Store.PutItem |
| +// enforces it rather than trusting the caller. |
| +type Item struct { |
| + ID []byte `gorm:"primaryKey;type:blob"` |
| + VaultID []byte `gorm:"index;type:blob;not null"` |
| + |
| + // Version is bound into the AAD along with the vault and item ids, so a |
| + // ciphertext cannot be transplanted between slots or rolled back. |
| + Version uint64 `gorm:"not null"` |
| + |
| + Nonce []byte `gorm:"type:blob;not null"` |
| + Ciphertext []byte `gorm:"type:blob;not null"` |
| + |
| + CreatedAt time.Time |
| + UpdatedAt time.Time |
| +} |
diff --git a/internal/contra/store/store.go b/internal/contra/store/store.go| new file mode 100644 |
| index 0000000..0e11f7f |
| --- /dev/null |
| +++ b/internal/contra/store/store.go |
| @@ -0,0 +1,241 @@ |
| +// Package store is every write path to CONTRA's sealed tables, and the only |
| +// one. Nothing else in the app writes a row. |
| +// |
| +// # Why the API looks like this |
| +// |
| +// The server is host-blind: it holds ciphertext, public keys and opaque |
| +// random ids, and nothing else. So none of these functions takes a title, a |
| +// URL, a username, a vault name or an email address, and there is no |
| +// argument you could pass one to. Sealing happens in the client, where the |
| +// keys are; by the time bytes reach here they are already opaque, and the |
| +// type system is the first thing saying so. |
| +// |
| +// That is deliberate defence. The failure this package is shaped against is |
| +// not a cryptographer's mistake — it is somebody adding a `title` column |
| +// next spring so the list view can be rendered server-side, because the API |
| +// made it easy. Here it is not easy: there is nowhere to put it. |
| +// |
| +// # The rule |
| +// |
| +// A new storage surface lands with its canary or it does not land. Every |
| +// function below is driven by internal/contratest/greps_test.go, which |
| +// pushes canary strings through it and searches the raw database bytes. |
| +// Adding a write path means adding it there, on the same commit. |
| +package store |
| + |
| +import ( |
| + "context" |
| + "crypto/rand" |
| + "errors" |
| + "fmt" |
| + "time" |
| + |
| + "gorm.io/gorm" |
| + |
| + "github.com/carlosframework/contra/internal/contra" |
| + "github.com/carlosframework/contra/internal/contra/crypto" |
| +) |
| + |
| +// FormatVersion is the wrap header version this build writes. |
| +const FormatVersion = 1 |
| + |
| +var ( |
| + ErrNotAligned = errors.New("store: item ciphertext is not quantised into 256-byte buckets") |
| + ErrShortField = errors.New("store: a required sealed field is empty or the wrong length") |
| +) |
| + |
| +// Store writes the sealed tables. It holds a *gorm.DB and no keys, because |
| +// there are no keys on this side to hold. |
| +type Store struct{ db *gorm.DB } |
| + |
| +func New(db *gorm.DB) *Store { return &Store{db: db} } |
| + |
| +// NewID is the only way a row gets an identifier here: 16 bytes of CSPRNG. |
| +// |
| +// Never a content hash, and never derived from anything the user typed. A |
| +// content-derived identifier at rest is an A1 violation and an equality |
| +// oracle — it lets the operator prove two accounts hold the same item |
| +// without opening either. |
| +func NewID() ([]byte, error) { |
| + id := make([]byte, 16) |
| + if _, err := rand.Read(id); err != nil { |
| + return nil, fmt.Errorf("store: generate id: %w", err) |
| + } |
| + return id, nil |
| +} |
| + |
| +// Sealed is one AEAD blob as it is stored: a 96-bit nonce and the ciphertext |
| +// with its 128-bit tag appended. |
| +type Sealed struct { |
| + Nonce []byte |
| + Ciphertext []byte |
| +} |
| + |
| +func (s Sealed) valid() bool { return len(s.Nonce) == 12 && len(s.Ciphertext) > 16 } |
| + |
| +// CreateAccount records an identity. emailIndex is the blind index — |
| +// HMAC-SHA-256 under the server-held pepper — and this function neither |
| +// takes nor could compute an address. |
| +func (s *Store) CreateAccount(ctx context.Context, emailIndex, kdfSalt []byte) (*contra.Account, error) { |
| + if len(emailIndex) != 32 || len(kdfSalt) != 16 { |
| + return nil, ErrShortField |
| + } |
| + id, err := NewID() |
| + if err != nil { |
| + return nil, err |
| + } |
| + a := &contra.Account{ |
| + ID: id, |
| + EmailIndex: emailIndex, |
| + KDFSalt: kdfSalt, |
| + CreatedAt: time.Now().UTC(), |
| + } |
| + return a, s.db.WithContext(ctx).Create(a).Error |
| +} |
| + |
| +// PutLoginKey stores a public key to verify signed challenges against. There |
| +// is no sibling that stores a secret, because there is no secret to store. |
| +func (s *Store) PutLoginKey(ctx context.Context, accountID []byte, branch string, publicKey []byte) error { |
| + if len(publicKey) != 65 { |
| + return ErrShortField |
| + } |
| + id, err := NewID() |
| + if err != nil { |
| + return err |
| + } |
| + return s.db.WithContext(ctx).Create(&contra.LoginKey{ |
| + ID: id, |
| + AccountID: accountID, |
| + Branch: branch, |
| + PublicKey: publicKey, |
| + CreatedAt: time.Now().UTC(), |
| + }).Error |
| +} |
| + |
| +// PutWrap stores one peer wrap of the root key. |
| +// |
| +// kdfParams is canonical JSON, authenticated as AAD by the client, and kept |
| +// so parameters can be raised later with a transparent re-wrap — and so a |
| +// client can see and refuse a set below its floor. credSalt is the passkey |
| +// credential's non-secret 32 bytes, nil on the other two branches. |
| +func (s *Store) PutWrap(ctx context.Context, accountID []byte, kind, kdfParams string, |
| + version uint64, kcv, credSalt []byte, sealed Sealed) error { |
| + if !sealed.valid() || len(kcv) != 16 { |
| + return ErrShortField |
| + } |
| + id, err := NewID() |
| + if err != nil { |
| + return err |
| + } |
| + return s.db.WithContext(ctx).Create(&contra.Wrap{ |
| + ID: id, |
| + AccountID: accountID, |
| + Kind: kind, |
| + FormatVersion: FormatVersion, |
| + Version: version, |
| + KDFParams: kdfParams, |
| + CredSalt: credSalt, |
| + KCV: kcv, |
| + Nonce: sealed.Nonce, |
| + Ciphertext: sealed.Ciphertext, |
| + CreatedAt: time.Now().UTC(), |
| + }).Error |
| +} |
| + |
| +// CreateVault records a vault. Its name is not a parameter: a vault name is |
| +// metadata, metadata is a secret, and it seals into the keyset the members |
| +// hold. dropBoxPublicKey is vP_j — what a daemon seals a captured item to |
| +// with no key in memory. |
| +func (s *Store) CreateVault(ctx context.Context, accountID, dropBoxPublicKey []byte) (*contra.Vault, error) { |
| + if len(dropBoxPublicKey) != 65 { |
| + return nil, ErrShortField |
| + } |
| + id, err := NewID() |
| + if err != nil { |
| + return nil, err |
| + } |
| + v := &contra.Vault{ |
| + ID: id, |
| + AccountID: accountID, |
| + DropBoxPublicKey: dropBoxPublicKey, |
| + CreatedAt: time.Now().UTC(), |
| + } |
| + return v, s.db.WithContext(ctx).Create(v).Error |
| +} |
| + |
| +// PutVaultMember stores the vault keyset sealed to one member's account |
| +// public key. These rows are the sharing graph — at-rest metadata A1 |
| +// tolerates, and published in the model rather than hidden. |
| +func (s *Store) PutVaultMember(ctx context.Context, vaultID, accountID, |
| + ephemeralPublicKey []byte, sealed Sealed) error { |
| + if !sealed.valid() || len(ephemeralPublicKey) != 65 { |
| + return ErrShortField |
| + } |
| + id, err := NewID() |
| + if err != nil { |
| + return err |
| + } |
| + return s.db.WithContext(ctx).Create(&contra.VaultMember{ |
| + ID: id, |
| + VaultID: vaultID, |
| + AccountID: accountID, |
| + EphemeralPublicKey: ephemeralPublicKey, |
| + Nonce: sealed.Nonce, |
| + Ciphertext: sealed.Ciphertext, |
| + CreatedAt: time.Now().UTC(), |
| + }).Error |
| +} |
| + |
| +// PutItem stores one item: title, URL, username, notes and password all |
| +// inside the single blob, because splitting them into columns would publish |
| +// which fields an item has and how long each one is. |
| +// |
| +// It refuses a ciphertext that is not quantised into 256-byte buckets. The |
| +// padding is the client's job, but "the client pads" is a hope; refusing |
| +// here is a control. The check is scheme-independent — it says the plaintext |
| +// was quantised, not how — so it holds for the JS and Swift clients too. |
| +func (s *Store) PutItem(ctx context.Context, vaultID []byte, version uint64, sealed Sealed) (*contra.Item, error) { |
| + if !sealed.valid() { |
| + return nil, ErrShortField |
| + } |
| + if !crypto.IsBucketAligned(sealed.Ciphertext) { |
| + return nil, ErrNotAligned |
| + } |
| + id, err := NewID() |
| + if err != nil { |
| + return nil, err |
| + } |
| + now := time.Now().UTC() |
| + it := &contra.Item{ |
| + ID: id, |
| + VaultID: vaultID, |
| + Version: version, |
| + Nonce: sealed.Nonce, |
| + Ciphertext: sealed.Ciphertext, |
| + CreatedAt: now, |
| + UpdatedAt: now, |
| + } |
| + return it, s.db.WithContext(ctx).Create(it).Error |
| +} |
| + |
| +// UpdateItem replaces an item's sealed body and bumps its version. The |
| +// version is bound into the client's AAD, so a server that served an old |
| +// ciphertext back under a new version number would produce a blob that does |
| +// not open. |
| +func (s *Store) UpdateItem(ctx context.Context, itemID []byte, version uint64, sealed Sealed) error { |
| + if !sealed.valid() { |
| + return ErrShortField |
| + } |
| + if !crypto.IsBucketAligned(sealed.Ciphertext) { |
| + return ErrNotAligned |
| + } |
| + return s.db.WithContext(ctx).Model(&contra.Item{}). |
| + Where("id = ?", itemID). |
| + Select("Version", "Nonce", "Ciphertext", "UpdatedAt"). |
| + Updates(map[string]any{ |
| + "Version": version, |
| + "Nonce": sealed.Nonce, |
| + "Ciphertext": sealed.Ciphertext, |
| + "UpdatedAt": time.Now().UTC(), |
| + }).Error |
| +} |
diff --git a/internal/contratest/greps_test.go b/internal/contratest/greps_test.go| new file mode 100644 |
| index 0000000..f708c82 |
| --- /dev/null |
| +++ b/internal/contratest/greps_test.go |
| @@ -0,0 +1,481 @@ |
| +package contratest |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/hmac" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "fmt" |
| + "io" |
| + "log/slog" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "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 DESIGN.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. |
| +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", |
| +} |
| + |
| +// 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, 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, 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) |
| + } |
| + vault, err := s.CreateVault(ctx, acct.ID, dropBox.PublicKey().Bytes()) |
| + 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, vault.ID, 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, item.ID, 2, store.Sealed{Nonce: editNonce, Ciphertext: editCT}); err != nil { |
| + t.Fatalf("UpdateItem: %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") |
| + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| + |
| + d, err := db.Open(dbPath, logger) |
| + if err != nil { |
| + t.Fatalf("db.Open: %v", err) |
| + } |
| + if _, err := contra.App(d, testOrigin, logger); err != nil { |
| + t.Fatalf("App: %v", err) |
| + } |
| + |
| + 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{ |
| + "accounts": 1, "login_keys": 2, "wraps": 3, |
| + "vaults": 1, "vault_members": 1, "items": 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"). |
| + 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, testOrigin, 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, testOrigin, 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(), vaultID, 1, unpadded); err == nil { |
| + t.Error("PutItem accepted an item that leaks its own length") |
| + } |
| +} |
diff --git a/internal/contratest/storage_test.go b/internal/contratest/storage_test.go| index 7bd56a1..d1a86b2 100644 |
| --- a/internal/contratest/storage_test.go |
| +++ b/internal/contratest/storage_test.go |
| @@ -87,44 +87,59 @@ func TestMigrationsApplyOnce(t *testing.T) { |
| } |
| } |
| -// The A1 tripwire. |
| +// The bootstrap tripwire is gone, replaced by the thing it was holding the |
| +// door for: the merciless greps in greps_test.go. What is left here is the |
| +// narrower question of WHICH tables exist, which the greps do not ask. |
| // |
| -// CONTRA is CARLOS A1 strict and the whole SQLite file replicates to |
| -// object storage, so a plaintext column is a plaintext leak — item names, |
| -// URLs, usernames and vault names included. The standing rule in AGENTS.md |
| -// is that a new storage surface lands with its canary or it does not land. |
| -// |
| -// At bootstrap the app owns no tables at all: everything here belongs to a |
| -// framework subsystem. So the moment somebody adds one, this test fails, |
| -// and it is meant to. It is not the guard — the merciless greps in web/2 |
| -// (discussion 8) are — it is the thing that stops a table arriving quietly |
| -// while those greps do not exist yet. When they land, replace this test |
| -// with them rather than deleting it. |
| -func TestAppOwnsNoTablesYet(t *testing.T) { |
| +// A table appearing without anybody deciding to add it is how a storage |
| +// surface arrives unnoticed. So the set is written down, each with its |
| +// owner, and a new one fails until somebody adds it here — and, on the same |
| +// commit, to the greps' write list. |
| +func TestSchemaIsTheDeclaredSet(t *testing.T) { |
| d := newDB(t) |
| logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| if _, err := contra.App(d, testOrigin, logger); err != nil { |
| t.Fatalf("App: %v", err) |
| } |
| - if len(contra.Models) != 0 { |
| - t.Fatalf("contra.Models declares %d model(s); the sealed schema and its "+ |
| - "greps are web/2 (discussion 8), and a storage surface does not land "+ |
| - "without them", len(contra.Models)) |
| - } |
| - |
| - // Everything a fresh boot is allowed to have created, and who owns it. |
| - allowed := map[string]string{ |
| + owners := map[string]string{ |
| "rastrillo_migrations": "rastrillo/migrate — the migration ledger", |
| "sessions": "rastrillo/sessions", |
| "sqlite_sequence": "sqlite, for AUTOINCREMENT", |
| + |
| + // CONTRA's own. Every one holds ciphertext, public keys, opaque |
| + // random ids, or the parameters needed to hand the right blob back. |
| + "accounts": "an identity: a blind index, never an address", |
| + "login_keys": "public keys to verify signed challenges against", |
| + "wraps": "the three peer wraps of RK", |
| + "vaults": "a vault's existence and its drop-box public key", |
| + "vault_members": "vault keysets sealed to members — the sharing graph", |
| + "items": "one opaque blob per item, quantised into buckets", |
| } |
| + |
| + found := map[string]bool{} |
| for _, name := range tableNames(t, d) { |
| - if _, ok := allowed[name]; !ok { |
| - t.Errorf("unexpected table %q after boot: an app table has landed "+ |
| - "without the merciless greps over it (discussion 8)", name) |
| + found[name] = true |
| + if _, ok := owners[name]; !ok { |
| + t.Errorf("undeclared table %q. If you meant to add it: name its owner "+ |
| + "here, and add its write path to writeEverything in greps_test.go "+ |
| + "on this same commit. A storage surface lands with its canary or "+ |
| + "it does not land.", name) |
| } |
| } |
| + for name := range owners { |
| + if name == "sqlite_sequence" { |
| + continue // only exists once something AUTOINCREMENTs |
| + } |
| + if !found[name] { |
| + t.Errorf("declared table %q is missing: a migration did not apply", name) |
| + } |
| + } |
| + |
| + if len(contra.Models) == 0 { |
| + t.Error("contra.Models is empty, so `rastrillo migration check` is " + |
| + "comparing nothing against nothing") |
| + } |
| } |
| func tableNames(t *testing.T, d *db.DB) []string { |