| 1 | package contratest |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/hex" |
| 6 | "net/http" |
| 7 | "testing" |
| 8 | |
| 9 | "github.com/carlosframework/contra/internal/contra/crypto" |
| 10 | "github.com/carlosframework/contra/internal/contra/store" |
| 11 | ) |
| 12 | |
| 13 | // Shared vaults, at the storage layer. |
| 14 | // |
| 15 | // The two facts worth proving are the two that a refactor can silently |
| 16 | // reverse: a member CAN reach the items of a vault shared with them, and a |
| 17 | // member CANNOT change who else is in it. Those go through different seams — |
| 18 | // reachableVault and ownedVault — and swapping one for the other compiles |
| 19 | // perfectly and is a security hole in either direction. |
| 20 | |
| 21 | // share puts `to` into `owner`'s vault, sealing the keyset to a throwaway |
| 22 | // public key. The bytes are opaque to the store, so a test does not need the |
| 23 | // real ECDH to exercise the row. |
| 24 | func share(t *testing.T, s *store.Store, owner provisioned, to []byte) { |
| 25 | t.Helper() |
| 26 | eph, err := crypto.DeriveP256(randBytes(t, 32)) |
| 27 | if err != nil { |
| 28 | t.Fatal(err) |
| 29 | } |
| 30 | nonce := randBytes(t, 12) |
| 31 | ct, err := crypto.Seal(randBytes(t, 32), nonce, |
| 32 | crypto.AAD(owner.vaultID, "vault.keyset", to, 1), []byte(`{"k_vault":"x"}`)) |
| 33 | if err != nil { |
| 34 | t.Fatal(err) |
| 35 | } |
| 36 | if err := s.AddMember(context.Background(), owner.id, owner.vaultID, store.MemberWrap{ |
| 37 | AccountID: to, |
| 38 | EphemeralPublicKey: eph.PublicKey().Bytes(), |
| 39 | Sealed: store.Sealed{Nonce: nonce, Ciphertext: ct}, |
| 40 | }); err != nil { |
| 41 | t.Fatalf("AddMember: %v", err) |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | func TestAMemberCanReadAndWriteAVaultTheyDoNotOwn(t *testing.T) { |
| 46 | h, gdb := newAppWithDB(t) |
| 47 | owner := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| 48 | guest := provision(t, h, "guest@example.invalid", "a different generated one 4d2e9b") |
| 49 | s := store.New(gdb) |
| 50 | |
| 51 | itemID := newItemID(t) |
| 52 | as(t, h, owner.cookie, http.MethodPost, "/api/items", |
| 53 | sealItem(t, owner, itemID, 1, `{"title":"Shared"}`)) |
| 54 | |
| 55 | // Before sharing, the guest cannot see it at all — 404, never 403. |
| 56 | if rec := as(t, h, guest.cookie, http.MethodGet, |
| 57 | "/api/items?vault="+hex.EncodeToString(owner.vaultID), nil); rec.Code != http.StatusNotFound { |
| 58 | t.Fatalf("before sharing: status %d, want 404", rec.Code) |
| 59 | } |
| 60 | |
| 61 | share(t, s, owner, guest.id) |
| 62 | |
| 63 | // After sharing, the guest can list it. |
| 64 | var listed struct { |
| 65 | Items []struct { |
| 66 | ItemIDHex string `json:"item_id_hex"` |
| 67 | Version uint64 `json:"version"` |
| 68 | } `json:"items"` |
| 69 | } |
| 70 | rec := as(t, h, guest.cookie, http.MethodGet, |
| 71 | "/api/items?vault="+hex.EncodeToString(owner.vaultID), nil) |
| 72 | if rec.Code != http.StatusOK { |
| 73 | t.Fatalf("member list: status %d", rec.Code) |
| 74 | } |
| 75 | decode(t, rec, &listed) |
| 76 | if len(listed.Items) != 1 { |
| 77 | t.Fatalf("member sees %d items, want 1", len(listed.Items)) |
| 78 | } |
| 79 | |
| 80 | // And write to it. Sharing that is read-only is a different feature and |
| 81 | // is not what decision 1 describes. |
| 82 | edit := sealItem(t, owner, itemID, 2, `{"title":"Edited by the member"}`) |
| 83 | if rec := as(t, h, guest.cookie, http.MethodPut, "/api/items", edit); rec.Code != http.StatusOK { |
| 84 | t.Fatalf("member write: status %d, body %s", rec.Code, rec.Body.String()) |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | // Bootstrap has to hand a member THEIR wrap, not the owner's — the owner's is |
| 89 | // sealed under the owner's RK and would be an unopenable blob, which is a |
| 90 | // confusing way to say "not for you". |
| 91 | func TestBootstrapGivesAMemberTheirOwnWrap(t *testing.T) { |
| 92 | h, gdb := newAppWithDB(t) |
| 93 | owner := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| 94 | guest := provision(t, h, "guest@example.invalid", "a different generated one 4d2e9b") |
| 95 | share(t, store.New(gdb), owner, guest.id) |
| 96 | |
| 97 | var boot struct { |
| 98 | Vaults []struct { |
| 99 | VaultIDHex string `json:"vault_id_hex"` |
| 100 | Owned bool `json:"owned"` |
| 101 | Epoch uint64 `json:"epoch"` |
| 102 | EphemeralPublicKeyHex string `json:"ephemeral_public_key_hex"` |
| 103 | Keyset struct { |
| 104 | CiphertextHex string `json:"ciphertext_hex"` |
| 105 | } `json:"keyset"` |
| 106 | } `json:"vaults"` |
| 107 | } |
| 108 | decode(t, as(t, h, guest.cookie, http.MethodGet, "/api/bootstrap", nil), &boot) |
| 109 | |
| 110 | var shared *struct { |
| 111 | VaultIDHex string `json:"vault_id_hex"` |
| 112 | Owned bool `json:"owned"` |
| 113 | Epoch uint64 `json:"epoch"` |
| 114 | EphemeralPublicKeyHex string `json:"ephemeral_public_key_hex"` |
| 115 | Keyset struct { |
| 116 | CiphertextHex string `json:"ciphertext_hex"` |
| 117 | } `json:"keyset"` |
| 118 | } |
| 119 | for i := range boot.Vaults { |
| 120 | if boot.Vaults[i].VaultIDHex == hex.EncodeToString(owner.vaultID) { |
| 121 | shared = &boot.Vaults[i] |
| 122 | } |
| 123 | } |
| 124 | if shared == nil { |
| 125 | t.Fatal("the shared vault is not in the member's bootstrap") |
| 126 | } |
| 127 | if shared.Owned { |
| 128 | t.Error("a shared vault is reported as owned by the member") |
| 129 | } |
| 130 | if shared.EphemeralPublicKeyHex == "" { |
| 131 | t.Error("a member's wrap arrived without the ephemeral public key that opens it") |
| 132 | } |
| 133 | if shared.Epoch != 1 { |
| 134 | t.Errorf("epoch %d, want 1", shared.Epoch) |
| 135 | } |
| 136 | |
| 137 | // The owner's own bootstrap still reports it as owned, with a different |
| 138 | // ciphertext — the two copies are sealed under different keys. |
| 139 | var ownerBoot struct { |
| 140 | Vaults []struct { |
| 141 | VaultIDHex string `json:"vault_id_hex"` |
| 142 | Owned bool `json:"owned"` |
| 143 | Keyset struct { |
| 144 | CiphertextHex string `json:"ciphertext_hex"` |
| 145 | } `json:"keyset"` |
| 146 | } `json:"vaults"` |
| 147 | } |
| 148 | decode(t, as(t, h, owner.cookie, http.MethodGet, "/api/bootstrap", nil), &ownerBoot) |
| 149 | for _, vt := range ownerBoot.Vaults { |
| 150 | if vt.VaultIDHex != hex.EncodeToString(owner.vaultID) { |
| 151 | continue |
| 152 | } |
| 153 | if !vt.Owned { |
| 154 | t.Error("the owner's own vault is not reported as owned") |
| 155 | } |
| 156 | if vt.Keyset.CiphertextHex == shared.Keyset.CiphertextHex { |
| 157 | t.Error("the member was handed the owner's keyset copy") |
| 158 | } |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | // The other half of the seam, and the one that is a security hole if it is |
| 163 | // wrong: a member must not be able to change who can read. |
| 164 | func TestAMemberCannotChangeMembership(t *testing.T) { |
| 165 | h, gdb := newAppWithDB(t) |
| 166 | owner := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| 167 | guest := provision(t, h, "guest@example.invalid", "a different generated one 4d2e9b") |
| 168 | s := store.New(gdb) |
| 169 | share(t, s, owner, guest.id) |
| 170 | |
| 171 | ctx := context.Background() |
| 172 | |
| 173 | // Listing the membership is the owner's business. |
| 174 | if _, err := s.Members(ctx, guest.id, owner.vaultID); err == nil { |
| 175 | t.Error("a member listed the vault's membership") |
| 176 | } |
| 177 | if _, err := s.Members(ctx, owner.id, owner.vaultID); err != nil { |
| 178 | t.Errorf("the owner cannot list their own vault's membership: %v", err) |
| 179 | } |
| 180 | |
| 181 | // Adding another member is too. |
| 182 | eph, err := crypto.DeriveP256(randBytes(t, 32)) |
| 183 | if err != nil { |
| 184 | t.Fatal(err) |
| 185 | } |
| 186 | nonce := randBytes(t, 12) |
| 187 | ct, _ := crypto.Seal(randBytes(t, 32), nonce, |
| 188 | crypto.AAD(owner.vaultID, "vault.keyset", randBytes(t, 16), 1), []byte(`{"k":"x"}`)) |
| 189 | err = s.AddMember(ctx, guest.id, owner.vaultID, store.MemberWrap{ |
| 190 | AccountID: randBytes(t, 16), |
| 191 | EphemeralPublicKey: eph.PublicKey().Bytes(), |
| 192 | Sealed: store.Sealed{Nonce: nonce, Ciphertext: ct}, |
| 193 | }) |
| 194 | if err == nil { |
| 195 | t.Error("a member added another member to somebody else's vault") |
| 196 | } |
| 197 | |
| 198 | // And so is rotating, which would let a member evict the owner. |
| 199 | keysetNonce := randBytes(t, 12) |
| 200 | keysetCT, _ := crypto.Seal(randBytes(t, 32), keysetNonce, |
| 201 | crypto.AAD(owner.vaultID, "vault.keyset", owner.vaultID, 1), []byte(`{"k":"x"}`)) |
| 202 | err = s.RotateVault(ctx, guest.id, owner.vaultID, 1, store.Rotation{ |
| 203 | Keyset: store.Sealed{Nonce: keysetNonce, Ciphertext: keysetCT}, |
| 204 | }) |
| 205 | if err == nil { |
| 206 | t.Error("a member rotated somebody else's vault") |
| 207 | } |
| 208 | } |
| 209 | |
| 210 | // Revocation is rotation, and it is one transaction: the key moves, the |
| 211 | // removed member's row goes, the remaining wraps are reissued, and every item |
| 212 | // is re-sealed. A half-applied rotation leaves items nobody can open. |
| 213 | func TestRemovingAMemberRotatesTheVault(t *testing.T) { |
| 214 | h, gdb := newAppWithDB(t) |
| 215 | owner := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| 216 | guest := provision(t, h, "guest@example.invalid", "a different generated one 4d2e9b") |
| 217 | s := store.New(gdb) |
| 218 | ctx := context.Background() |
| 219 | |
| 220 | itemID := newItemID(t) |
| 221 | as(t, h, owner.cookie, http.MethodPost, "/api/items", |
| 222 | sealItem(t, owner, itemID, 1, `{"title":"Shared"}`)) |
| 223 | share(t, s, owner, guest.id) |
| 224 | |
| 225 | // Rotate with nobody remaining: the guest is removed. |
| 226 | newKVault := randBytes(t, 32) |
| 227 | keysetNonce := randBytes(t, 12) |
| 228 | keysetCT, err := crypto.Seal(owner.rk, keysetNonce, |
| 229 | crypto.AAD(owner.id, "vault.keyset", owner.vaultID, 1), []byte(`{"k_vault":"new"}`)) |
| 230 | if err != nil { |
| 231 | t.Fatal(err) |
| 232 | } |
| 233 | itemNonce := randBytes(t, 12) |
| 234 | itemCT, err := crypto.Seal(newKVault, itemNonce, |
| 235 | crypto.AAD(owner.vaultID, crypto.ObjectItem, itemID, 2), |
| 236 | crypto.Pad([]byte(`{"title":"Shared"}`))) |
| 237 | if err != nil { |
| 238 | t.Fatal(err) |
| 239 | } |
| 240 | if err := s.RotateVault(ctx, owner.id, owner.vaultID, 1, store.Rotation{ |
| 241 | Keyset: store.Sealed{Nonce: keysetNonce, Ciphertext: keysetCT}, |
| 242 | Items: []store.RotatedItem{{ |
| 243 | ID: itemID, Version: 2, |
| 244 | Sealed: store.Sealed{Nonce: itemNonce, Ciphertext: itemCT}, |
| 245 | }}, |
| 246 | }); err != nil { |
| 247 | t.Fatalf("RotateVault: %v", err) |
| 248 | } |
| 249 | |
| 250 | // Access dies immediately: the ex-member cannot reach the vault at all. |
| 251 | if rec := as(t, h, guest.cookie, http.MethodGet, |
| 252 | "/api/items?vault="+hex.EncodeToString(owner.vaultID), nil); rec.Code != http.StatusNotFound { |
| 253 | t.Errorf("a removed member still reaches the vault: status %d", rec.Code) |
| 254 | } |
| 255 | |
| 256 | // The epoch moved, so a writer holding the old one is refused rather than |
| 257 | // storing an item sealed under the revoked key. |
| 258 | epoch, err := s.VaultEpoch(ctx, owner.id, owner.vaultID) |
| 259 | if err != nil { |
| 260 | t.Fatal(err) |
| 261 | } |
| 262 | if epoch != 2 { |
| 263 | t.Errorf("epoch %d after one rotation, want 2", epoch) |
| 264 | } |
| 265 | if err := s.RotateVault(ctx, owner.id, owner.vaultID, 1, store.Rotation{ |
| 266 | Keyset: store.Sealed{Nonce: keysetNonce, Ciphertext: keysetCT}, |
| 267 | }); err != store.ErrRotated { |
| 268 | t.Errorf("a rotation against a stale epoch returned %v, want ErrRotated", err) |
| 269 | } |
| 270 | } |
| 271 | |
| 272 | // A rotation that cannot re-seal every item must apply none of it. Half a |
| 273 | // rotation is a vault whose items no longer match the key its members hold. |
| 274 | func TestAPartialRotationIsRolledBack(t *testing.T) { |
| 275 | h, gdb := newAppWithDB(t) |
| 276 | owner := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| 277 | s := store.New(gdb) |
| 278 | ctx := context.Background() |
| 279 | |
| 280 | itemID := newItemID(t) |
| 281 | as(t, h, owner.cookie, http.MethodPost, "/api/items", |
| 282 | sealItem(t, owner, itemID, 1, `{"title":"Real"}`)) |
| 283 | |
| 284 | keysetNonce := randBytes(t, 12) |
| 285 | keysetCT, _ := crypto.Seal(owner.rk, keysetNonce, |
| 286 | crypto.AAD(owner.id, "vault.keyset", owner.vaultID, 1), []byte(`{"k_vault":"new"}`)) |
| 287 | nonce := randBytes(t, 12) |
| 288 | ct, _ := crypto.Seal(randBytes(t, 32), nonce, |
| 289 | crypto.AAD(owner.vaultID, crypto.ObjectItem, itemID, 2), |
| 290 | crypto.Pad([]byte(`{"title":"Real"}`))) |
| 291 | |
| 292 | // Names an item that is not there — as a concurrent delete would. |
| 293 | err := s.RotateVault(ctx, owner.id, owner.vaultID, 1, store.Rotation{ |
| 294 | Keyset: store.Sealed{Nonce: keysetNonce, Ciphertext: keysetCT}, |
| 295 | Items: []store.RotatedItem{ |
| 296 | {ID: itemID, Version: 2, Sealed: store.Sealed{Nonce: nonce, Ciphertext: ct}}, |
| 297 | {ID: newItemID(t), Version: 2, Sealed: store.Sealed{Nonce: nonce, Ciphertext: ct}}, |
| 298 | }, |
| 299 | }) |
| 300 | if err != store.ErrRotated { |
| 301 | t.Fatalf("rotation naming a missing item returned %v, want ErrRotated", err) |
| 302 | } |
| 303 | |
| 304 | // Nothing moved: the epoch is untouched, so the whole thing rolled back. |
| 305 | epoch, err := s.VaultEpoch(ctx, owner.id, owner.vaultID) |
| 306 | if err != nil { |
| 307 | t.Fatal(err) |
| 308 | } |
| 309 | if epoch != 1 { |
| 310 | t.Errorf("epoch %d after a failed rotation, want 1 — it was not rolled back", epoch) |
| 311 | } |
| 312 | } |
| 313 | |