| 1 | // Package model is CONTRA's sealed tables, and nothing else. |
| 2 | // |
| 3 | // It is a package of its own so that the app, the store and the auth |
| 4 | // ceremony can all name a row without importing one another — but the |
| 5 | // separation earns its keep for a second reason: every struct in here is the |
| 6 | // complete answer to "what does the instance keep?", in one file, with |
| 7 | // nothing else in the way of reading it. |
| 8 | // |
| 9 | // # The rule these tables are shaped by |
| 10 | // |
| 11 | // CONTRA is CARLOS A1 strict, and the whole SQLite file is continuously |
| 12 | // replicated to object storage — so a plaintext column is not a bug caught in |
| 13 | // review, it is a plaintext leak, in a bucket, forever. Item names, URLs, |
| 14 | // usernames, folder structure and vault names seal client-side exactly like |
| 15 | // passwords do, because a name is a secret too. |
| 16 | // |
| 17 | // What that means for anyone adding a column: the server stores ciphertext, |
| 18 | // public keys, opaque random ids, and the parameters it needs to hand the |
| 19 | // right blob back. Nothing else. There is no column for a searchable title, |
| 20 | // no column for a display name, and no "just for the admin page" column. |
| 21 | // |
| 22 | // **A new storage surface lands with its canary or it does not land.** The |
| 23 | // greps in internal/contratest/greps_test.go push canary strings through |
| 24 | // every write path and search the raw database bytes; a new table belongs in |
| 25 | // that test's write list on the same commit that adds it. |
| 26 | // |
| 27 | // Every id is a random UUID, never a content hash: a content-derived |
| 28 | // identifier at rest is both an A1 violation and an equality oracle. |
| 29 | package model |
| 30 | |
| 31 | import "time" |
| 32 | |
| 33 | // Account is an identity on this instance. |
| 34 | // |
| 35 | // There is no address column and no provider user-id. EmailIndex is Kass's |
| 36 | // blind index — an HMAC under a server-held pepper — which proves identity |
| 37 | // for sign-in and can never decrypt anything (AGENTS.md decision 3). The |
| 38 | // pepper belongs off the instance, at the routing layer or in a KMS, so that |
| 39 | // a database-plus-bucket leak alone cannot unblind addresses. |
| 40 | type Account struct { |
| 41 | // ID is 16 random bytes. Not derived from the email, not derived from |
| 42 | // anything: an id you can recompute from content is an equality oracle. |
| 43 | ID []byte `gorm:"primaryKey;type:blob"` |
| 44 | |
| 45 | // EmailIndex is HMAC-SHA-256(pepper, normalized_email), 32 bytes. |
| 46 | EmailIndex []byte `gorm:"uniqueIndex;type:blob;not null"` |
| 47 | |
| 48 | // KDFSalt is the 16 random bytes the passphrase branch stretches |
| 49 | // against, served pre-auth. The salt endpoint answers an unknown email |
| 50 | // with a deterministic fake so account existence does not leak from it. |
| 51 | KDFSalt []byte `gorm:"type:blob;not null"` |
| 52 | |
| 53 | // PublicKey is the account's P-256 public half — what another member |
| 54 | // seals a shared vault keyset to. The private half lives inside the |
| 55 | // account keyset below and never reaches the server. |
| 56 | PublicKey []byte `gorm:"type:blob"` |
| 57 | |
| 58 | // The account keyset — d_acct and K_meta — sealed under RK, with AAD |
| 59 | // (account_id, "account.keyset", account_id, version). It is the layer |
| 60 | // between the root key and everything a vault needs, so that rotating a |
| 61 | // credential does not mean re-encrypting every item. |
| 62 | KeysetNonce []byte `gorm:"type:blob"` |
| 63 | KeysetCiphertext []byte `gorm:"type:blob"` |
| 64 | |
| 65 | CreatedAt time.Time |
| 66 | } |
| 67 | |
| 68 | // LoginKey is a public key the server authenticates a signed challenge |
| 69 | // against — the whole of what it stores about a credential. |
| 70 | // |
| 71 | // Not a password verifier, hashed or otherwise. A fully compromised server |
| 72 | // holds something that cannot decrypt, cannot be replayed to impersonate, |
| 73 | // and adds no offline attack surface it did not already have via the wraps. |
| 74 | type LoginKey struct { |
| 75 | ID []byte `gorm:"primaryKey;type:blob"` |
| 76 | AccountID []byte `gorm:"index;type:blob;not null"` |
| 77 | |
| 78 | // Branch is "pw" or "rc" — which peer credential this key belongs to. |
| 79 | // A label of ours, not derived from anything the user typed. |
| 80 | Branch string `gorm:"not null"` |
| 81 | |
| 82 | // PublicKey is the SEC1 uncompressed P-256 point, 65 bytes. |
| 83 | PublicKey []byte `gorm:"type:blob;not null"` |
| 84 | |
| 85 | CreatedAt time.Time |
| 86 | } |
| 87 | |
| 88 | // Wrap is one of the three peer wraps of the root key: passphrase, recovery |
| 89 | // code, passkey PRF. Peers, not a chain — each is independently sufficient |
| 90 | // to reach RK and none can be derived from another. |
| 91 | // |
| 92 | // PROTOCOL.md requires every wrap row to carry a format version, the KDF |
| 93 | // parameters (authenticated as AAD), a 16-byte KCV, a 96-bit nonce and the |
| 94 | // ciphertext. All six are here, and the KCV is load-bearing twice over: |
| 95 | // AES-GCM is not key-committing, so it is the commitment check, and it is |
| 96 | // what tells "wrong key" from "corrupted blob" — the distinction that lets a |
| 97 | // passkey provider returning a mismatched PRF be read as "fast unlock |
| 98 | // unavailable" rather than an authentication failure. |
| 99 | type Wrap struct { |
| 100 | ID []byte `gorm:"primaryKey;type:blob"` |
| 101 | AccountID []byte `gorm:"index;type:blob;not null"` |
| 102 | |
| 103 | // Kind is the AAD's object type: "wrap.pw", "wrap.rc" or "wrap.prf". |
| 104 | Kind string `gorm:"index;not null"` |
| 105 | |
| 106 | // FormatVersion and Version are the wrap header. Version is bound into |
| 107 | // the AAD, so a malicious server cannot roll a row back to an older |
| 108 | // ciphertext and have it open. |
| 109 | FormatVersion int `gorm:"not null"` |
| 110 | Version uint64 `gorm:"not null"` |
| 111 | |
| 112 | // KDFParams is the canonical JSON of the parameters this wrap was made |
| 113 | // under, authenticated as AAD. Stored so parameters can be raised later |
| 114 | // with a transparent re-wrap on the next successful unlock — and so the |
| 115 | // client can refuse a set below its floor. |
| 116 | KDFParams string `gorm:"not null"` |
| 117 | |
| 118 | // CredSalt is the passkey credential's 32 non-secret random bytes, |
| 119 | // empty on the other two branches. Several PRF wraps per credential are |
| 120 | // harmless: each is an independent ciphertext of the same RK, which is |
| 121 | // what lets a newly observed provider output be enrolled as an |
| 122 | // additional wrap instead of stranding anyone. |
| 123 | CredSalt []byte `gorm:"type:blob"` |
| 124 | |
| 125 | // CredentialID is the passkey this wrap belongs to, empty on the other two |
| 126 | // branches. It lives here rather than in a credentials table because a |
| 127 | // passkey, to this product, IS a wrap of RK — there is nothing else to |
| 128 | // know about one. Several rows per credential are expected and harmless: |
| 129 | // a provider that returns a different PRF for the same credential gets |
| 130 | // another wrap rather than an error, which is how PROTOCOL.md says to |
| 131 | // tolerate the 1Password-class mismatch. |
| 132 | CredentialID []byte `gorm:"index;type:blob"` |
| 133 | |
| 134 | KCV []byte `gorm:"type:blob;not null"` // 16 bytes |
| 135 | Nonce []byte `gorm:"type:blob;not null"` // 12 bytes |
| 136 | Ciphertext []byte `gorm:"type:blob;not null"` // RK, sealed, tag appended |
| 137 | |
| 138 | CreatedAt time.Time |
| 139 | } |
| 140 | |
| 141 | // Vault is a container of items. Its name is not here: a vault name is |
| 142 | // metadata, metadata is a secret, and it seals into the vault keyset the |
| 143 | // members hold. The server knows a vault exists and who it serves bytes to. |
| 144 | type Vault struct { |
| 145 | ID []byte `gorm:"primaryKey;type:blob"` |
| 146 | |
| 147 | // AccountID is the owner. A shared vault lives on its owner's instance; |
| 148 | // other members are identities on that instance (AGENTS.md decision 1). |
| 149 | AccountID []byte `gorm:"index;type:blob;not null"` |
| 150 | |
| 151 | // DropBoxPublicKey is vP_j, the SEC1 uncompressed P-256 point anything |
| 152 | // can seal to without unlocking — how a daemon captures an item with no |
| 153 | // key in memory. The private half never leaves the members. |
| 154 | DropBoxPublicKey []byte `gorm:"type:blob;not null"` |
| 155 | |
| 156 | // The vault keyset — K_vault and the drop-box private half — sealed for |
| 157 | // the owner, AAD (account_id, "vault.keyset", vault_id, version). |
| 158 | // |
| 159 | // Sealed directly under RK, which is the right shape for a vault with one |
| 160 | // member and the wrong shape for a shared one. Sharing wraps the same |
| 161 | // keyset to each member's account public key by ephemeral-static ECDH, |
| 162 | // into the vault_members rows that already exist for it — that is web/8 |
| 163 | // (discussion 14), and it will decide whether the owner's copy moves |
| 164 | // there too. Recorded here so the next person meets a decision rather |
| 165 | // than an inconsistency. |
| 166 | KeysetNonce []byte `gorm:"type:blob"` |
| 167 | KeysetCiphertext []byte `gorm:"type:blob"` |
| 168 | |
| 169 | // Epoch counts vault rotations, and is bound into every item's AAD. |
| 170 | // |
| 171 | // Removing a member rotates the vault — in a seal-to-public-key model |
| 172 | // revocation IS rotation, because the removed member may have cached |
| 173 | // K_vault. Rotation is eager: every item re-sealed under the new key and |
| 174 | // every member wrap reissued, in ONE transaction guarded by a |
| 175 | // compare-and-swap on this number. |
| 176 | // |
| 177 | // That is what the epoch buys. Without it a writer who began an edit |
| 178 | // before the rotation landed would write an item sealed under the key |
| 179 | // that was just revoked, and nothing would notice; with it, that writer's |
| 180 | // AAD names the old epoch and the write cannot open. It is also why |
| 181 | // rotation is eager rather than lazy: lazy needs a per-row |
| 182 | // key-generation column, and that column tells the operator which items |
| 183 | // changed since a removal. |
| 184 | // |
| 185 | // Starts at 1. PROTOCOL.md, "Rotation — and what does not heal". |
| 186 | Epoch uint64 `gorm:"not null;default:1"` |
| 187 | |
| 188 | CreatedAt time.Time |
| 189 | } |
| 190 | |
| 191 | // VaultMember is the vault keyset sealed to one member's account public key: |
| 192 | // ephemeral-static ECDH over P-256, HKDF, AES-GCM. |
| 193 | // |
| 194 | // These rows are the sharing graph, and the sharing graph is at-rest |
| 195 | // metadata that A1 tolerates — wrap rows reveal who shares with whom. That |
| 196 | // is published in the model rather than hidden, and hiding it (per-vault |
| 197 | // pseudonymous member slots) is a choice we have looked at and not made. |
| 198 | // |
| 199 | // Removing a member rotates the vault, because in a seal-to-public-key model |
| 200 | // revocation *is* rotation: the removed member may have cached the key. |
| 201 | // Nothing already downloaded is un-downloaded, and no copy pretends |
| 202 | // otherwise. |
| 203 | type VaultMember struct { |
| 204 | ID []byte `gorm:"primaryKey;type:blob"` |
| 205 | VaultID []byte `gorm:"index;type:blob;not null"` |
| 206 | AccountID []byte `gorm:"index;type:blob;not null"` |
| 207 | |
| 208 | // EphemeralPublicKey is the sender's throwaway half of the ECDH. |
| 209 | EphemeralPublicKey []byte `gorm:"type:blob;not null"` |
| 210 | |
| 211 | Nonce []byte `gorm:"type:blob;not null"` |
| 212 | Ciphertext []byte `gorm:"type:blob;not null"` |
| 213 | |
| 214 | CreatedAt time.Time |
| 215 | } |
| 216 | |
| 217 | // Item is one entry in a vault, and it is a single opaque blob on purpose. |
| 218 | // |
| 219 | // Title, URL, username, notes, TOTP seed and password are all inside the one |
| 220 | // ciphertext. Splitting them into columns — even sealed ones — would publish |
| 221 | // which fields an item has and how long each is, and would invite exactly |
| 222 | // the plaintext "just the title, for the list" column this whole file exists |
| 223 | // to refuse. The client seals the item; the list view is built from items |
| 224 | // the client has already opened. |
| 225 | // |
| 226 | // Ciphertext lengths are quantised into 256-byte buckets before sealing, so |
| 227 | // the bucket cannot be read for item-length fingerprints. Store.PutItem |
| 228 | // enforces it rather than trusting the caller. |
| 229 | type Item struct { |
| 230 | ID []byte `gorm:"primaryKey;type:blob"` |
| 231 | VaultID []byte `gorm:"index;type:blob;not null"` |
| 232 | |
| 233 | // Version is bound into the AAD along with the vault and item ids, so a |
| 234 | // ciphertext cannot be transplanted between slots or rolled back. |
| 235 | Version uint64 `gorm:"not null"` |
| 236 | |
| 237 | Nonce []byte `gorm:"type:blob;not null"` |
| 238 | Ciphertext []byte `gorm:"type:blob;not null"` |
| 239 | |
| 240 | CreatedAt time.Time |
| 241 | UpdatedAt time.Time |
| 242 | } |
| 243 | |
| 244 | // LoginChallenge is live state, not history: the bytes a client is proving |
| 245 | // possession of, between the two requests of one sign-in ceremony. |
| 246 | // |
| 247 | // It holds nothing derived from content — a random nonce, an opaque account |
| 248 | // id, and a branch label of ours. Rows are deleted the moment they are spent |
| 249 | // and swept when they expire, deliberately: a challenge table that accumulated |
| 250 | // would become a log of when each account signed in, which is transport-shape |
| 251 | // residue we do not have to keep and therefore should not. |
| 252 | type LoginChallenge struct { |
| 253 | ID []byte `gorm:"primaryKey;type:blob"` |
| 254 | AccountID []byte `gorm:"index;type:blob;not null"` |
| 255 | |
| 256 | // Branch is "pw" or "rc". Bound into the signed message, so a challenge |
| 257 | // issued for the recovery credential cannot be spent on the passphrase one. |
| 258 | Branch string `gorm:"not null"` |
| 259 | |
| 260 | Challenge []byte `gorm:"index;type:blob;not null"` |
| 261 | ExpiresAt time.Time `gorm:"index;not null"` |
| 262 | CreatedAt time.Time |
| 263 | } |
| 264 | |
| 265 | // SignupSession is an address that has proved itself and an account that does |
| 266 | // not exist yet — the gap between "we emailed you a link" and "you chose a |
| 267 | // passphrase". |
| 268 | // |
| 269 | // It holds the blind index, never the address: the email is used to send one |
| 270 | // message and is not kept. TokenHash is SHA-256 of the emailed token, so a |
| 271 | // database-plus-bucket leak yields nothing anyone can click — the token itself |
| 272 | // exists only in that message and in the link the person follows. |
| 273 | // |
| 274 | // Rows are deleted the moment they are spent and swept when they expire. A |
| 275 | // table of these that accumulated would become a log of who tried to sign up |
| 276 | // and when, which is transport-shape residue we do not have to keep. |
| 277 | type SignupSession struct { |
| 278 | ID []byte `gorm:"primaryKey;type:blob"` |
| 279 | EmailIndex []byte `gorm:"index;type:blob;not null"` |
| 280 | TokenHash []byte `gorm:"index;type:blob;not null"` |
| 281 | |
| 282 | // Verified is false until the link is followed. |
| 283 | Verified bool `gorm:"not null"` |
| 284 | ExpiresAt time.Time `gorm:"index;not null"` |
| 285 | CreatedAt time.Time |
| 286 | } |
| 287 | |
| 288 | // DeviceLink is one live handoff: a fresh device waiting for an enrolled one |
| 289 | // to seal it a copy of the Account Key. PROTOCOL.md's rung 1, following |
| 290 | // slopbox's `link` shape rather than inventing another. |
| 291 | // |
| 292 | // The row holds no secret. CodeHash is SHA-256 of the normalised code, so a |
| 293 | // database-plus-bucket leak yields nothing anyone can spend — the code itself |
| 294 | // exists only on the two screens the person is looking at. Sealed is an ECIES |
| 295 | // envelope the server has no key for, and the AAD inside it names |
| 296 | // DevicePublic, so the server cannot relay one envelope to a second device. |
| 297 | // |
| 298 | // AccountID is here, where slopbox's equivalent deliberately has no user id, |
| 299 | // because in CONTRA both ends are already signed in: a fresh device signs in |
| 300 | // with the passphrase alone and only then discovers it has no Account Key. |
| 301 | // Scoping every route to the session's account means an unrelated account |
| 302 | // cannot see that a link exists, let alone spend one — which is what lets the |
| 303 | // code be short enough to read aloud. |
| 304 | // |
| 305 | // Rows are deleted the moment they are claimed and swept when they expire. A |
| 306 | // table of these that accumulated would be a log of when each account |
| 307 | // enrolled a device, and there is no reason to keep one. |
| 308 | type DeviceLink struct { |
| 309 | ID []byte `gorm:"primaryKey;type:blob"` |
| 310 | CodeHash []byte `gorm:"uniqueIndex;type:blob;not null"` |
| 311 | |
| 312 | // AccountID scopes the link: both ends must be signed in as this account. |
| 313 | AccountID []byte `gorm:"index;type:blob;not null"` |
| 314 | |
| 315 | // DevicePublic is the fresh device's ephemeral P-256 point, SEC1 |
| 316 | // uncompressed. The matching scalar never leaves that browser tab. |
| 317 | DevicePublic []byte `gorm:"type:blob;not null"` |
| 318 | |
| 319 | // There is deliberately no device label, which is where this departs from |
| 320 | // slopbox. A label would be a plaintext column in a file that replicates |
| 321 | // whole — and worse, it is attacker-chosen text shown to the victim at |
| 322 | // the exact moment they are deciding whether to hand over a key. "CONTRA |
| 323 | // official device" is a free phishing line. The code the person carried |
| 324 | // from one screen to the other is the confirmation; a label adds nothing |
| 325 | // to it and takes something away. |
| 326 | |
| 327 | // Sealed is the ECIES envelope, empty until an enrolled device approves. |
| 328 | Sealed []byte `gorm:"type:blob"` |
| 329 | |
| 330 | ExpiresAt time.Time `gorm:"index;not null"` |
| 331 | } |
| 332 | |