| 1 | // Package store is every write path to CONTRA's sealed tables, and the only |
| 2 | // one. Nothing else in the app writes a row. |
| 3 | // |
| 4 | // # Why the API looks like this |
| 5 | // |
| 6 | // The server is host-blind: it holds ciphertext, public keys and opaque |
| 7 | // random ids, and nothing else. So none of these functions takes a title, a |
| 8 | // URL, a username, a vault name or an email address, and there is no |
| 9 | // argument you could pass one to. Sealing happens in the client, where the |
| 10 | // keys are; by the time bytes reach here they are already opaque, and the |
| 11 | // type system is the first thing saying so. |
| 12 | // |
| 13 | // That is deliberate defence. The failure this package is shaped against is |
| 14 | // not a cryptographer's mistake — it is somebody adding a `title` column |
| 15 | // next spring so the list view can be rendered server-side, because the API |
| 16 | // made it easy. Here it is not easy: there is nowhere to put it. |
| 17 | // |
| 18 | // # The rule |
| 19 | // |
| 20 | // A new storage surface lands with its canary or it does not land. Every |
| 21 | // function below is driven by internal/contratest/greps_test.go, which |
| 22 | // pushes canary strings through it and searches the raw database bytes. |
| 23 | // Adding a write path means adding it there, on the same commit. |
| 24 | package store |
| 25 | |
| 26 | import ( |
| 27 | "context" |
| 28 | "crypto/rand" |
| 29 | "errors" |
| 30 | "fmt" |
| 31 | "time" |
| 32 | |
| 33 | "gorm.io/gorm" |
| 34 | |
| 35 | "github.com/carlosframework/contra/internal/contra/crypto" |
| 36 | "github.com/carlosframework/contra/internal/contra/model" |
| 37 | ) |
| 38 | |
| 39 | // FormatVersion is the wrap header version this build writes. |
| 40 | const FormatVersion = 1 |
| 41 | |
| 42 | var ( |
| 43 | ErrNotAligned = errors.New("store: item ciphertext is not quantised into 256-byte buckets") |
| 44 | ErrShortField = errors.New("store: a required sealed field is empty or the wrong length") |
| 45 | |
| 46 | // ErrStale is a lost compare-and-swap: somebody wrote this item between |
| 47 | // the caller reading it and the caller writing it. The API answers 409 |
| 48 | // with the current version so the client can merge rather than guess. |
| 49 | ErrStale = errors.New("store: this item changed since you read it") |
| 50 | |
| 51 | // ErrBadVersion is a client numbering its own write wrongly — a gap or a |
| 52 | // repeat rather than expected+1. |
| 53 | ErrBadVersion = errors.New("store: an item version must be exactly one more than the one it replaces") |
| 54 | ) |
| 55 | |
| 56 | // Store writes the sealed tables. It holds a *gorm.DB and no keys, because |
| 57 | // there are no keys on this side to hold. |
| 58 | type Store struct{ db *gorm.DB } |
| 59 | |
| 60 | func New(db *gorm.DB) *Store { return &Store{db: db} } |
| 61 | |
| 62 | // NewID is the only way a row gets an identifier here: 16 bytes of CSPRNG. |
| 63 | // |
| 64 | // Never a content hash, and never derived from anything the user typed. A |
| 65 | // content-derived identifier at rest is an A1 violation and an equality |
| 66 | // oracle — it lets the operator prove two accounts hold the same item |
| 67 | // without opening either. |
| 68 | func NewID() ([]byte, error) { |
| 69 | id := make([]byte, 16) |
| 70 | if _, err := rand.Read(id); err != nil { |
| 71 | return nil, fmt.Errorf("store: generate id: %w", err) |
| 72 | } |
| 73 | return id, nil |
| 74 | } |
| 75 | |
| 76 | // Sealed is one AEAD blob as it is stored: a 96-bit nonce and the ciphertext |
| 77 | // with its 128-bit tag appended. |
| 78 | type Sealed struct { |
| 79 | Nonce []byte |
| 80 | Ciphertext []byte |
| 81 | } |
| 82 | |
| 83 | func (s Sealed) valid() bool { return len(s.Nonce) == 12 && len(s.Ciphertext) > 16 } |
| 84 | |
| 85 | // CreateAccount records an identity. emailIndex is the blind index — |
| 86 | // HMAC-SHA-256 under the server-held pepper — and this function neither takes |
| 87 | // nor could compute an address. |
| 88 | // |
| 89 | // The id comes from the client, like every other id in this protocol. That is |
| 90 | // not laxness: the client binds the account id into the AAD of the wraps it |
| 91 | // seals before the server has seen any of them, so a server-minted id would |
| 92 | // produce wraps whose binding never matches and which therefore never open. A |
| 93 | // collision is refused by the primary key. |
| 94 | func (s *Store) CreateAccount(ctx context.Context, id, emailIndex, kdfSalt []byte) (*model.Account, error) { |
| 95 | if len(id) != 16 || len(emailIndex) != 32 || len(kdfSalt) != 16 { |
| 96 | return nil, ErrShortField |
| 97 | } |
| 98 | a := &model.Account{ |
| 99 | ID: id, |
| 100 | EmailIndex: emailIndex, |
| 101 | KDFSalt: kdfSalt, |
| 102 | CreatedAt: time.Now().UTC(), |
| 103 | } |
| 104 | return a, s.db.WithContext(ctx).Create(a).Error |
| 105 | } |
| 106 | |
| 107 | // PutLoginKey stores a public key to verify signed challenges against. There |
| 108 | // is no sibling that stores a secret, because there is no secret to store. |
| 109 | func (s *Store) PutLoginKey(ctx context.Context, accountID []byte, branch string, publicKey []byte) error { |
| 110 | if len(publicKey) != 65 { |
| 111 | return ErrShortField |
| 112 | } |
| 113 | id, err := NewID() |
| 114 | if err != nil { |
| 115 | return err |
| 116 | } |
| 117 | return s.db.WithContext(ctx).Create(&model.LoginKey{ |
| 118 | ID: id, |
| 119 | AccountID: accountID, |
| 120 | Branch: branch, |
| 121 | PublicKey: publicKey, |
| 122 | CreatedAt: time.Now().UTC(), |
| 123 | }).Error |
| 124 | } |
| 125 | |
| 126 | // PutWrap stores one peer wrap of the root key. |
| 127 | // |
| 128 | // wrapID is the client's, for the same reason the account id is: it is bound |
| 129 | // into the AAD before the server sees the ciphertext, so minting a different |
| 130 | // one here would store a row that can never be opened. This is exactly the |
| 131 | // bug that a Go test building the request struct by hand does not catch and |
| 132 | // the real client does. |
| 133 | // |
| 134 | // kdfParams is canonical JSON, authenticated as AAD by the client, and kept |
| 135 | // so parameters can be raised later with a transparent re-wrap — and so a |
| 136 | // client can see and refuse a set below its floor. credSalt is the passkey |
| 137 | // credential's non-secret 32 bytes, nil on the other two branches. |
| 138 | func (s *Store) PutWrap(ctx context.Context, accountID, wrapID []byte, kind, kdfParams string, |
| 139 | version uint64, kcv, credSalt []byte, sealed Sealed) error { |
| 140 | if !sealed.valid() || len(kcv) != 16 || len(wrapID) != 16 { |
| 141 | return ErrShortField |
| 142 | } |
| 143 | return s.db.WithContext(ctx).Create(&model.Wrap{ |
| 144 | ID: wrapID, |
| 145 | AccountID: accountID, |
| 146 | Kind: kind, |
| 147 | FormatVersion: FormatVersion, |
| 148 | Version: version, |
| 149 | KDFParams: kdfParams, |
| 150 | CredSalt: credSalt, |
| 151 | KCV: kcv, |
| 152 | Nonce: sealed.Nonce, |
| 153 | Ciphertext: sealed.Ciphertext, |
| 154 | CreatedAt: time.Now().UTC(), |
| 155 | }).Error |
| 156 | } |
| 157 | |
| 158 | // PutPasskeyWrap stores a passkey's wrap of RK: the credential it belongs to, |
| 159 | // the non-secret salt the PRF was evaluated under, and the sealed root key. |
| 160 | // |
| 161 | // No attestation is checked and none is wanted. The wrap either opens or it |
| 162 | // does not, and its KCV says which — a signature from the authenticator would |
| 163 | // prove the credential exists, which is not the question. What matters is |
| 164 | // whether the key it yields is the key that opens this account. |
| 165 | func (s *Store) PutPasskeyWrap(ctx context.Context, accountID, wrapID, credentialID, |
| 166 | credSalt, kcv []byte, kdfParams string, sealed Sealed) error { |
| 167 | if len(credentialID) == 0 || len(credSalt) != 32 || len(kcv) != 16 || |
| 168 | len(wrapID) != 16 || !sealed.valid() { |
| 169 | return ErrShortField |
| 170 | } |
| 171 | return s.db.WithContext(ctx).Create(&model.Wrap{ |
| 172 | ID: wrapID, |
| 173 | AccountID: accountID, |
| 174 | Kind: crypto.ObjectWrapPRF, |
| 175 | FormatVersion: FormatVersion, |
| 176 | Version: 1, |
| 177 | KDFParams: kdfParams, |
| 178 | CredSalt: credSalt, |
| 179 | CredentialID: credentialID, |
| 180 | KCV: kcv, |
| 181 | Nonce: sealed.Nonce, |
| 182 | Ciphertext: sealed.Ciphertext, |
| 183 | CreatedAt: time.Now().UTC(), |
| 184 | }).Error |
| 185 | } |
| 186 | |
| 187 | // CreateVault records a vault. Its name is not a parameter: a vault name is |
| 188 | // metadata, metadata is a secret, and it seals into the keyset. The id is the |
| 189 | // client's, for the reason PutWrap's is — it is bound into the keyset's AAD |
| 190 | // before the server sees the ciphertext. |
| 191 | // |
| 192 | // dropBoxPublicKey is vP_j: what a daemon seals a captured item to with no |
| 193 | // key in memory. |
| 194 | func (s *Store) CreateVault(ctx context.Context, id, accountID, dropBoxPublicKey []byte, |
| 195 | keyset Sealed) (*model.Vault, error) { |
| 196 | if len(id) != 16 || len(dropBoxPublicKey) != 65 || !keyset.valid() { |
| 197 | return nil, ErrShortField |
| 198 | } |
| 199 | v := &model.Vault{ |
| 200 | ID: id, |
| 201 | AccountID: accountID, |
| 202 | DropBoxPublicKey: dropBoxPublicKey, |
| 203 | KeysetNonce: keyset.Nonce, |
| 204 | KeysetCiphertext: keyset.Ciphertext, |
| 205 | CreatedAt: time.Now().UTC(), |
| 206 | } |
| 207 | return v, s.db.WithContext(ctx).Create(v).Error |
| 208 | } |
| 209 | |
| 210 | // SetAccountKeyset stores the account keyset — d_acct and K_meta sealed under |
| 211 | // RK — and the public half that other members seal shared vaults to. |
| 212 | func (s *Store) SetAccountKeyset(ctx context.Context, accountID, publicKey []byte, |
| 213 | keyset Sealed) error { |
| 214 | if len(publicKey) != 65 || !keyset.valid() { |
| 215 | return ErrShortField |
| 216 | } |
| 217 | return s.db.WithContext(ctx).Model(&model.Account{}). |
| 218 | Where("id = ?", accountID). |
| 219 | Select("PublicKey", "KeysetNonce", "KeysetCiphertext"). |
| 220 | Updates(map[string]any{ |
| 221 | "PublicKey": publicKey, |
| 222 | "KeysetNonce": keyset.Nonce, |
| 223 | "KeysetCiphertext": keyset.Ciphertext, |
| 224 | }).Error |
| 225 | } |
| 226 | |
| 227 | // ownedVault is the OWNER-only seam: anything that changes who can read a |
| 228 | // vault — adding a member, removing one, rotating, deleting it. |
| 229 | // |
| 230 | // Item reads and writes go through reachableVault instead (sharing.go), which |
| 231 | // also admits members. The two are not interchangeable and the split is a |
| 232 | // security boundary: ownedVault on items would lock members out of the thing |
| 233 | // they were shared, and reachableVault on membership would let any member |
| 234 | // evict any other. |
| 235 | // |
| 236 | // rastrillo's scope.Owned filters on an int64 user_id, and CONTRA's owner is |
| 237 | // a 16-byte account id carried on the session Subject — so this is written |
| 238 | // out rather than borrowed. The rule it enforces is the same one: never a |
| 239 | // query on an owned row without the owner filter, on writes as much as |
| 240 | // reads, so a later refactor cannot quietly turn an update into an IDOR. |
| 241 | func (s *Store) ownedVault(ctx context.Context, accountID, vaultID []byte) (*model.Vault, error) { |
| 242 | var v model.Vault |
| 243 | err := s.db.WithContext(ctx). |
| 244 | Where("id = ? AND account_id = ?", vaultID, accountID).First(&v).Error |
| 245 | if err != nil { |
| 246 | return nil, err |
| 247 | } |
| 248 | return &v, nil |
| 249 | } |
| 250 | |
| 251 | // Vaults lists what this account owns. Nothing here is readable without the |
| 252 | // keys: the rows carry sealed keysets and opaque ids. |
| 253 | func (s *Store) Vaults(ctx context.Context, accountID []byte) ([]model.Vault, error) { |
| 254 | var out []model.Vault |
| 255 | err := s.db.WithContext(ctx). |
| 256 | Where("account_id = ?", accountID).Order("created_at").Find(&out).Error |
| 257 | return out, err |
| 258 | } |
| 259 | |
| 260 | // Account returns one account row by id. |
| 261 | func (s *Store) Account(ctx context.Context, accountID []byte) (*model.Account, error) { |
| 262 | var a model.Account |
| 263 | if err := s.db.WithContext(ctx).Where("id = ?", accountID).First(&a).Error; err != nil { |
| 264 | return nil, err |
| 265 | } |
| 266 | return &a, nil |
| 267 | } |
| 268 | |
| 269 | // Wraps returns an account's peer wraps of RK. |
| 270 | func (s *Store) Wraps(ctx context.Context, accountID []byte) ([]model.Wrap, error) { |
| 271 | var out []model.Wrap |
| 272 | err := s.db.WithContext(ctx).Where("account_id = ?", accountID).Find(&out).Error |
| 273 | return out, err |
| 274 | } |
| 275 | |
| 276 | // Items lists a vault's sealed items — after proving the vault is this |
| 277 | // account's. A vault that is not yours is a vault that does not exist. |
| 278 | func (s *Store) Items(ctx context.Context, accountID, vaultID []byte) ([]model.Item, error) { |
| 279 | if _, err := s.reachableVault(ctx, accountID, vaultID); err != nil { |
| 280 | return nil, err |
| 281 | } |
| 282 | var out []model.Item |
| 283 | err := s.db.WithContext(ctx). |
| 284 | Where("vault_id = ?", vaultID).Order("created_at").Find(&out).Error |
| 285 | return out, err |
| 286 | } |
| 287 | |
| 288 | // DeleteItem removes one item, scoped through its vault. |
| 289 | // |
| 290 | // The scope is in the SQL rather than in a check beside it: the statement |
| 291 | // carries the owner filter itself, so a foreign id deletes nothing rather |
| 292 | // than relying on a lookup somebody might later drop. |
| 293 | func (s *Store) DeleteItem(ctx context.Context, accountID, vaultID, itemID []byte) error { |
| 294 | if _, err := s.reachableVault(ctx, accountID, vaultID); err != nil { |
| 295 | return err |
| 296 | } |
| 297 | return s.db.WithContext(ctx). |
| 298 | Where("id = ? AND vault_id = ?", itemID, vaultID). |
| 299 | Delete(&model.Item{}).Error |
| 300 | } |
| 301 | |
| 302 | // PutVaultMember stores the vault keyset sealed to one member's account |
| 303 | // public key. These rows are the sharing graph — at-rest metadata A1 |
| 304 | // tolerates, and published in the model rather than hidden. |
| 305 | func (s *Store) PutVaultMember(ctx context.Context, vaultID, accountID, |
| 306 | ephemeralPublicKey []byte, sealed Sealed) error { |
| 307 | if !sealed.valid() || len(ephemeralPublicKey) != 65 { |
| 308 | return ErrShortField |
| 309 | } |
| 310 | id, err := NewID() |
| 311 | if err != nil { |
| 312 | return err |
| 313 | } |
| 314 | return s.db.WithContext(ctx).Create(&model.VaultMember{ |
| 315 | ID: id, |
| 316 | VaultID: vaultID, |
| 317 | AccountID: accountID, |
| 318 | EphemeralPublicKey: ephemeralPublicKey, |
| 319 | Nonce: sealed.Nonce, |
| 320 | Ciphertext: sealed.Ciphertext, |
| 321 | CreatedAt: time.Now().UTC(), |
| 322 | }).Error |
| 323 | } |
| 324 | |
| 325 | // PutItem stores one item: title, URL, username, notes and password all |
| 326 | // inside the single blob, because splitting them into columns would publish |
| 327 | // which fields an item has and how long each one is. |
| 328 | // |
| 329 | // It refuses a ciphertext that is not quantised into 256-byte buckets. The |
| 330 | // padding is the client's job, but "the client pads" is a hope; refusing |
| 331 | // here is a control. The check is scheme-independent — it says the plaintext |
| 332 | // was quantised, not how — so it holds for the JS and Swift clients too. |
| 333 | func (s *Store) PutItem(ctx context.Context, accountID, vaultID, itemID []byte, |
| 334 | version uint64, sealed Sealed) (*model.Item, error) { |
| 335 | if !sealed.valid() || len(itemID) != 16 { |
| 336 | return nil, ErrShortField |
| 337 | } |
| 338 | if !crypto.IsBucketAligned(sealed.Ciphertext) { |
| 339 | return nil, ErrNotAligned |
| 340 | } |
| 341 | if _, err := s.reachableVault(ctx, accountID, vaultID); err != nil { |
| 342 | return nil, err |
| 343 | } |
| 344 | now := time.Now().UTC() |
| 345 | it := &model.Item{ |
| 346 | ID: itemID, |
| 347 | VaultID: vaultID, |
| 348 | Version: version, |
| 349 | Nonce: sealed.Nonce, |
| 350 | Ciphertext: sealed.Ciphertext, |
| 351 | CreatedAt: now, |
| 352 | UpdatedAt: now, |
| 353 | } |
| 354 | return it, s.db.WithContext(ctx).Create(it).Error |
| 355 | } |
| 356 | |
| 357 | // UpdateItem replaces an item's sealed body and bumps its version. |
| 358 | // |
| 359 | // # Compare-and-swap, and why it is not optional |
| 360 | // |
| 361 | // The WHERE carries `version = ?` — the version the client believes it is |
| 362 | // replacing — and zero rows affected is ErrStale, which the API turns into a |
| 363 | // 409 carrying the current version. |
| 364 | // |
| 365 | // Without that precondition this wrote whatever version the client sent over |
| 366 | // whatever was there. With one device that is invisible. With two, or with |
| 367 | // two members of a shared vault, it is silent data loss: both open version 3, |
| 368 | // both edit, both write version 4, and the second simply erases the first |
| 369 | // with nothing anywhere recording that an edit existed. It also let a client |
| 370 | // reuse a version number, which undercuts what binding the version into the |
| 371 | // AAD is for. |
| 372 | // |
| 373 | // The version in the AAD stops RELABELLING — a server cannot serve ciphertext |
| 374 | // v3 as v7. It has never stopped rollback, and does not claim to: serving the |
| 375 | // whole older (version, nonce, ciphertext) triple consistently yields an item |
| 376 | // that opens, because it is a genuinely older authentic item. That is in |
| 377 | // PROTOCOL.md's residue section, not papered over here. |
| 378 | // |
| 379 | // This is Eleven's putBlob contract applied per item rather than per user, |
| 380 | // which is the shape that lets a conflict scope to one item instead of a |
| 381 | // whole vault. |
| 382 | func (s *Store) UpdateItem(ctx context.Context, accountID, vaultID, itemID []byte, |
| 383 | expected, version uint64, sealed Sealed) error { |
| 384 | if !sealed.valid() { |
| 385 | return ErrShortField |
| 386 | } |
| 387 | if !crypto.IsBucketAligned(sealed.Ciphertext) { |
| 388 | return ErrNotAligned |
| 389 | } |
| 390 | if version != expected+1 { |
| 391 | // The client decides the bytes; it does not get to decide the |
| 392 | // numbering. A gap or a repeat here means a confused writer. |
| 393 | return ErrBadVersion |
| 394 | } |
| 395 | if _, err := s.reachableVault(ctx, accountID, vaultID); err != nil { |
| 396 | return err |
| 397 | } |
| 398 | res := s.db.WithContext(ctx).Model(&model.Item{}). |
| 399 | Where("id = ? AND vault_id = ? AND version = ?", itemID, vaultID, expected). |
| 400 | Select("Version", "Nonce", "Ciphertext", "UpdatedAt"). |
| 401 | Updates(map[string]any{ |
| 402 | "Version": version, |
| 403 | "Nonce": sealed.Nonce, |
| 404 | "Ciphertext": sealed.Ciphertext, |
| 405 | "UpdatedAt": time.Now().UTC(), |
| 406 | }) |
| 407 | if res.Error != nil { |
| 408 | return res.Error |
| 409 | } |
| 410 | if res.RowsAffected == 0 { |
| 411 | // Either the item is gone or somebody else wrote first. Both are |
| 412 | // "refetch and try again", and telling them apart across a vault the |
| 413 | // caller can already list is not worth a second query. |
| 414 | return ErrStale |
| 415 | } |
| 416 | return nil |
| 417 | } |
| 418 | |
| 419 | // ItemVersion reports the version currently stored, so a 409 can tell the |
| 420 | // client what it has to merge against rather than only that it lost. |
| 421 | func (s *Store) ItemVersion(ctx context.Context, accountID, vaultID, itemID []byte) (uint64, error) { |
| 422 | if _, err := s.reachableVault(ctx, accountID, vaultID); err != nil { |
| 423 | return 0, err |
| 424 | } |
| 425 | var it model.Item |
| 426 | err := s.db.WithContext(ctx).Select("version"). |
| 427 | Where("id = ? AND vault_id = ?", itemID, vaultID).Take(&it).Error |
| 428 | if err != nil { |
| 429 | return 0, err |
| 430 | } |
| 431 | return it.Version, nil |
| 432 | } |
| 433 | |