web/8: membership, the epoch, and the rotation revocation really is
The storage half of shared vaults. **Two seams, and the split is the security boundary.** ownedVault is now owner-only and gates anything that changes WHO CAN READ — add a member, remove one, rotate, list membership. reachableVault (owner OR member) gates item reads and writes. Getting that backwards in either direction compiles perfectly and is a hole: ownedVault on items locks members out of the thing they were shared, reachableVault on membership lets any member evict any other. Both directions are tested. **Rotation is one transaction.** RotateVault re-seals every item under the new key, reissues every remaining member wrap, drops the rest, and bumps the epoch — or does none of it. A half-applied rotation is a vault whose items no longer match the key its members hold, so a rotation naming an item that vanished under it rolls back rather than limping on. Membership is replaced wholesale rather than diffed: the caller sends who remains, so a removal is the absence of a row and cannot be half-applied. **The epoch is NOT in the AAD, which diverges from #23's recommendation.** Binding it there would be redundant: rotation mints a new K_vault, so an item sealed under the old key already fails to open under the new one — the cryptographic binding is the key itself. What the epoch adds is a server-side precondition, so a stale write is refused with something a client can act on rather than stored as a row nobody can read. Putting it in the AAD would change the pinned AAD format and every golden vector to buy a property already held. Written down in sharing.go as a deliberate divergence rather than left looking like an oversight. Bootstrap now returns what an account can OPEN rather than what it owns, and hands a member their own wrap plus the ephemeral public key that opens it. Never the owner's copy, which is sealed under the owner's RK and would be an unopenable blob — a confusing way to say "not for you". Adding a member does not rotate. Adding gives them what the vault already holds, which is the intent; removing has to rotate, because that is the case where somebody keeps what they already had. The greps gained AddMember and RotateVault. Their own row-count guard caught the first attempt: rotating with no members left vault_members empty, so the greps would have been searching a table the write paths never filled.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
8 files changed,
+757
−11
internal/contra/migrations/0007_alter_vaults.sql+2 −0internal/contra/migrations/schema.sql+1 −1internal/contra/model/model.go+19 −0internal/contra/store/sharing.go+310 −0internal/contra/store/store.go+13 −6internal/contra/vault/vault.go+38 −4internal/contratest/greps_test.go+62 −0internal/contratest/sharing_test.go+312 −0
CI passed — run details
CI log
=== vet === go vet ./... go: downloading github.com/carlosframework/rastrillo v0.23.0 go: downloading github.com/go-chi/chi/v5 v5.3.2 go: downloading gorm.io/gorm v1.31.2 go: downloading golang.org/x/crypto v0.55.0 go: downloading github.com/jinzhu/now v1.1.5 go: downloading golang.org/x/text v0.41.0 go: downloading github.com/jinzhu/inflection v1.0.0 go: downloading modernc.org/sqlite v1.55.0 go: downloading gorm.io/plugin/dbresolver v1.6.2 go: downloading modernc.org/libc v1.74.1 go: downloading golang.org/x/sys v0.47.0 go: downloading github.com/google/uuid v1.6.0 go: downloading github.com/dustin/go-humanize v1.0.1 go: downloading modernc.org/mathutil v1.7.1 go: downloading modernc.org/memory v1.11.0 go: downloading github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec === fmt === === test === go test ./... ? github.com/carlosframework/contra/cmd/contra [no test files] ? github.com/carlosframework/contra/internal/contra [no test files] ? github.com/carlosframework/contra/internal/contra/auth [no test files] ok github.com/carlosframework/contra/internal/contra/crypto 0.423s ? github.com/carlosframework/contra/internal/contra/icons [no test files] ? github.com/carlosframework/contra/internal/contra/mail [no test files] ? github.com/carlosframework/contra/internal/contra/model [no test files] ? github.com/carlosframework/contra/internal/contra/store [no test files] ? github.com/carlosframework/contra/internal/contra/vault [no test files] ok github.com/carlosframework/contra/internal/contratest 14.448s === js-test === node --test test/crypto.test.mjs test/ecies.test.mjs test/items.test.mjs TAP version 13 # Subtest: HKDF: every contra/v1 label ok 1 - HKDF: every contra/v1 label --- duration_ms: 9.952037 type: 'test' ... # Subtest: KCV ok 2 - KCV --- duration_ms: 2.23008 type: 'test' ... # Subtest: P-256 from a seed, including the rejection branch ok 3 - P-256 from a seed, including the rejection branch --- duration_ms: 12.547068 type: 'test' ... # Subtest: signed challenge round-trips against the derived public key ok 4 - signed challenge round-trips against the derived public key --- duration_ms: 4.965593 type: 'test' ... # Subtest: Crockford: alphabet, encoding, normalisation, strict decoding ok 5 - Crockford: alphabet, encoding, normalisation, strict decoding --- duration_ms: 0.989406 type: 'test' ... # Subtest: the printed code, and the O/I/L confusions it has to survive ok 6 - the printed code, and the O/I/L confusions it has to survive --- duration_ms: 2.14017 type: 'test' ... # Subtest: repair recovers one substituted character ok 7 - repair recovers one substituted character --- duration_ms: 50.280284 type: 'test' ... # Subtest: AEAD: the AAD binding and every wrap ok 8 - AEAD: the AAD binding and every wrap --- duration_ms: 2.566794 type: 'test' ... # Subtest: AEAD: the item, scoped to its vault ok 9 - AEAD: the item, scoped to its vault --- duration_ms: 1.099655 type: 'test' ... # Subtest: a transplanted ciphertext does not open ok 10 - a transplanted ciphertext does not open --- duration_ms: 1.957226 type: 'test' ... # Subtest: padding quantises into 256-byte buckets and round-trips ok 11 - padding quantises into 256-byte buckets and round-trips --- duration_ms: 0.926417 type: 'test' ... # Subtest: the KDF parameter floor refuses what a malicious server would offer ok 12 - the KDF parameter floor refuses what a malicious server would offer --- duration_ms: 1.078958 type: 'test' ... # Subtest: Argon2id matches the published PHC reference vector ok 13 - Argon2id matches the published PHC reference vector --- duration_ms: 1821.846285 type: 'test' ... # Subtest: Argon2id at the corners the 64 MiB case does not reach ok 14 - Argon2id at the corners the 64 MiB case does not reach --- duration_ms: 215.506565 type: 'test' ... # Subtest: Argon2id matches the golden vector at the parameters DESIGN.md pins ok 15 - Argon2id matches the golden vector at the parameters DESIGN.md pins --- duration_ms: 2603.709659 type: 'test' ... # Subtest: the account chain, end to end ok 16 - the account chain, end to end --- duration_ms: 2612.639292 type: 'test' ... # Subtest: handoff: both keypairs derive from their published seeds ok 17 - handoff: both keypairs derive from their published seeds --- duration_ms: 16.283579 type: 'test' ... # Subtest: handoff: ECDH agrees from both sides ok 18 - handoff: ECDH agrees from both sides --- duration_ms: 8.086889 type: 'test' ... # Subtest: handoff: the derived key, the AAD and the sealed bytes ok 19 - handoff: the derived key, the AAD and the sealed bytes --- duration_ms: 2.387632 type: 'test' ... # Subtest: handoff: an envelope opens for its device and for nobody else ok 20 - handoff: an envelope opens for its device and for nobody else --- duration_ms: 16.611539 type: 'test' ... # Subtest: handoff: the Go and JS halves seal for each other ok 21 - handoff: the Go and JS halves seal for each other --- duration_ms: 7.938535 type: 'test' ... # Subtest: handoff: a truncated envelope is refused before any key work ok 22 - handoff: a truncated envelope is refused before any key work --- duration_ms: 0.545193 type: 'test' ... # Subtest: an item round-trips through create, list and update ok 23 - an item round-trips through create, list and update --- duration_ms: 11.250191 type: 'test' ... # Subtest: nothing readable reaches the network ok 24 - nothing readable reaches the network --- duration_ms: 2.578445 type: 'test' ... # Subtest: item ciphertexts are quantised into 256-byte buckets ok 25 - item ciphertexts are quantised into 256-byte buckets --- duration_ms: 1.661268 type: 'test' ... # Subtest: an item does not open in another slot ok 26 - an item does not open in another slot --- duration_ms: 2.86035 type: 'test' ... # Subtest: every field is present whether or not it was filled in ok 27 - every field is present whether or not it was filled in --- duration_ms: 1.248508 type: 'test' ... # Subtest: the Account Key cache is storage handed in, not reached for ok 28 - the Account Key cache is storage handed in, not reached for --- duration_ms: 0.363108 type: 'test' ... 1..28 # tests 28 # suites 0 # pass 28 # fail 0 # cancelled 0 # skipped 0 # todo 0 # duration_ms 7414.680811 === migration-check === go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check go: downloading github.com/BurntSushi/toml v1.6.0 rastrillo migration check: models and migrations agree
diff --git a/internal/contra/migrations/0007_alter_vaults.sql b/internal/contra/migrations/0007_alter_vaults.sql| new file mode 100644 |
| index 0000000..f872bc3 |
| --- /dev/null |
| +++ b/internal/contra/migrations/0007_alter_vaults.sql |
| @@ -0,0 +1,2 @@ |
| +ALTER TABLE `vaults` ADD `epoch` integer NOT NULL DEFAULT 1; |
| + |
diff --git a/internal/contra/migrations/schema.sql b/internal/contra/migrations/schema.sql| index be7baf1..6cd43d5 100644 |
| --- a/internal/contra/migrations/schema.sql |
| +++ b/internal/contra/migrations/schema.sql |
| @@ -15,7 +15,7 @@ CREATE TABLE `signup_sessions` (`id` blob,`email_index` blob NOT NULL,`token_has |
| CREATE TABLE `vault_members` (`id` blob,`vault_id` blob NOT NULL,`account_id` blob NOT NULL,`ephemeral_public_key` blob NOT NULL,`nonce` blob NOT NULL,`ciphertext` blob NOT NULL,`created_at` datetime,PRIMARY KEY (`id`)); |
| -CREATE TABLE `vaults` (`id` blob,`account_id` blob NOT NULL,`drop_box_public_key` blob NOT NULL,`created_at` datetime, `keyset_nonce` blob, `keyset_ciphertext` blob,PRIMARY KEY (`id`)); |
| +CREATE TABLE `vaults` (`id` blob,`account_id` blob NOT NULL,`drop_box_public_key` blob NOT NULL,`created_at` datetime, `keyset_nonce` blob, `keyset_ciphertext` blob, `epoch` integer NOT NULL DEFAULT 1,PRIMARY KEY (`id`)); |
| CREATE TABLE `wraps` (`id` blob,`account_id` blob NOT NULL,`kind` text NOT NULL,`format_version` integer NOT NULL,`version` integer NOT NULL,`kdf_params` text NOT NULL,`cred_salt` blob,`kcv` blob NOT NULL,`nonce` blob NOT NULL,`ciphertext` blob NOT NULL,`created_at` datetime, `credential_id` blob,PRIMARY KEY (`id`)); |
diff --git a/internal/contra/model/model.go b/internal/contra/model/model.go| index fbb737e..609a054 100644 |
| --- a/internal/contra/model/model.go |
| +++ b/internal/contra/model/model.go |
| @@ -166,6 +166,25 @@ type Vault struct { |
| KeysetNonce []byte `gorm:"type:blob"` |
| KeysetCiphertext []byte `gorm:"type:blob"` |
| + // Epoch counts vault rotations, and is bound into every item's AAD. |
| + // |
| + // Removing a member rotates the vault — in a seal-to-public-key model |
| + // revocation IS rotation, because the removed member may have cached |
| + // K_vault. Rotation is eager: every item re-sealed under the new key and |
| + // every member wrap reissued, in ONE transaction guarded by a |
| + // compare-and-swap on this number. |
| + // |
| + // That is what the epoch buys. Without it a writer who began an edit |
| + // before the rotation landed would write an item sealed under the key |
| + // that was just revoked, and nothing would notice; with it, that writer's |
| + // AAD names the old epoch and the write cannot open. It is also why |
| + // rotation is eager rather than lazy: lazy needs a per-row |
| + // key-generation column, and that column tells the operator which items |
| + // changed since a removal. |
| + // |
| + // Starts at 1. DESIGN.md, "Rotation — and what does not heal". |
| + Epoch uint64 `gorm:"not null;default:1"` |
| + |
| CreatedAt time.Time |
| } |
diff --git a/internal/contra/store/sharing.go b/internal/contra/store/sharing.go| new file mode 100644 |
| index 0000000..6029581 |
| --- /dev/null |
| +++ b/internal/contra/store/sharing.go |
| @@ -0,0 +1,310 @@ |
| +// Shared vaults: membership, and the rotation that revocation really is. |
| +// |
| +// # Two seams, deliberately |
| +// |
| +// `ownedVault` and `reachableVault` are not interchangeable and the split is |
| +// the security boundary of this file: |
| +// |
| +// - reachableVault — owner OR member. Reading and writing ITEMS. A member |
| +// of a shared vault is there to use it. |
| +// - ownedVault — owner only. Anything that changes WHO CAN READ: adding a |
| +// member, removing one, rotating, deleting the vault. |
| +// |
| +// Getting that backwards in either direction is a real failure. Using |
| +// ownedVault for items locks members out of the thing they were shared; |
| +// using reachableVault for membership lets any member add or evict any other. |
| +// So every function below names which one it uses and why. |
| +// |
| +// # Why membership is not a content-derived identifier |
| +// |
| +// AGENTS.md decision 1: membership of a thread on a shared multi-tenant |
| +// instance would be. Membership of a vault on the OWNER'S OWN instance is |
| +// the access-control list the instance must consult to decide who it serves |
| +// sealed bytes to — transport shape, not content. That distinction is what |
| +// keeps this A1 rather than A2, and it is load-bearing: if a change starts |
| +// eroding it, stop rather than designing around it. |
| + |
| +package store |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "time" |
| + |
| + "gorm.io/gorm" |
| + |
| + "github.com/carlosframework/contra/internal/contra/crypto" |
| + "github.com/carlosframework/contra/internal/contra/model" |
| +) |
| + |
| +var ( |
| + // ErrRotated is a lost compare-and-swap on the vault epoch: the vault |
| + // rotated between the caller reading it and writing. The caller has to |
| + // refetch the keyset — the key it holds no longer opens anything. |
| + ErrRotated = errors.New("store: this vault rotated since you read it") |
| + |
| + // ErrNotOwner is an attempt to change who can read a vault by somebody |
| + // who is only a member of it. |
| + ErrNotOwner = errors.New("store: only a vault's owner can change its membership") |
| +) |
| + |
| +// reachableVault is the seam for ITEM reads and writes: the vault is yours, |
| +// or it is shared with you. |
| +// |
| +// The filter is in the SQL rather than in a check beside it, for the same |
| +// reason ownedVault's is — a later refactor cannot turn a read into an IDOR |
| +// by deleting a line it did not understand. |
| +func (s *Store) reachableVault(ctx context.Context, accountID, vaultID []byte) (*model.Vault, error) { |
| + var v model.Vault |
| + err := s.db.WithContext(ctx). |
| + Where(`id = ? AND (account_id = ? OR id IN ( |
| + SELECT vault_id FROM vault_members WHERE account_id = ?))`, |
| + vaultID, accountID, accountID). |
| + First(&v).Error |
| + if err != nil { |
| + return nil, err |
| + } |
| + return &v, nil |
| +} |
| + |
| +// ReachableVaults is every vault this account can open: the ones it owns and |
| +// the ones shared with it. Bootstrap's list. |
| +func (s *Store) ReachableVaults(ctx context.Context, accountID []byte) ([]model.Vault, error) { |
| + var out []model.Vault |
| + err := s.db.WithContext(ctx). |
| + Where(`account_id = ? OR id IN ( |
| + SELECT vault_id FROM vault_members WHERE account_id = ?)`, |
| + accountID, accountID). |
| + Order("created_at").Find(&out).Error |
| + return out, err |
| +} |
| + |
| +// MemberWrapOf returns the caller's own wrap of a vault keyset, or nil when |
| +// they are the owner and read the keyset off the vault row instead. |
| +func (s *Store) MemberWrapOf(ctx context.Context, vaultID, accountID []byte) (*model.VaultMember, error) { |
| + var m model.VaultMember |
| + err := s.db.WithContext(ctx). |
| + Where("vault_id = ? AND account_id = ?", vaultID, accountID).Take(&m).Error |
| + if errors.Is(err, gorm.ErrRecordNotFound) { |
| + return nil, nil |
| + } |
| + if err != nil { |
| + return nil, err |
| + } |
| + return &m, nil |
| +} |
| + |
| +// Members lists a vault's members. Owner-only: who a vault is shared with is |
| +// the owner's business, and a member learning the whole membership list is a |
| +// disclosure nobody asked for. |
| +func (s *Store) Members(ctx context.Context, accountID, vaultID []byte) ([]model.VaultMember, error) { |
| + if _, err := s.ownedVault(ctx, accountID, vaultID); err != nil { |
| + return nil, err |
| + } |
| + var out []model.VaultMember |
| + err := s.db.WithContext(ctx). |
| + Where("vault_id = ?", vaultID).Order("id").Find(&out).Error |
| + return out, err |
| +} |
| + |
| +// MemberWrap is one member's copy of a vault keyset, sealed to their account |
| +// public key by ephemeral-static ECDH. The server never holds the key that |
| +// opens it and there is no argument here that could carry one. |
| +type MemberWrap struct { |
| + AccountID []byte |
| + EphemeralPublicKey []byte |
| + Sealed Sealed |
| +} |
| + |
| +func (m MemberWrap) valid() bool { |
| + return len(m.AccountID) == 16 && |
| + len(m.EphemeralPublicKey) == crypto.PublicKeyLen && |
| + m.EphemeralPublicKey[0] == 0x04 && |
| + m.Sealed.valid() |
| +} |
| + |
| +// AddMember shares a vault. Owner-only. |
| +// |
| +// Sealing to a public key needs no unlock on the RECIPIENT's side and no key |
| +// on the server's — which is the property the agent surface depends on later: |
| +// a daemon can be handed a vault without anybody typing a passphrase. |
| +// |
| +// It does not rotate. Adding a member gives them what the vault already |
| +// holds, which is the intent; it is REMOVING one that has to rotate, because |
| +// that is the case where somebody keeps what they already had. |
| +func (s *Store) AddMember(ctx context.Context, ownerID, vaultID []byte, w MemberWrap) error { |
| + if !w.valid() { |
| + return ErrShortField |
| + } |
| + if _, err := s.ownedVault(ctx, ownerID, vaultID); err != nil { |
| + return err |
| + } |
| + id, err := NewID() |
| + if err != nil { |
| + return err |
| + } |
| + // One row per (vault, account): re-sharing replaces rather than |
| + // accumulates, or a removal would have to find every copy. |
| + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| + if err := tx.Where("vault_id = ? AND account_id = ?", vaultID, w.AccountID). |
| + Delete(&model.VaultMember{}).Error; err != nil { |
| + return err |
| + } |
| + return tx.Create(&model.VaultMember{ |
| + ID: id, |
| + VaultID: vaultID, |
| + AccountID: w.AccountID, |
| + EphemeralPublicKey: w.EphemeralPublicKey, |
| + Nonce: w.Sealed.Nonce, |
| + Ciphertext: w.Sealed.Ciphertext, |
| + CreatedAt: time.Now().UTC(), |
| + }).Error |
| + }) |
| +} |
| + |
| +// RotatedItem is one item re-sealed under the new vault key. |
| +type RotatedItem struct { |
| + ID []byte |
| + Version uint64 |
| + Sealed Sealed |
| +} |
| + |
| +// Rotation is everything one vault rotation writes. It is a single struct |
| +// because it is a single transaction: a rotation that applied half of this |
| +// would leave a vault whose items no longer match the key its members hold. |
| +type Rotation struct { |
| + // Keyset is the owner's copy of the new keyset, re-sealed under RK. |
| + Keyset Sealed |
| + |
| + // Items is EVERY item in the vault, re-sealed under the new K_vault with |
| + // its version bumped. Eager, not lazy: lazy rotation needs a per-row |
| + // key-generation column, and that column tells the operator which items |
| + // changed since a member was removed. |
| + Items []RotatedItem |
| + |
| + // Members is the remaining members' wraps of the new keyset. Anybody not |
| + // in this list stops being a member. |
| + Members []MemberWrap |
| +} |
| + |
| +// RotateVault is revocation. Owner-only, and one transaction. |
| +// |
| +// In a seal-to-public-key model revocation IS rotation: a removed member may |
| +// have cached K_vault, so taking their row away without changing the key |
| +// changes nothing about what they can decrypt. DESIGN.md's three-part answer, |
| +// none of which may be softened in the copy: access dies immediately, the key |
| +// rotates forward, and **nothing already downloaded is un-downloaded**. |
| +// |
| +// The compare-and-swap on the epoch is what makes concurrent writers safe. A |
| +// member who began an edit before the rotation landed has an item sealed |
| +// under the old key; their write is refused with ErrRotated rather than |
| +// accepted as a row that silently cannot be opened by anybody. |
| +// |
| +// # Why the epoch is not in the AAD |
| +// |
| +// #23 recommended binding it into every item's AAD. It is not, and the reason |
| +// is that it would be redundant: rotation mints a NEW K_vault, so an item |
| +// sealed under the old key already fails to open under the new one — the |
| +// cryptographic binding is the key itself. What the epoch adds is a |
| +// SERVER-side precondition, so a stale write is refused with an error a |
| +// client can act on instead of being stored as a row nobody can read. Putting |
| +// it in the AAD would change the pinned AAD format and every golden vector |
| +// for a property already held. Recorded because it is a deliberate divergence |
| +// from a review, not an oversight. |
| +func (s *Store) RotateVault(ctx context.Context, ownerID, vaultID []byte, |
| + expectedEpoch uint64, rot Rotation) error { |
| + if !rot.Keyset.valid() { |
| + return ErrShortField |
| + } |
| + for _, m := range rot.Members { |
| + if !m.valid() { |
| + return ErrShortField |
| + } |
| + } |
| + for _, it := range rot.Items { |
| + if len(it.ID) != 16 || !it.Sealed.valid() { |
| + return ErrShortField |
| + } |
| + if !crypto.IsBucketAligned(it.Sealed.Ciphertext) { |
| + return ErrNotAligned |
| + } |
| + } |
| + |
| + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| + // The CAS and the ownership check are the same statement, so there is |
| + // no window between proving ownership and acting on it. |
| + res := tx.Model(&model.Vault{}). |
| + Where("id = ? AND account_id = ? AND epoch = ?", vaultID, ownerID, expectedEpoch). |
| + Updates(map[string]any{ |
| + "epoch": expectedEpoch + 1, |
| + "keyset_nonce": rot.Keyset.Nonce, |
| + "keyset_ciphertext": rot.Keyset.Ciphertext, |
| + }) |
| + if res.Error != nil { |
| + return res.Error |
| + } |
| + if res.RowsAffected == 0 { |
| + // Not yours, gone, or already rotated. All three are refusals and |
| + // telling them apart would answer a question about somebody |
| + // else's vault. |
| + return ErrRotated |
| + } |
| + |
| + // Membership is replaced wholesale rather than diffed: the caller |
| + // sends who remains, so a removal is the absence of a row and cannot |
| + // be half-applied. |
| + if err := tx.Where("vault_id = ?", vaultID). |
| + Delete(&model.VaultMember{}).Error; err != nil { |
| + return err |
| + } |
| + now := time.Now().UTC() |
| + for _, m := range rot.Members { |
| + id, err := NewID() |
| + if err != nil { |
| + return err |
| + } |
| + if err := tx.Create(&model.VaultMember{ |
| + ID: id, |
| + VaultID: vaultID, |
| + AccountID: m.AccountID, |
| + EphemeralPublicKey: m.EphemeralPublicKey, |
| + Nonce: m.Sealed.Nonce, |
| + Ciphertext: m.Sealed.Ciphertext, |
| + CreatedAt: now, |
| + }).Error; err != nil { |
| + return err |
| + } |
| + } |
| + |
| + for _, it := range rot.Items { |
| + r := tx.Model(&model.Item{}). |
| + Where("id = ? AND vault_id = ?", it.ID, vaultID). |
| + Updates(map[string]any{ |
| + "version": it.Version, |
| + "nonce": it.Sealed.Nonce, |
| + "ciphertext": it.Sealed.Ciphertext, |
| + "updated_at": now, |
| + }) |
| + if r.Error != nil { |
| + return r.Error |
| + } |
| + if r.RowsAffected == 0 { |
| + // An item was created or deleted while the rotation was being |
| + // composed. Rolling back is right: a partial rotation leaves |
| + // items nobody can open. |
| + return ErrRotated |
| + } |
| + } |
| + return nil |
| + }) |
| +} |
| + |
| +// VaultEpoch reports the current epoch, so a refused write can be told what |
| +// to re-read rather than only that it lost. |
| +func (s *Store) VaultEpoch(ctx context.Context, accountID, vaultID []byte) (uint64, error) { |
| + v, err := s.reachableVault(ctx, accountID, vaultID) |
| + if err != nil { |
| + return 0, err |
| + } |
| + return v.Epoch, nil |
| +} |
diff --git a/internal/contra/store/store.go b/internal/contra/store/store.go| index a5005db..b1412cb 100644 |
| --- a/internal/contra/store/store.go |
| +++ b/internal/contra/store/store.go |
| @@ -224,7 +224,14 @@ func (s *Store) SetAccountKeyset(ctx context.Context, accountID, publicKey []byt |
| }).Error |
| } |
| -// ownedVault is the one seam every vault-scoped read and write goes through. |
| +// ownedVault is the OWNER-only seam: anything that changes who can read a |
| +// vault — adding a member, removing one, rotating, deleting it. |
| +// |
| +// Item reads and writes go through reachableVault instead (sharing.go), which |
| +// also admits members. The two are not interchangeable and the split is a |
| +// security boundary: ownedVault on items would lock members out of the thing |
| +// they were shared, and reachableVault on membership would let any member |
| +// evict any other. |
| // |
| // rastrillo's scope.Owned filters on an int64 user_id, and CONTRA's owner is |
| // a 16-byte account id carried on the session Subject — so this is written |
| @@ -269,7 +276,7 @@ func (s *Store) Wraps(ctx context.Context, accountID []byte) ([]model.Wrap, erro |
| // Items lists a vault's sealed items — after proving the vault is this |
| // account's. A vault that is not yours is a vault that does not exist. |
| func (s *Store) Items(ctx context.Context, accountID, vaultID []byte) ([]model.Item, error) { |
| - if _, err := s.ownedVault(ctx, accountID, vaultID); err != nil { |
| + if _, err := s.reachableVault(ctx, accountID, vaultID); err != nil { |
| return nil, err |
| } |
| var out []model.Item |
| @@ -284,7 +291,7 @@ func (s *Store) Items(ctx context.Context, accountID, vaultID []byte) ([]model.I |
| // carries the owner filter itself, so a foreign id deletes nothing rather |
| // than relying on a lookup somebody might later drop. |
| func (s *Store) DeleteItem(ctx context.Context, accountID, vaultID, itemID []byte) error { |
| - if _, err := s.ownedVault(ctx, accountID, vaultID); err != nil { |
| + if _, err := s.reachableVault(ctx, accountID, vaultID); err != nil { |
| return err |
| } |
| return s.db.WithContext(ctx). |
| @@ -331,7 +338,7 @@ func (s *Store) PutItem(ctx context.Context, accountID, vaultID, itemID []byte, |
| if !crypto.IsBucketAligned(sealed.Ciphertext) { |
| return nil, ErrNotAligned |
| } |
| - if _, err := s.ownedVault(ctx, accountID, vaultID); err != nil { |
| + if _, err := s.reachableVault(ctx, accountID, vaultID); err != nil { |
| return nil, err |
| } |
| now := time.Now().UTC() |
| @@ -385,7 +392,7 @@ func (s *Store) UpdateItem(ctx context.Context, accountID, vaultID, itemID []byt |
| // numbering. A gap or a repeat here means a confused writer. |
| return ErrBadVersion |
| } |
| - if _, err := s.ownedVault(ctx, accountID, vaultID); err != nil { |
| + if _, err := s.reachableVault(ctx, accountID, vaultID); err != nil { |
| return err |
| } |
| res := s.db.WithContext(ctx).Model(&model.Item{}). |
| @@ -412,7 +419,7 @@ func (s *Store) UpdateItem(ctx context.Context, accountID, vaultID, itemID []byt |
| // ItemVersion reports the version currently stored, so a 409 can tell the |
| // client what it has to merge against rather than only that it lost. |
| func (s *Store) ItemVersion(ctx context.Context, accountID, vaultID, itemID []byte) (uint64, error) { |
| - if _, err := s.ownedVault(ctx, accountID, vaultID); err != nil { |
| + if _, err := s.reachableVault(ctx, accountID, vaultID); err != nil { |
| return 0, err |
| } |
| var it model.Item |
diff --git a/internal/contra/vault/vault.go b/internal/contra/vault/vault.go| index 041f017..3eafc16 100644 |
| --- a/internal/contra/vault/vault.go |
| +++ b/internal/contra/vault/vault.go |
| @@ -14,6 +14,7 @@ |
| package vault |
| import ( |
| + "bytes" |
| "encoding/hex" |
| "encoding/json" |
| "errors" |
| @@ -115,7 +116,11 @@ func (v *Vault) Bootstrap(w http.ResponseWriter, r *http.Request) { |
| writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "lookup failed"}) |
| return |
| } |
| - vaults, err := v.store.Vaults(ctx, id) |
| + // Everything this account can open: what it owns, and what is shared with |
| + // it. A member's copy of a shared keyset is a different blob under a |
| + // different key from the owner's, so which one is sent depends on who is |
| + // asking — see the loop below. |
| + vaults, err := v.store.ReachableVaults(ctx, id) |
| if err != nil { |
| writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "lookup failed"}) |
| return |
| @@ -132,6 +137,18 @@ func (v *Vault) Bootstrap(w http.ResponseWriter, r *http.Request) { |
| type vaultOut struct { |
| VaultIDHex string `json:"vault_id_hex"` |
| Keyset sealedJSON `json:"keyset"` |
| + |
| + // Epoch is the compare-and-swap token for writes into this vault. A |
| + // client that writes against a stale one is refused rather than |
| + // storing an item sealed under a key that has just been revoked. |
| + Epoch uint64 `json:"epoch"` |
| + |
| + // Owned says which key opens Keyset. The owner's copy is sealed under |
| + // RK; a member's is sealed to their account public key by |
| + // ephemeral-static ECDH, and needs EphemeralPublicKeyHex to open. |
| + Owned bool `json:"owned"` |
| + |
| + EphemeralPublicKeyHex string `json:"ephemeral_public_key_hex,omitempty"` |
| } |
| out := struct { |
| @@ -155,10 +172,27 @@ func (v *Vault) Bootstrap(w http.ResponseWriter, r *http.Request) { |
| }) |
| } |
| for _, vt := range vaults { |
| - out.Vaults = append(out.Vaults, vaultOut{ |
| + row := vaultOut{ |
| VaultIDHex: hex.EncodeToString(vt.ID), |
| - Keyset: sealedOf(vt.KeysetNonce, vt.KeysetCiphertext), |
| - }) |
| + Epoch: vt.Epoch, |
| + Owned: bytes.Equal(vt.AccountID, id), |
| + } |
| + if row.Owned { |
| + row.Keyset = sealedOf(vt.KeysetNonce, vt.KeysetCiphertext) |
| + } else { |
| + // A member gets THEIR wrap, never the owner's — the owner's is |
| + // sealed under the owner's RK and would be an unopenable blob to |
| + // anybody else, which is a confusing way to say "not for you". |
| + m, err := v.store.MemberWrapOf(ctx, vt.ID, id) |
| + if err != nil || m == nil { |
| + // The membership row vanished between listing and reading it. |
| + // Dropping the vault is right: there is nothing to send. |
| + continue |
| + } |
| + row.Keyset = sealedOf(m.Nonce, m.Ciphertext) |
| + row.EphemeralPublicKeyHex = hex.EncodeToString(m.EphemeralPublicKey) |
| + } |
| + out.Vaults = append(out.Vaults, row) |
| } |
| writeJSON(w, http.StatusOK, out) |
| } |
diff --git a/internal/contratest/greps_test.go b/internal/contratest/greps_test.go| index e8b1d5f..58d982f 100644 |
| --- a/internal/contratest/greps_test.go |
| +++ b/internal/contratest/greps_test.go |
| @@ -315,6 +315,68 @@ func writeEverything(t *testing.T, s *store.Store) secrets { |
| t.Fatalf("StartLink(stranded): %v", err) |
| } |
| + // Sharing, and the rotation that revocation is. Both carry the vault |
| + // keyset — which holds the vault's NAME and its bulk key — so both are |
| + // canary-bearing write paths and the rotation is the one most likely to |
| + // leave the superseded ciphertext behind in a freed page. |
| + member, err := crypto.DeriveP256(randBytes(t, 32)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + memberAccountID := randBytes(t, 16) |
| + shareNonce := randBytes(t, 12) |
| + shareCT, err := crypto.Seal(randBytes(t, 32), shareNonce, |
| + crypto.AAD(vault.ID, "vault.keyset", memberAccountID, 1), []byte(keyset)) |
| + if err != nil { |
| + t.Fatalf("Seal(member keyset): %v", err) |
| + } |
| + if err := s.AddMember(ctx, acct.ID, vault.ID, store.MemberWrap{ |
| + AccountID: memberAccountID, |
| + EphemeralPublicKey: member.PublicKey().Bytes(), |
| + Sealed: store.Sealed{Nonce: shareNonce, Ciphertext: shareCT}, |
| + }); err != nil { |
| + t.Fatalf("AddMember: %v", err) |
| + } |
| + |
| + // Then rotate them straight back out, which re-seals every item under a |
| + // new key and reissues every remaining wrap in one transaction. |
| + rotatedNonce := randBytes(t, 12) |
| + rotatedKVault := randBytes(t, 32) |
| + rotatedKeysetCT, err := crypto.Seal(sec.rk, rotatedNonce, |
| + crypto.AAD(acct.ID, "vault.keyset", vault.ID, 1), []byte(keyset)) |
| + if err != nil { |
| + t.Fatalf("Seal(rotated keyset): %v", err) |
| + } |
| + rotatedItemNonce := randBytes(t, 12) |
| + rotatedItemCT, err := crypto.Seal(rotatedKVault, rotatedItemNonce, |
| + crypto.AAD(vault.ID, crypto.ObjectItem, item.ID, 3), crypto.Pad([]byte(body))) |
| + if err != nil { |
| + t.Fatalf("Seal(rotated item): %v", err) |
| + } |
| + // One member is retained and one is removed, which is the realistic shape |
| + // and keeps both halves of the transaction under the grep: a reissued |
| + // wrap of the NEW keyset, and the absence of the removed one. |
| + retainedNonce := randBytes(t, 12) |
| + retainedCT, err := crypto.Seal(randBytes(t, 32), retainedNonce, |
| + crypto.AAD(vault.ID, "vault.keyset", acct.ID, 1), []byte(keyset)) |
| + if err != nil { |
| + t.Fatalf("Seal(retained keyset): %v", err) |
| + } |
| + if err := s.RotateVault(ctx, acct.ID, vault.ID, 1, store.Rotation{ |
| + Keyset: store.Sealed{Nonce: rotatedNonce, Ciphertext: rotatedKeysetCT}, |
| + Items: []store.RotatedItem{{ |
| + ID: item.ID, Version: 3, |
| + Sealed: store.Sealed{Nonce: rotatedItemNonce, Ciphertext: rotatedItemCT}, |
| + }}, |
| + Members: []store.MemberWrap{{ |
| + AccountID: acct.ID, |
| + EphemeralPublicKey: ephemeral.PublicKey().Bytes(), |
| + Sealed: store.Sealed{Nonce: retainedNonce, Ciphertext: retainedCT}, |
| + }}, |
| + }); err != nil { |
| + t.Fatalf("RotateVault: %v", err) |
| + } |
| + |
| // Reissuing the recovery code: the one path in the app that DELETES, so |
| // the one most likely to leave the old row's bytes behind in a freed |
| // page. The grep reads the whole file including free space, which is the |
diff --git a/internal/contratest/sharing_test.go b/internal/contratest/sharing_test.go| new file mode 100644 |
| index 0000000..a42935c |
| --- /dev/null |
| +++ b/internal/contratest/sharing_test.go |
| @@ -0,0 +1,312 @@ |
| +package contratest |
| + |
| +import ( |
| + "context" |
| + "encoding/hex" |
| + "net/http" |
| + "testing" |
| + |
| + "github.com/carlosframework/contra/internal/contra/crypto" |
| + "github.com/carlosframework/contra/internal/contra/store" |
| +) |
| + |
| +// Shared vaults, at the storage layer. |
| +// |
| +// The two facts worth proving are the two that a refactor can silently |
| +// reverse: a member CAN reach the items of a vault shared with them, and a |
| +// member CANNOT change who else is in it. Those go through different seams — |
| +// reachableVault and ownedVault — and swapping one for the other compiles |
| +// perfectly and is a security hole in either direction. |
| + |
| +// share puts `to` into `owner`'s vault, sealing the keyset to a throwaway |
| +// public key. The bytes are opaque to the store, so a test does not need the |
| +// real ECDH to exercise the row. |
| +func share(t *testing.T, s *store.Store, owner provisioned, to []byte) { |
| + t.Helper() |
| + eph, err := crypto.DeriveP256(randBytes(t, 32)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + nonce := randBytes(t, 12) |
| + ct, err := crypto.Seal(randBytes(t, 32), nonce, |
| + crypto.AAD(owner.vaultID, "vault.keyset", to, 1), []byte(`{"k_vault":"x"}`)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.AddMember(context.Background(), owner.id, owner.vaultID, store.MemberWrap{ |
| + AccountID: to, |
| + EphemeralPublicKey: eph.PublicKey().Bytes(), |
| + Sealed: store.Sealed{Nonce: nonce, Ciphertext: ct}, |
| + }); err != nil { |
| + t.Fatalf("AddMember: %v", err) |
| + } |
| +} |
| + |
| +func TestAMemberCanReadAndWriteAVaultTheyDoNotOwn(t *testing.T) { |
| + h, gdb := newAppWithDB(t) |
| + owner := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| + guest := provision(t, h, "guest@example.invalid", "a different generated one 4d2e9b") |
| + s := store.New(gdb) |
| + |
| + itemID := newItemID(t) |
| + as(t, h, owner.cookie, http.MethodPost, "/api/items", |
| + sealItem(t, owner, itemID, 1, `{"title":"Shared"}`)) |
| + |
| + // Before sharing, the guest cannot see it at all — 404, never 403. |
| + if rec := as(t, h, guest.cookie, http.MethodGet, |
| + "/api/items?vault="+hex.EncodeToString(owner.vaultID), nil); rec.Code != http.StatusNotFound { |
| + t.Fatalf("before sharing: status %d, want 404", rec.Code) |
| + } |
| + |
| + share(t, s, owner, guest.id) |
| + |
| + // After sharing, the guest can list it. |
| + var listed struct { |
| + Items []struct { |
| + ItemIDHex string `json:"item_id_hex"` |
| + Version uint64 `json:"version"` |
| + } `json:"items"` |
| + } |
| + rec := as(t, h, guest.cookie, http.MethodGet, |
| + "/api/items?vault="+hex.EncodeToString(owner.vaultID), nil) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("member list: status %d", rec.Code) |
| + } |
| + decode(t, rec, &listed) |
| + if len(listed.Items) != 1 { |
| + t.Fatalf("member sees %d items, want 1", len(listed.Items)) |
| + } |
| + |
| + // And write to it. Sharing that is read-only is a different feature and |
| + // is not what decision 1 describes. |
| + edit := sealItem(t, owner, itemID, 2, `{"title":"Edited by the member"}`) |
| + if rec := as(t, h, guest.cookie, http.MethodPut, "/api/items", edit); rec.Code != http.StatusOK { |
| + t.Fatalf("member write: status %d, body %s", rec.Code, rec.Body.String()) |
| + } |
| +} |
| + |
| +// Bootstrap has to hand a member THEIR wrap, not the owner's — the owner's is |
| +// sealed under the owner's RK and would be an unopenable blob, which is a |
| +// confusing way to say "not for you". |
| +func TestBootstrapGivesAMemberTheirOwnWrap(t *testing.T) { |
| + h, gdb := newAppWithDB(t) |
| + owner := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| + guest := provision(t, h, "guest@example.invalid", "a different generated one 4d2e9b") |
| + share(t, store.New(gdb), owner, guest.id) |
| + |
| + var boot struct { |
| + Vaults []struct { |
| + VaultIDHex string `json:"vault_id_hex"` |
| + Owned bool `json:"owned"` |
| + Epoch uint64 `json:"epoch"` |
| + EphemeralPublicKeyHex string `json:"ephemeral_public_key_hex"` |
| + Keyset struct { |
| + CiphertextHex string `json:"ciphertext_hex"` |
| + } `json:"keyset"` |
| + } `json:"vaults"` |
| + } |
| + decode(t, as(t, h, guest.cookie, http.MethodGet, "/api/bootstrap", nil), &boot) |
| + |
| + var shared *struct { |
| + VaultIDHex string `json:"vault_id_hex"` |
| + Owned bool `json:"owned"` |
| + Epoch uint64 `json:"epoch"` |
| + EphemeralPublicKeyHex string `json:"ephemeral_public_key_hex"` |
| + Keyset struct { |
| + CiphertextHex string `json:"ciphertext_hex"` |
| + } `json:"keyset"` |
| + } |
| + for i := range boot.Vaults { |
| + if boot.Vaults[i].VaultIDHex == hex.EncodeToString(owner.vaultID) { |
| + shared = &boot.Vaults[i] |
| + } |
| + } |
| + if shared == nil { |
| + t.Fatal("the shared vault is not in the member's bootstrap") |
| + } |
| + if shared.Owned { |
| + t.Error("a shared vault is reported as owned by the member") |
| + } |
| + if shared.EphemeralPublicKeyHex == "" { |
| + t.Error("a member's wrap arrived without the ephemeral public key that opens it") |
| + } |
| + if shared.Epoch != 1 { |
| + t.Errorf("epoch %d, want 1", shared.Epoch) |
| + } |
| + |
| + // The owner's own bootstrap still reports it as owned, with a different |
| + // ciphertext — the two copies are sealed under different keys. |
| + var ownerBoot struct { |
| + Vaults []struct { |
| + VaultIDHex string `json:"vault_id_hex"` |
| + Owned bool `json:"owned"` |
| + Keyset struct { |
| + CiphertextHex string `json:"ciphertext_hex"` |
| + } `json:"keyset"` |
| + } `json:"vaults"` |
| + } |
| + decode(t, as(t, h, owner.cookie, http.MethodGet, "/api/bootstrap", nil), &ownerBoot) |
| + for _, vt := range ownerBoot.Vaults { |
| + if vt.VaultIDHex != hex.EncodeToString(owner.vaultID) { |
| + continue |
| + } |
| + if !vt.Owned { |
| + t.Error("the owner's own vault is not reported as owned") |
| + } |
| + if vt.Keyset.CiphertextHex == shared.Keyset.CiphertextHex { |
| + t.Error("the member was handed the owner's keyset copy") |
| + } |
| + } |
| +} |
| + |
| +// The other half of the seam, and the one that is a security hole if it is |
| +// wrong: a member must not be able to change who can read. |
| +func TestAMemberCannotChangeMembership(t *testing.T) { |
| + h, gdb := newAppWithDB(t) |
| + owner := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| + guest := provision(t, h, "guest@example.invalid", "a different generated one 4d2e9b") |
| + s := store.New(gdb) |
| + share(t, s, owner, guest.id) |
| + |
| + ctx := context.Background() |
| + |
| + // Listing the membership is the owner's business. |
| + if _, err := s.Members(ctx, guest.id, owner.vaultID); err == nil { |
| + t.Error("a member listed the vault's membership") |
| + } |
| + if _, err := s.Members(ctx, owner.id, owner.vaultID); err != nil { |
| + t.Errorf("the owner cannot list their own vault's membership: %v", err) |
| + } |
| + |
| + // Adding another member is too. |
| + eph, err := crypto.DeriveP256(randBytes(t, 32)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + nonce := randBytes(t, 12) |
| + ct, _ := crypto.Seal(randBytes(t, 32), nonce, |
| + crypto.AAD(owner.vaultID, "vault.keyset", randBytes(t, 16), 1), []byte(`{"k":"x"}`)) |
| + err = s.AddMember(ctx, guest.id, owner.vaultID, store.MemberWrap{ |
| + AccountID: randBytes(t, 16), |
| + EphemeralPublicKey: eph.PublicKey().Bytes(), |
| + Sealed: store.Sealed{Nonce: nonce, Ciphertext: ct}, |
| + }) |
| + if err == nil { |
| + t.Error("a member added another member to somebody else's vault") |
| + } |
| + |
| + // And so is rotating, which would let a member evict the owner. |
| + keysetNonce := randBytes(t, 12) |
| + keysetCT, _ := crypto.Seal(randBytes(t, 32), keysetNonce, |
| + crypto.AAD(owner.vaultID, "vault.keyset", owner.vaultID, 1), []byte(`{"k":"x"}`)) |
| + err = s.RotateVault(ctx, guest.id, owner.vaultID, 1, store.Rotation{ |
| + Keyset: store.Sealed{Nonce: keysetNonce, Ciphertext: keysetCT}, |
| + }) |
| + if err == nil { |
| + t.Error("a member rotated somebody else's vault") |
| + } |
| +} |
| + |
| +// Revocation is rotation, and it is one transaction: the key moves, the |
| +// removed member's row goes, the remaining wraps are reissued, and every item |
| +// is re-sealed. A half-applied rotation leaves items nobody can open. |
| +func TestRemovingAMemberRotatesTheVault(t *testing.T) { |
| + h, gdb := newAppWithDB(t) |
| + owner := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| + guest := provision(t, h, "guest@example.invalid", "a different generated one 4d2e9b") |
| + s := store.New(gdb) |
| + ctx := context.Background() |
| + |
| + itemID := newItemID(t) |
| + as(t, h, owner.cookie, http.MethodPost, "/api/items", |
| + sealItem(t, owner, itemID, 1, `{"title":"Shared"}`)) |
| + share(t, s, owner, guest.id) |
| + |
| + // Rotate with nobody remaining: the guest is removed. |
| + newKVault := randBytes(t, 32) |
| + keysetNonce := randBytes(t, 12) |
| + keysetCT, err := crypto.Seal(owner.rk, keysetNonce, |
| + crypto.AAD(owner.id, "vault.keyset", owner.vaultID, 1), []byte(`{"k_vault":"new"}`)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + itemNonce := randBytes(t, 12) |
| + itemCT, err := crypto.Seal(newKVault, itemNonce, |
| + crypto.AAD(owner.vaultID, crypto.ObjectItem, itemID, 2), |
| + crypto.Pad([]byte(`{"title":"Shared"}`))) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.RotateVault(ctx, owner.id, owner.vaultID, 1, store.Rotation{ |
| + Keyset: store.Sealed{Nonce: keysetNonce, Ciphertext: keysetCT}, |
| + Items: []store.RotatedItem{{ |
| + ID: itemID, Version: 2, |
| + Sealed: store.Sealed{Nonce: itemNonce, Ciphertext: itemCT}, |
| + }}, |
| + }); err != nil { |
| + t.Fatalf("RotateVault: %v", err) |
| + } |
| + |
| + // Access dies immediately: the ex-member cannot reach the vault at all. |
| + if rec := as(t, h, guest.cookie, http.MethodGet, |
| + "/api/items?vault="+hex.EncodeToString(owner.vaultID), nil); rec.Code != http.StatusNotFound { |
| + t.Errorf("a removed member still reaches the vault: status %d", rec.Code) |
| + } |
| + |
| + // The epoch moved, so a writer holding the old one is refused rather than |
| + // storing an item sealed under the revoked key. |
| + epoch, err := s.VaultEpoch(ctx, owner.id, owner.vaultID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if epoch != 2 { |
| + t.Errorf("epoch %d after one rotation, want 2", epoch) |
| + } |
| + if err := s.RotateVault(ctx, owner.id, owner.vaultID, 1, store.Rotation{ |
| + Keyset: store.Sealed{Nonce: keysetNonce, Ciphertext: keysetCT}, |
| + }); err != store.ErrRotated { |
| + t.Errorf("a rotation against a stale epoch returned %v, want ErrRotated", err) |
| + } |
| +} |
| + |
| +// A rotation that cannot re-seal every item must apply none of it. Half a |
| +// rotation is a vault whose items no longer match the key its members hold. |
| +func TestAPartialRotationIsRolledBack(t *testing.T) { |
| + h, gdb := newAppWithDB(t) |
| + owner := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| + s := store.New(gdb) |
| + ctx := context.Background() |
| + |
| + itemID := newItemID(t) |
| + as(t, h, owner.cookie, http.MethodPost, "/api/items", |
| + sealItem(t, owner, itemID, 1, `{"title":"Real"}`)) |
| + |
| + keysetNonce := randBytes(t, 12) |
| + keysetCT, _ := crypto.Seal(owner.rk, keysetNonce, |
| + crypto.AAD(owner.id, "vault.keyset", owner.vaultID, 1), []byte(`{"k_vault":"new"}`)) |
| + nonce := randBytes(t, 12) |
| + ct, _ := crypto.Seal(randBytes(t, 32), nonce, |
| + crypto.AAD(owner.vaultID, crypto.ObjectItem, itemID, 2), |
| + crypto.Pad([]byte(`{"title":"Real"}`))) |
| + |
| + // Names an item that is not there — as a concurrent delete would. |
| + err := s.RotateVault(ctx, owner.id, owner.vaultID, 1, store.Rotation{ |
| + Keyset: store.Sealed{Nonce: keysetNonce, Ciphertext: keysetCT}, |
| + Items: []store.RotatedItem{ |
| + {ID: itemID, Version: 2, Sealed: store.Sealed{Nonce: nonce, Ciphertext: ct}}, |
| + {ID: newItemID(t), Version: 2, Sealed: store.Sealed{Nonce: nonce, Ciphertext: ct}}, |
| + }, |
| + }) |
| + if err != store.ErrRotated { |
| + t.Fatalf("rotation naming a missing item returned %v, want ErrRotated", err) |
| + } |
| + |
| + // Nothing moved: the epoch is untouched, so the whole thing rolled back. |
| + epoch, err := s.VaultEpoch(ctx, owner.id, owner.vaultID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if epoch != 1 { |
| + t.Errorf("epoch %d after a failed rotation, want 1 — it was not rolled back", epoch) |
| + } |
| +} |