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) } }