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 "crypto/rand"
5 "encoding/hex"
6 "encoding/json"
7 "net/http"
8 "net/http/httptest"
9 "strings"
10 "testing"
11
12 "github.com/carlosframework/contra/internal/contra/crypto"
13)
14
15// The item API: a sealed-blob store with an owner filter.
16//
17// Nothing here decrypts anything, so what these tests are really about is the
18// filter. A vault that is not yours must be a vault that does not exist —
19// answered 404, never 403, because a 403 confirms the row and that is a fact
20// about somebody else's vault.
21
22// session signs an account in and returns the cookie, so a test can act as
23// that account.
24func session(t *testing.T, h http.Handler, a account) string {
25 t.Helper()
26 rec := doSignIn(t, h, a.email, a.passphrase)
27 if rec.Code != http.StatusOK {
28 t.Fatalf("sign in: status %d", rec.Code)
29 }
30 cookie := rec.Header().Get("Set-Cookie")
31 if cookie == "" {
32 t.Fatal("no session cookie")
33 }
34 return strings.SplitN(cookie, ";", 2)[0]
35}
36
37func as(t *testing.T, h http.Handler, cookie, method, path string, body any) *httptest.ResponseRecorder {
38 t.Helper()
39 var req *http.Request
40 if body != nil {
41 raw, err := json.Marshal(body)
42 if err != nil {
43 t.Fatal(err)
44 }
45 req = httptest.NewRequest(method, path, strings.NewReader(string(raw)))
46 req.Header.Set("Content-Type", "application/json")
47 } else {
48 req = httptest.NewRequest(method, path, nil)
49 }
50 req.Header.Set("Origin", testOrigin)
51 req.Header.Set("Cookie", cookie)
52 rec := httptest.NewRecorder()
53 h.ServeHTTP(rec, req)
54 return rec
55}
56
57// provisioned is a signed-in account with its account keyset and first vault
58// in place — the state the browser reaches at the end of signup.
59type provisioned struct {
60 account
61 cookie string
62 vaultID []byte
63 kVault []byte
64
65 // The account keypair, kept so a sharing test can open a keyset sealed to
66 // this account's public half — which is the only way to prove sharing
67 // works rather than that rows moved.
68 accountScalar []byte
69 accountPublic []byte
70}
71
72func provision(t *testing.T, h http.Handler, email, passphrase string) provisioned {
73 t.Helper()
74 a := doSignup(t, h, email, passphrase)
75 cookie := session(t, h, a)
76
77 p := provisioned{account: a, cookie: cookie}
78 p.vaultID = make([]byte, 16)
79 p.kVault = make([]byte, 32)
80 kMeta := make([]byte, 32)
81 dropBoxSeed := make([]byte, 32)
82 acctSeed := make([]byte, 32)
83 for _, b := range [][]byte{p.vaultID, p.kVault, kMeta, dropBoxSeed, acctSeed} {
84 if _, err := rand.Read(b); err != nil {
85 t.Fatal(err)
86 }
87 }
88 dropBox, err := crypto.DeriveP256(dropBoxSeed)
89 if err != nil {
90 t.Fatal(err)
91 }
92 acctKey, err := crypto.DeriveP256(acctSeed)
93 if err != nil {
94 t.Fatal(err)
95 }
96
97 sealUnder := func(objectType string, objectID []byte, plaintext string) map[string]string {
98 nonce := make([]byte, 12)
99 if _, err := rand.Read(nonce); err != nil {
100 t.Fatal(err)
101 }
102 ct, err := crypto.Seal(a.rk, nonce,
103 crypto.AAD(a.id, objectType, objectID, 1), []byte(plaintext))
104 if err != nil {
105 t.Fatal(err)
106 }
107 return map[string]string{
108 "nonce_hex": hex.EncodeToString(nonce), "ciphertext_hex": hex.EncodeToString(ct)}
109 }
110
111 p.accountScalar = acctKey.Bytes()
112 p.accountPublic = acctKey.PublicKey().Bytes()
113
114 rec := as(t, h, cookie, http.MethodPost, "/api/provision", map[string]any{
115 "public_key_hex": hex.EncodeToString(acctKey.PublicKey().Bytes()),
116 "account_keyset": sealUnder("account.keyset", a.id,
117 `{"d_acct":"00","k_meta":"`+hex.EncodeToString(kMeta)+`"}`),
118 "vault_id_hex": hex.EncodeToString(p.vaultID),
119 "drop_box_public_key_hex": hex.EncodeToString(dropBox.PublicKey().Bytes()),
120 "vault_keyset": sealUnder("vault.keyset", p.vaultID,
121 `{"name":"Personal","k_vault":"`+hex.EncodeToString(p.kVault)+`"}`),
122 })
123 if rec.Code != http.StatusCreated {
124 t.Fatalf("provision: status %d, body %s", rec.Code, rec.Body.String())
125 }
126 return p
127}
128
129// sealItem is the client half: pad into buckets, then seal under K_vault with
130// the AAD binding (vault_id, "item", item_id, version).
131func sealItem(t *testing.T, p provisioned, itemID []byte, version uint64, body string) map[string]any {
132 t.Helper()
133 nonce := make([]byte, 12)
134 if _, err := rand.Read(nonce); err != nil {
135 t.Fatal(err)
136 }
137 ct, err := crypto.Seal(p.kVault, nonce,
138 crypto.AAD(p.vaultID, crypto.ObjectItem, itemID, version), crypto.Pad([]byte(body)))
139 if err != nil {
140 t.Fatal(err)
141 }
142 return map[string]any{
143 "vault_id_hex": hex.EncodeToString(p.vaultID),
144 "item_id_hex": hex.EncodeToString(itemID),
145 "version": version,
146 // The compare-and-swap precondition: what this write replaces.
147 // Ignored on create, where there is nothing to replace.
148 "expected_version": version - 1,
149 "nonce_hex": hex.EncodeToString(nonce),
150 "ciphertext_hex": hex.EncodeToString(ct),
151 }
152}
153
154func newItemID(t *testing.T) []byte {
155 t.Helper()
156 id := make([]byte, 16)
157 if _, err := rand.Read(id); err != nil {
158 t.Fatal(err)
159 }
160 return id
161}
162
163func TestItemsRoundTrip(t *testing.T) {
164 h := newApp(t)
165 p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c")
166
167 const body = `{"title":"Bakery","username":"me","password":"generated-not-real"}`
168 itemID := newItemID(t)
169 if rec := as(t, h, p.cookie, http.MethodPost, "/api/items",
170 sealItem(t, p, itemID, 1, body)); rec.Code != http.StatusCreated {
171 t.Fatalf("create: status %d, body %s", rec.Code, rec.Body.String())
172 }
173
174 var listed struct {
175 Items []struct {
176 ItemIDHex string `json:"item_id_hex"`
177 Version uint64 `json:"version"`
178 NonceHex string `json:"nonce_hex"`
179 CiphertextHex string `json:"ciphertext_hex"`
180 } `json:"items"`
181 }
182 rec := as(t, h, p.cookie, http.MethodGet,
183 "/api/items?vault="+hex.EncodeToString(p.vaultID), nil)
184 if rec.Code != http.StatusOK {
185 t.Fatalf("list: status %d", rec.Code)
186 }
187 decode(t, rec, &listed)
188 if len(listed.Items) != 1 {
189 t.Fatalf("listed %d items, want 1", len(listed.Items))
190 }
191
192 // And it opens, back in the client, to exactly what went in.
193 row := listed.Items[0]
194 nonce, _ := hex.DecodeString(row.NonceHex)
195 ct, _ := hex.DecodeString(row.CiphertextHex)
196 id, _ := hex.DecodeString(row.ItemIDHex)
197 plain, err := crypto.Open(p.kVault, nonce,
198 crypto.AAD(p.vaultID, crypto.ObjectItem, id, row.Version), ct)
199 if err != nil {
200 t.Fatalf("the stored item does not open: %v", err)
201 }
202 unpadded, err := crypto.Unpad(plain)
203 if err != nil {
204 t.Fatalf("unpad: %v", err)
205 }
206 if string(unpadded) != body {
207 t.Errorf("round trip\n got %s\n want %s", unpadded, body)
208 }
209
210 // Update bumps the version, which is in the AAD.
211 const edited = `{"title":"Bakery","username":"me2","password":"generated-not-real"}`
212 if rec := as(t, h, p.cookie, http.MethodPut, "/api/items",
213 sealItem(t, p, itemID, 2, edited)); rec.Code != http.StatusOK {
214 t.Fatalf("update: status %d, body %s", rec.Code, rec.Body.String())
215 }
216
217 // Delete leaves nothing.
218 if rec := as(t, h, p.cookie, http.MethodPost, "/api/items/delete", map[string]string{
219 "vault_id_hex": hex.EncodeToString(p.vaultID),
220 "item_id_hex": hex.EncodeToString(itemID),
221 }); rec.Code != http.StatusNoContent {
222 t.Fatalf("delete: status %d", rec.Code)
223 }
224 rec = as(t, h, p.cookie, http.MethodGet,
225 "/api/items?vault="+hex.EncodeToString(p.vaultID), nil)
226 decode(t, rec, &listed)
227 if len(listed.Items) != 0 {
228 t.Errorf("after delete, %d items remain", len(listed.Items))
229 }
230}
231
232// Two accounts, and neither can see or touch the other's vault. This is the
233// test that would catch the owner filter being dropped in a refactor.
234func TestAnotherAccountsVaultDoesNotExist(t *testing.T) {
235 h := newApp(t)
236 mine := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c")
237 theirs := provision(t, h, "other@example.invalid", "a different generated one 4d2e9b")
238
239 itemID := newItemID(t)
240 if rec := as(t, h, mine.cookie, http.MethodPost, "/api/items",
241 sealItem(t, mine, itemID, 1, `{"title":"Mine"}`)); rec.Code != http.StatusCreated {
242 t.Fatalf("create: status %d", rec.Code)
243 }
244
245 // Listing somebody else's vault: not found, not forbidden.
246 rec := as(t, h, theirs.cookie, http.MethodGet,
247 "/api/items?vault="+hex.EncodeToString(mine.vaultID), nil)
248 if rec.Code != http.StatusNotFound {
249 t.Errorf("listing another account's vault: status %d, want 404", rec.Code)
250 }
251
252 // Writing into it: also not found.
253 body := sealItem(t, theirs, newItemID(t), 1, `{"title":"Theirs"}`)
254 body["vault_id_hex"] = hex.EncodeToString(mine.vaultID)
255 if rec := as(t, h, theirs.cookie, http.MethodPost, "/api/items", body); rec.Code != http.StatusNotFound {
256 t.Errorf("writing into another account's vault: status %d, want 404", rec.Code)
257 }
258
259 // Deleting from it: not found, and the item survives.
260 if rec := as(t, h, theirs.cookie, http.MethodPost, "/api/items/delete", map[string]string{
261 "vault_id_hex": hex.EncodeToString(mine.vaultID),
262 "item_id_hex": hex.EncodeToString(itemID),
263 }); rec.Code != http.StatusNotFound {
264 t.Errorf("deleting from another account's vault: status %d, want 404", rec.Code)
265 }
266 var listed struct {
267 Items []json.RawMessage `json:"items"`
268 }
269 decode(t, as(t, h, mine.cookie, http.MethodGet,
270 "/api/items?vault="+hex.EncodeToString(mine.vaultID), nil), &listed)
271 if len(listed.Items) != 1 {
272 t.Errorf("the item did not survive a foreign delete: %d left", len(listed.Items))
273 }
274}
275
276func TestVaultAPIRefusesAStranger(t *testing.T) {
277 h := newApp(t)
278 for _, path := range []string{"/api/bootstrap", "/api/items?vault=00"} {
279 rec := get(t, h, path)
280 if rec.Code == http.StatusOK {
281 t.Errorf("GET %s served a signed-out request", path)
282 }
283 }
284}
285
286// The quantisation rule holds at the API boundary, not only in the store.
287func TestAPIRefusesAnUnquantisedItem(t *testing.T) {
288 h := newApp(t)
289 p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c")
290
291 body := sealItem(t, p, newItemID(t), 1, `{"title":"x"}`)
292 // 116 bytes: a real ciphertext of an unpadded plaintext.
293 body["ciphertext_hex"] = strings.Repeat("ab", 116)
294 if rec := as(t, h, p.cookie, http.MethodPost, "/api/items", body); rec.Code != http.StatusBadRequest {
295 t.Errorf("unquantised item: status %d, want 400", rec.Code)
296 }
297}
298
299// Bootstrap hands back ciphertext and nothing else. If a key ever appears in
300// this response, it appears here first.
301func TestBootstrapCarriesNoKeys(t *testing.T) {
302 h := newApp(t)
303 p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c")
304
305 rec := as(t, h, p.cookie, http.MethodGet, "/api/bootstrap", nil)
306 if rec.Code != http.StatusOK {
307 t.Fatalf("bootstrap: status %d", rec.Code)
308 }
309 raw := rec.Body.String()
310 for name, secret := range map[string][]byte{
311 "RK": p.rk, "K_vault": p.kVault,
312 "the Account Key": p.accountKey, "the recovery code": p.recovery,
313 } {
314 if strings.Contains(raw, hex.EncodeToString(secret)) {
315 t.Errorf("/api/bootstrap returned %s", name)
316 }
317 }
318}
319