carlos / contra Public

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

Plain git — no account needed to clone.

Download

Download this file

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.
24package store
25
26import (
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.
40const FormatVersion = 1
41
42var (
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.
58type Store struct{ db *gorm.DB }
59
60func 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.
68func 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.
78type Sealed struct {
79 Nonce []byte
80 Ciphertext []byte
81}
82
83func (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.
94func (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.
109func (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.
138func (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.
165func (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.
194func (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.
212func (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.
241func (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.
253func (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.
261func (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.
270func (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.
278func (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.
293func (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.
305func (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.
333func (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.
382func (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.
421func (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