carlos / contra Public

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>
Paul Campbell pushed by paul@keymail.dev 4a618f1d9e9d265ba6cc25e08872396beb873df0 parent a974578
8 files changed, +757 −11
  • internal/contra/migrations/0007_alter_vaults.sql +2 −0
  • internal/contra/migrations/schema.sql +1 −1
  • internal/contra/model/model.go +19 −0
  • internal/contra/store/sharing.go +310 −0
  • internal/contra/store/store.go +13 −6
  • internal/contra/vault/vault.go +38 −4
  • internal/contratest/greps_test.go +62 −0
  • internal/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)
+ }
+}