carlos / contra Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Shared vaults: membership, and the rotation that revocation really is.
2//
3// # Two seams, deliberately
4//
5// `ownedVault` and `reachableVault` are not interchangeable and the split is
6// the security boundary of this file:
7//
8// - reachableVault — owner OR member. Reading and writing ITEMS. A member
9// of a shared vault is there to use it.
10// - ownedVault — owner only. Anything that changes WHO CAN READ: adding a
11// member, removing one, rotating, deleting the vault.
12//
13// Getting that backwards in either direction is a real failure. Using
14// ownedVault for items locks members out of the thing they were shared;
15// using reachableVault for membership lets any member add or evict any other.
16// So every function below names which one it uses and why.
17//
18// # Why membership is not a content-derived identifier
19//
20// AGENTS.md decision 1: membership of a thread on a shared multi-tenant
21// instance would be. Membership of a vault on the OWNER'S OWN instance is
22// the access-control list the instance must consult to decide who it serves
23// sealed bytes to — transport shape, not content. That distinction is what
24// keeps this A1 rather than A2, and it is load-bearing: if a change starts
25// eroding it, stop rather than designing around it.
26
27package store
28
29import (
30 "context"
31 "errors"
32 "time"
33
34 "gorm.io/gorm"
35
36 "github.com/carlosframework/contra/internal/contra/crypto"
37 "github.com/carlosframework/contra/internal/contra/model"
38)
39
40var (
41 // ErrRotated is a lost compare-and-swap on the vault epoch: the vault
42 // rotated between the caller reading it and writing. The caller has to
43 // refetch the keyset — the key it holds no longer opens anything.
44 ErrRotated = errors.New("store: this vault rotated since you read it")
45
46 // ErrNotOwner is an attempt to change who can read a vault by somebody
47 // who is only a member of it.
48 ErrNotOwner = errors.New("store: only a vault's owner can change its membership")
49)
50
51// reachableVault is the seam for ITEM reads and writes: the vault is yours,
52// or it is shared with you.
53//
54// The filter is in the SQL rather than in a check beside it, for the same
55// reason ownedVault's is — a later refactor cannot turn a read into an IDOR
56// by deleting a line it did not understand.
57func (s *Store) reachableVault(ctx context.Context, accountID, vaultID []byte) (*model.Vault, error) {
58 var v model.Vault
59 err := s.db.WithContext(ctx).
60 Where(`id = ? AND (account_id = ? OR id IN (
61 SELECT vault_id FROM vault_members WHERE account_id = ?))`,
62 vaultID, accountID, accountID).
63 First(&v).Error
64 if err != nil {
65 return nil, err
66 }
67 return &v, nil
68}
69
70// ReachableVaults is every vault this account can open: the ones it owns and
71// the ones shared with it. Bootstrap's list.
72func (s *Store) ReachableVaults(ctx context.Context, accountID []byte) ([]model.Vault, error) {
73 var out []model.Vault
74 err := s.db.WithContext(ctx).
75 Where(`account_id = ? OR id IN (
76 SELECT vault_id FROM vault_members WHERE account_id = ?)`,
77 accountID, accountID).
78 Order("created_at").Find(&out).Error
79 return out, err
80}
81
82// MemberWrapOf returns the caller's own wrap of a vault keyset, or nil when
83// they are the owner and read the keyset off the vault row instead.
84func (s *Store) MemberWrapOf(ctx context.Context, vaultID, accountID []byte) (*model.VaultMember, error) {
85 var m model.VaultMember
86 err := s.db.WithContext(ctx).
87 Where("vault_id = ? AND account_id = ?", vaultID, accountID).Take(&m).Error
88 if errors.Is(err, gorm.ErrRecordNotFound) {
89 return nil, nil
90 }
91 if err != nil {
92 return nil, err
93 }
94 return &m, nil
95}
96
97// Members lists a vault's members. Owner-only: who a vault is shared with is
98// the owner's business, and a member learning the whole membership list is a
99// disclosure nobody asked for.
100func (s *Store) Members(ctx context.Context, accountID, vaultID []byte) ([]model.VaultMember, error) {
101 if _, err := s.ownedVault(ctx, accountID, vaultID); err != nil {
102 return nil, err
103 }
104 var out []model.VaultMember
105 err := s.db.WithContext(ctx).
106 Where("vault_id = ?", vaultID).Order("id").Find(&out).Error
107 return out, err
108}
109
110// MemberWrap is one member's copy of a vault keyset, sealed to their account
111// public key by ephemeral-static ECDH. The server never holds the key that
112// opens it and there is no argument here that could carry one.
113type MemberWrap struct {
114 AccountID []byte
115 EphemeralPublicKey []byte
116 Sealed Sealed
117}
118
119func (m MemberWrap) valid() bool {
120 return len(m.AccountID) == 16 &&
121 len(m.EphemeralPublicKey) == crypto.PublicKeyLen &&
122 m.EphemeralPublicKey[0] == 0x04 &&
123 m.Sealed.valid()
124}
125
126// AddMember shares a vault. Owner-only.
127//
128// Sealing to a public key needs no unlock on the RECIPIENT's side and no key
129// on the server's — which is the property the agent surface depends on later:
130// a daemon can be handed a vault without anybody typing a passphrase.
131//
132// It does not rotate. Adding a member gives them what the vault already
133// holds, which is the intent; it is REMOVING one that has to rotate, because
134// that is the case where somebody keeps what they already had.
135func (s *Store) AddMember(ctx context.Context, ownerID, vaultID []byte, w MemberWrap) error {
136 if !w.valid() {
137 return ErrShortField
138 }
139 if _, err := s.ownedVault(ctx, ownerID, vaultID); err != nil {
140 return err
141 }
142 id, err := NewID()
143 if err != nil {
144 return err
145 }
146 // One row per (vault, account): re-sharing replaces rather than
147 // accumulates, or a removal would have to find every copy.
148 return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
149 if err := tx.Where("vault_id = ? AND account_id = ?", vaultID, w.AccountID).
150 Delete(&model.VaultMember{}).Error; err != nil {
151 return err
152 }
153 return tx.Create(&model.VaultMember{
154 ID: id,
155 VaultID: vaultID,
156 AccountID: w.AccountID,
157 EphemeralPublicKey: w.EphemeralPublicKey,
158 Nonce: w.Sealed.Nonce,
159 Ciphertext: w.Sealed.Ciphertext,
160 CreatedAt: time.Now().UTC(),
161 }).Error
162 })
163}
164
165// RotatedItem is one item re-sealed under the new vault key.
166type RotatedItem struct {
167 ID []byte
168 Version uint64
169 Sealed Sealed
170}
171
172// Rotation is everything one vault rotation writes. It is a single struct
173// because it is a single transaction: a rotation that applied half of this
174// would leave a vault whose items no longer match the key its members hold.
175type Rotation struct {
176 // Keyset is the owner's copy of the new keyset, re-sealed under RK.
177 Keyset Sealed
178
179 // Items is EVERY item in the vault, re-sealed under the new K_vault with
180 // its version bumped. Eager, not lazy: lazy rotation needs a per-row
181 // key-generation column, and that column tells the operator which items
182 // changed since a member was removed.
183 Items []RotatedItem
184
185 // Members is the remaining members' wraps of the new keyset. Anybody not
186 // in this list stops being a member.
187 Members []MemberWrap
188}
189
190// RotateVault is revocation. Owner-only, and one transaction.
191//
192// In a seal-to-public-key model revocation IS rotation: a removed member may
193// have cached K_vault, so taking their row away without changing the key
194// changes nothing about what they can decrypt. PROTOCOL.md's three-part answer,
195// none of which may be softened in the copy: access dies immediately, the key
196// rotates forward, and **nothing already downloaded is un-downloaded**.
197//
198// The compare-and-swap on the epoch is what makes concurrent writers safe. A
199// member who began an edit before the rotation landed has an item sealed
200// under the old key; their write is refused with ErrRotated rather than
201// accepted as a row that silently cannot be opened by anybody.
202//
203// # Why the epoch is not in the AAD
204//
205// #23 recommended binding it into every item's AAD. It is not, and the reason
206// is that it would be redundant: rotation mints a NEW K_vault, so an item
207// sealed under the old key already fails to open under the new one — the
208// cryptographic binding is the key itself. What the epoch adds is a
209// SERVER-side precondition, so a stale write is refused with an error a
210// client can act on instead of being stored as a row nobody can read. Putting
211// it in the AAD would change the pinned AAD format and every golden vector
212// for a property already held. Recorded because it is a deliberate divergence
213// from a review, not an oversight.
214func (s *Store) RotateVault(ctx context.Context, ownerID, vaultID []byte,
215 expectedEpoch uint64, rot Rotation) error {
216 if !rot.Keyset.valid() {
217 return ErrShortField
218 }
219 for _, m := range rot.Members {
220 if !m.valid() {
221 return ErrShortField
222 }
223 }
224 for _, it := range rot.Items {
225 if len(it.ID) != 16 || !it.Sealed.valid() {
226 return ErrShortField
227 }
228 if !crypto.IsBucketAligned(it.Sealed.Ciphertext) {
229 return ErrNotAligned
230 }
231 }
232
233 return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
234 // The CAS and the ownership check are the same statement, so there is
235 // no window between proving ownership and acting on it.
236 res := tx.Model(&model.Vault{}).
237 Where("id = ? AND account_id = ? AND epoch = ?", vaultID, ownerID, expectedEpoch).
238 Updates(map[string]any{
239 "epoch": expectedEpoch + 1,
240 "keyset_nonce": rot.Keyset.Nonce,
241 "keyset_ciphertext": rot.Keyset.Ciphertext,
242 })
243 if res.Error != nil {
244 return res.Error
245 }
246 if res.RowsAffected == 0 {
247 // Not yours, gone, or already rotated. All three are refusals and
248 // telling them apart would answer a question about somebody
249 // else's vault.
250 return ErrRotated
251 }
252
253 // Membership is replaced wholesale rather than diffed: the caller
254 // sends who remains, so a removal is the absence of a row and cannot
255 // be half-applied.
256 if err := tx.Where("vault_id = ?", vaultID).
257 Delete(&model.VaultMember{}).Error; err != nil {
258 return err
259 }
260 now := time.Now().UTC()
261 for _, m := range rot.Members {
262 id, err := NewID()
263 if err != nil {
264 return err
265 }
266 if err := tx.Create(&model.VaultMember{
267 ID: id,
268 VaultID: vaultID,
269 AccountID: m.AccountID,
270 EphemeralPublicKey: m.EphemeralPublicKey,
271 Nonce: m.Sealed.Nonce,
272 Ciphertext: m.Sealed.Ciphertext,
273 CreatedAt: now,
274 }).Error; err != nil {
275 return err
276 }
277 }
278
279 for _, it := range rot.Items {
280 r := tx.Model(&model.Item{}).
281 Where("id = ? AND vault_id = ?", it.ID, vaultID).
282 Updates(map[string]any{
283 "version": it.Version,
284 "nonce": it.Sealed.Nonce,
285 "ciphertext": it.Sealed.Ciphertext,
286 "updated_at": now,
287 })
288 if r.Error != nil {
289 return r.Error
290 }
291 if r.RowsAffected == 0 {
292 // An item was created or deleted while the rotation was being
293 // composed. Rolling back is right: a partial rotation leaves
294 // items nobody can open.
295 return ErrRotated
296 }
297 }
298 return nil
299 })
300}
301
302// VaultEpoch reports the current epoch, so a refused write can be told what
303// to re-read rather than only that it lost.
304func (s *Store) VaultEpoch(ctx context.Context, accountID, vaultID []byte) (uint64, error) {
305 v, err := s.reachableVault(ctx, accountID, vaultID)
306 if err != nil {
307 return 0, err
308 }
309 return v.Epoch, nil
310}
311