package contratest import ( "encoding/hex" "net/http" "testing" "github.com/carlosframework/contra/internal/contra/crypto" ) // The sharing API over real HTTP, driven the way the browser drives it: // look the recipient's public key up by account id, seal the keyset to it // with the real ECDH, post it, and have the recipient open what comes back. // // That last part is the point. A test that posts opaque bytes and reads them // back proves the rows move; only sealing with one key and opening with the // other proves the sharing actually works — which is the class of bug that // bit us when wrap ids were minted server-side. func accountPublicKey(t *testing.T, h http.Handler, cookie string, id []byte) []byte { t.Helper() rec := as(t, h, cookie, http.MethodGet, "/api/account-key?account="+hex.EncodeToString(id), nil) if rec.Code != http.StatusOK { t.Fatalf("account-key: status %d, body %s", rec.Code, rec.Body.String()) } var out struct { PublicKeyHex string `json:"public_key_hex"` } decode(t, rec, &out) key, err := hex.DecodeString(out.PublicKeyHex) if err != nil || len(key) != crypto.PublicKeyLen { t.Fatalf("account-key returned a bad public key: %q", out.PublicKeyHex) } return key } func TestSharingSealsToTheRecipientAndOpensForThem(t *testing.T) { h := newApp(t) owner := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") guest := provision(t, h, "guest@example.invalid", "a different generated one 4d2e9b") // The owner looks the recipient up by the account id they were given. guestPublic := accountPublicKey(t, h, owner.cookie, guest.id) // And seals the vault keyset to it, in the browser's shape: ECIES with // the AAD naming the vault and the recipient. keyset := []byte(`{"name":"Household","k_vault":"` + hex.EncodeToString(owner.kVault) + `"}`) aad := crypto.AAD(owner.vaultID, "vault.keyset", guest.id, 1) envelope, err := crypto.SealTo(guestPublic, aad, keyset) if err != nil { t.Fatalf("SealTo: %v", err) } rec := as(t, h, owner.cookie, http.MethodPost, "/api/members", map[string]any{ "vault_id_hex": hex.EncodeToString(owner.vaultID), "account_id_hex": hex.EncodeToString(guest.id), "ephemeral_public_key_hex": hex.EncodeToString(envelope[:crypto.PublicKeyLen]), "sealed": map[string]string{ "nonce_hex": hex.EncodeToString( envelope[crypto.PublicKeyLen : crypto.PublicKeyLen+crypto.NonceLen]), "ciphertext_hex": hex.EncodeToString(envelope[crypto.PublicKeyLen+crypto.NonceLen:]), }, }) if rec.Code != http.StatusCreated { t.Fatalf("share: status %d, body %s", rec.Code, rec.Body.String()) } // The recipient's bootstrap carries their wrap — and it opens under their // account private key, which is the whole claim. var boot struct { Vaults []struct { VaultIDHex string `json:"vault_id_hex"` Owned bool `json:"owned"` EphemeralPublicKeyHex string `json:"ephemeral_public_key_hex"` Keyset struct { NonceHex string `json:"nonce_hex"` CiphertextHex string `json:"ciphertext_hex"` } `json:"keyset"` } `json:"vaults"` } decode(t, as(t, h, guest.cookie, http.MethodGet, "/api/bootstrap", nil), &boot) for _, vt := range boot.Vaults { if vt.VaultIDHex != hex.EncodeToString(owner.vaultID) || vt.Owned { continue } eph, _ := hex.DecodeString(vt.EphemeralPublicKeyHex) nonce, _ := hex.DecodeString(vt.Keyset.NonceHex) ct, _ := hex.DecodeString(vt.Keyset.CiphertextHex) rebuilt := append(append(append([]byte{}, eph...), nonce...), ct...) got, err := crypto.OpenFrom(guest.accountScalar, guest.accountPublic, aad, rebuilt) if err != nil { t.Fatalf("the member cannot open the keyset sealed to them: %v", err) } if string(got) != string(keyset) { t.Errorf("the member opened the wrong keyset\n got %s\n want %s", got, keyset) } return } t.Fatal("the shared vault is not in the member's bootstrap") } // Sharing needs no unlock on the recipient's side and no key on the server's, // which is what the agent surface depends on later. The lookup is by account // id and NEVER by address — an address-to-account endpoint would be the // existence oracle the blind index exists to prevent. func TestThereIsNoWayToLookAnAccountUpByAddress(t *testing.T) { h := newApp(t) owner := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") provision(t, h, "guest@example.invalid", "a different generated one 4d2e9b") for _, q := range []string{ "/api/account-key?account=guest@example.invalid", "/api/account-key?email=guest@example.invalid", "/api/account-key", } { if rec := as(t, h, owner.cookie, http.MethodGet, q, nil); rec.Code != http.StatusNotFound { t.Errorf("%s: status %d, want 404", q, rec.Code) } } // An id that does not exist is the same answer as one that does not have // a public key yet. if rec := as(t, h, owner.cookie, http.MethodGet, "/api/account-key?account="+hex.EncodeToString(randBytes(t, 16)), nil); rec.Code != http.StatusNotFound { t.Errorf("unknown account id: status %d, want 404", rec.Code) } } func TestSharingRoutesRefuseAStranger(t *testing.T) { h := newApp(t) owner := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") for _, c := range []struct { method string path string }{ {http.MethodGet, "/api/account-key?account=" + hex.EncodeToString(owner.id)}, {http.MethodGet, "/api/members?vault=" + hex.EncodeToString(owner.vaultID)}, {http.MethodPost, "/api/members"}, {http.MethodPost, "/api/vault/rotate"}, } { rec := as(t, h, "", c.method, c.path, map[string]string{}) if rec.Code == http.StatusOK || rec.Code == http.StatusCreated { t.Errorf("%s %s served a stranger: status %d", c.method, c.path, rec.Code) } } } // Sharing a vault with yourself would create a member row shadowing the // owner's own copy, and rotation would then have two answers for which keyset // is yours. func TestYouCannotShareAVaultWithYourself(t *testing.T) { h := newApp(t) owner := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") ownPublic := accountPublicKey(t, h, owner.cookie, owner.id) envelope, err := crypto.SealTo(ownPublic, crypto.AAD(owner.vaultID, "vault.keyset", owner.id, 1), []byte(`{"k":"x"}`)) if err != nil { t.Fatal(err) } rec := as(t, h, owner.cookie, http.MethodPost, "/api/members", map[string]any{ "vault_id_hex": hex.EncodeToString(owner.vaultID), "account_id_hex": hex.EncodeToString(owner.id), "ephemeral_public_key_hex": hex.EncodeToString(envelope[:crypto.PublicKeyLen]), "sealed": map[string]string{ "nonce_hex": hex.EncodeToString( envelope[crypto.PublicKeyLen : crypto.PublicKeyLen+crypto.NonceLen]), "ciphertext_hex": hex.EncodeToString(envelope[crypto.PublicKeyLen+crypto.NonceLen:]), }, }) if rec.Code != http.StatusBadRequest { t.Errorf("sharing with yourself: status %d, want 400", rec.Code) } }