carlos / contra Public

Clone
git clone https://amadan.net/carlos/contra

Plain git — no account needed to clone.

Download

Download this file

1package contratest
2
3import (
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.
24func 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
45func 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".
91func 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.
164func 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.
213func 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.
274func 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