package contratest import ( "crypto/rand" "encoding/hex" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "github.com/carlosframework/contra/internal/contra/crypto" ) // The item API: a sealed-blob store with an owner filter. // // Nothing here decrypts anything, so what these tests are really about is the // filter. A vault that is not yours must be a vault that does not exist — // answered 404, never 403, because a 403 confirms the row and that is a fact // about somebody else's vault. // session signs an account in and returns the cookie, so a test can act as // that account. func session(t *testing.T, h http.Handler, a account) string { t.Helper() rec := doSignIn(t, h, a.email, a.passphrase) if rec.Code != http.StatusOK { t.Fatalf("sign in: status %d", rec.Code) } cookie := rec.Header().Get("Set-Cookie") if cookie == "" { t.Fatal("no session cookie") } return strings.SplitN(cookie, ";", 2)[0] } func as(t *testing.T, h http.Handler, cookie, method, path string, body any) *httptest.ResponseRecorder { t.Helper() var req *http.Request if body != nil { raw, err := json.Marshal(body) if err != nil { t.Fatal(err) } req = httptest.NewRequest(method, path, strings.NewReader(string(raw))) req.Header.Set("Content-Type", "application/json") } else { req = httptest.NewRequest(method, path, nil) } req.Header.Set("Origin", testOrigin) req.Header.Set("Cookie", cookie) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } // provisioned is a signed-in account with its account keyset and first vault // in place — the state the browser reaches at the end of signup. type provisioned struct { account cookie string vaultID []byte kVault []byte // The account keypair, kept so a sharing test can open a keyset sealed to // this account's public half — which is the only way to prove sharing // works rather than that rows moved. accountScalar []byte accountPublic []byte } func provision(t *testing.T, h http.Handler, email, passphrase string) provisioned { t.Helper() a := doSignup(t, h, email, passphrase) cookie := session(t, h, a) p := provisioned{account: a, cookie: cookie} p.vaultID = make([]byte, 16) p.kVault = make([]byte, 32) kMeta := make([]byte, 32) dropBoxSeed := make([]byte, 32) acctSeed := make([]byte, 32) for _, b := range [][]byte{p.vaultID, p.kVault, kMeta, dropBoxSeed, acctSeed} { if _, err := rand.Read(b); err != nil { t.Fatal(err) } } dropBox, err := crypto.DeriveP256(dropBoxSeed) if err != nil { t.Fatal(err) } acctKey, err := crypto.DeriveP256(acctSeed) if err != nil { t.Fatal(err) } sealUnder := func(objectType string, objectID []byte, plaintext string) map[string]string { nonce := make([]byte, 12) if _, err := rand.Read(nonce); err != nil { t.Fatal(err) } ct, err := crypto.Seal(a.rk, nonce, crypto.AAD(a.id, objectType, objectID, 1), []byte(plaintext)) if err != nil { t.Fatal(err) } return map[string]string{ "nonce_hex": hex.EncodeToString(nonce), "ciphertext_hex": hex.EncodeToString(ct)} } p.accountScalar = acctKey.Bytes() p.accountPublic = acctKey.PublicKey().Bytes() rec := as(t, h, cookie, http.MethodPost, "/api/provision", map[string]any{ "public_key_hex": hex.EncodeToString(acctKey.PublicKey().Bytes()), "account_keyset": sealUnder("account.keyset", a.id, `{"d_acct":"00","k_meta":"`+hex.EncodeToString(kMeta)+`"}`), "vault_id_hex": hex.EncodeToString(p.vaultID), "drop_box_public_key_hex": hex.EncodeToString(dropBox.PublicKey().Bytes()), "vault_keyset": sealUnder("vault.keyset", p.vaultID, `{"name":"Personal","k_vault":"`+hex.EncodeToString(p.kVault)+`"}`), }) if rec.Code != http.StatusCreated { t.Fatalf("provision: status %d, body %s", rec.Code, rec.Body.String()) } return p } // sealItem is the client half: pad into buckets, then seal under K_vault with // the AAD binding (vault_id, "item", item_id, version). func sealItem(t *testing.T, p provisioned, itemID []byte, version uint64, body string) map[string]any { t.Helper() nonce := make([]byte, 12) if _, err := rand.Read(nonce); err != nil { t.Fatal(err) } ct, err := crypto.Seal(p.kVault, nonce, crypto.AAD(p.vaultID, crypto.ObjectItem, itemID, version), crypto.Pad([]byte(body))) if err != nil { t.Fatal(err) } return map[string]any{ "vault_id_hex": hex.EncodeToString(p.vaultID), "item_id_hex": hex.EncodeToString(itemID), "version": version, // The compare-and-swap precondition: what this write replaces. // Ignored on create, where there is nothing to replace. "expected_version": version - 1, "nonce_hex": hex.EncodeToString(nonce), "ciphertext_hex": hex.EncodeToString(ct), } } func newItemID(t *testing.T) []byte { t.Helper() id := make([]byte, 16) if _, err := rand.Read(id); err != nil { t.Fatal(err) } return id } func TestItemsRoundTrip(t *testing.T) { h := newApp(t) p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") const body = `{"title":"Bakery","username":"me","password":"generated-not-real"}` itemID := newItemID(t) if rec := as(t, h, p.cookie, http.MethodPost, "/api/items", sealItem(t, p, itemID, 1, body)); rec.Code != http.StatusCreated { t.Fatalf("create: status %d, body %s", rec.Code, rec.Body.String()) } var listed struct { Items []struct { ItemIDHex string `json:"item_id_hex"` Version uint64 `json:"version"` NonceHex string `json:"nonce_hex"` CiphertextHex string `json:"ciphertext_hex"` } `json:"items"` } rec := as(t, h, p.cookie, http.MethodGet, "/api/items?vault="+hex.EncodeToString(p.vaultID), nil) if rec.Code != http.StatusOK { t.Fatalf("list: status %d", rec.Code) } decode(t, rec, &listed) if len(listed.Items) != 1 { t.Fatalf("listed %d items, want 1", len(listed.Items)) } // And it opens, back in the client, to exactly what went in. row := listed.Items[0] nonce, _ := hex.DecodeString(row.NonceHex) ct, _ := hex.DecodeString(row.CiphertextHex) id, _ := hex.DecodeString(row.ItemIDHex) plain, err := crypto.Open(p.kVault, nonce, crypto.AAD(p.vaultID, crypto.ObjectItem, id, row.Version), ct) if err != nil { t.Fatalf("the stored item does not open: %v", err) } unpadded, err := crypto.Unpad(plain) if err != nil { t.Fatalf("unpad: %v", err) } if string(unpadded) != body { t.Errorf("round trip\n got %s\n want %s", unpadded, body) } // Update bumps the version, which is in the AAD. const edited = `{"title":"Bakery","username":"me2","password":"generated-not-real"}` if rec := as(t, h, p.cookie, http.MethodPut, "/api/items", sealItem(t, p, itemID, 2, edited)); rec.Code != http.StatusOK { t.Fatalf("update: status %d, body %s", rec.Code, rec.Body.String()) } // Delete leaves nothing. if rec := as(t, h, p.cookie, http.MethodPost, "/api/items/delete", map[string]string{ "vault_id_hex": hex.EncodeToString(p.vaultID), "item_id_hex": hex.EncodeToString(itemID), }); rec.Code != http.StatusNoContent { t.Fatalf("delete: status %d", rec.Code) } rec = as(t, h, p.cookie, http.MethodGet, "/api/items?vault="+hex.EncodeToString(p.vaultID), nil) decode(t, rec, &listed) if len(listed.Items) != 0 { t.Errorf("after delete, %d items remain", len(listed.Items)) } } // Two accounts, and neither can see or touch the other's vault. This is the // test that would catch the owner filter being dropped in a refactor. func TestAnotherAccountsVaultDoesNotExist(t *testing.T) { h := newApp(t) mine := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") theirs := provision(t, h, "other@example.invalid", "a different generated one 4d2e9b") itemID := newItemID(t) if rec := as(t, h, mine.cookie, http.MethodPost, "/api/items", sealItem(t, mine, itemID, 1, `{"title":"Mine"}`)); rec.Code != http.StatusCreated { t.Fatalf("create: status %d", rec.Code) } // Listing somebody else's vault: not found, not forbidden. rec := as(t, h, theirs.cookie, http.MethodGet, "/api/items?vault="+hex.EncodeToString(mine.vaultID), nil) if rec.Code != http.StatusNotFound { t.Errorf("listing another account's vault: status %d, want 404", rec.Code) } // Writing into it: also not found. body := sealItem(t, theirs, newItemID(t), 1, `{"title":"Theirs"}`) body["vault_id_hex"] = hex.EncodeToString(mine.vaultID) if rec := as(t, h, theirs.cookie, http.MethodPost, "/api/items", body); rec.Code != http.StatusNotFound { t.Errorf("writing into another account's vault: status %d, want 404", rec.Code) } // Deleting from it: not found, and the item survives. if rec := as(t, h, theirs.cookie, http.MethodPost, "/api/items/delete", map[string]string{ "vault_id_hex": hex.EncodeToString(mine.vaultID), "item_id_hex": hex.EncodeToString(itemID), }); rec.Code != http.StatusNotFound { t.Errorf("deleting from another account's vault: status %d, want 404", rec.Code) } var listed struct { Items []json.RawMessage `json:"items"` } decode(t, as(t, h, mine.cookie, http.MethodGet, "/api/items?vault="+hex.EncodeToString(mine.vaultID), nil), &listed) if len(listed.Items) != 1 { t.Errorf("the item did not survive a foreign delete: %d left", len(listed.Items)) } } func TestVaultAPIRefusesAStranger(t *testing.T) { h := newApp(t) for _, path := range []string{"/api/bootstrap", "/api/items?vault=00"} { rec := get(t, h, path) if rec.Code == http.StatusOK { t.Errorf("GET %s served a signed-out request", path) } } } // The quantisation rule holds at the API boundary, not only in the store. func TestAPIRefusesAnUnquantisedItem(t *testing.T) { h := newApp(t) p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") body := sealItem(t, p, newItemID(t), 1, `{"title":"x"}`) // 116 bytes: a real ciphertext of an unpadded plaintext. body["ciphertext_hex"] = strings.Repeat("ab", 116) if rec := as(t, h, p.cookie, http.MethodPost, "/api/items", body); rec.Code != http.StatusBadRequest { t.Errorf("unquantised item: status %d, want 400", rec.Code) } } // Bootstrap hands back ciphertext and nothing else. If a key ever appears in // this response, it appears here first. func TestBootstrapCarriesNoKeys(t *testing.T) { h := newApp(t) p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") rec := as(t, h, p.cookie, http.MethodGet, "/api/bootstrap", nil) if rec.Code != http.StatusOK { t.Fatalf("bootstrap: status %d", rec.Code) } raw := rec.Body.String() for name, secret := range map[string][]byte{ "RK": p.rk, "K_vault": p.kVault, "the Account Key": p.accountKey, "the recovery code": p.recovery, } { if strings.Contains(raw, hex.EncodeToString(secret)) { t.Errorf("/api/bootstrap returned %s", name) } } }