| 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 | |
| 27 | package store |
| 28 | |
| 29 | import ( |
| 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 | |
| 40 | var ( |
| 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. |
| 57 | func (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. |
| 72 | func (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. |
| 84 | func (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. |
| 100 | func (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. |
| 113 | type MemberWrap struct { |
| 114 | AccountID []byte |
| 115 | EphemeralPublicKey []byte |
| 116 | Sealed Sealed |
| 117 | } |
| 118 | |
| 119 | func (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. |
| 135 | func (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. |
| 166 | type 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. |
| 175 | type 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. |
| 214 | func (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. |
| 304 | func (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 | |