| 1 | package contratest |
| 2 | |
| 3 | import ( |
| 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. |
| 24 | func 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 | |
| 37 | func 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. |
| 59 | type 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 | |
| 72 | func 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). |
| 131 | func 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 | |
| 154 | func 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 | |
| 163 | func 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. |
| 234 | func 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 | |
| 276 | func 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. |
| 287 | func 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. |
| 301 | func 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 | |