| 1 | package contratest |
| 2 | |
| 3 | import ( |
| 4 | "encoding/hex" |
| 5 | "net/http" |
| 6 | "testing" |
| 7 | |
| 8 | "github.com/carlosframework/contra/internal/contra/crypto" |
| 9 | ) |
| 10 | |
| 11 | // The sharing API over real HTTP, driven the way the browser drives it: |
| 12 | // look the recipient's public key up by account id, seal the keyset to it |
| 13 | // with the real ECDH, post it, and have the recipient open what comes back. |
| 14 | // |
| 15 | // That last part is the point. A test that posts opaque bytes and reads them |
| 16 | // back proves the rows move; only sealing with one key and opening with the |
| 17 | // other proves the sharing actually works — which is the class of bug that |
| 18 | // bit us when wrap ids were minted server-side. |
| 19 | |
| 20 | func accountPublicKey(t *testing.T, h http.Handler, cookie string, id []byte) []byte { |
| 21 | t.Helper() |
| 22 | rec := as(t, h, cookie, http.MethodGet, |
| 23 | "/api/account-key?account="+hex.EncodeToString(id), nil) |
| 24 | if rec.Code != http.StatusOK { |
| 25 | t.Fatalf("account-key: status %d, body %s", rec.Code, rec.Body.String()) |
| 26 | } |
| 27 | var out struct { |
| 28 | PublicKeyHex string `json:"public_key_hex"` |
| 29 | } |
| 30 | decode(t, rec, &out) |
| 31 | key, err := hex.DecodeString(out.PublicKeyHex) |
| 32 | if err != nil || len(key) != crypto.PublicKeyLen { |
| 33 | t.Fatalf("account-key returned a bad public key: %q", out.PublicKeyHex) |
| 34 | } |
| 35 | return key |
| 36 | } |
| 37 | |
| 38 | func TestSharingSealsToTheRecipientAndOpensForThem(t *testing.T) { |
| 39 | h := newApp(t) |
| 40 | owner := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| 41 | guest := provision(t, h, "guest@example.invalid", "a different generated one 4d2e9b") |
| 42 | |
| 43 | // The owner looks the recipient up by the account id they were given. |
| 44 | guestPublic := accountPublicKey(t, h, owner.cookie, guest.id) |
| 45 | |
| 46 | // And seals the vault keyset to it, in the browser's shape: ECIES with |
| 47 | // the AAD naming the vault and the recipient. |
| 48 | keyset := []byte(`{"name":"Household","k_vault":"` + |
| 49 | hex.EncodeToString(owner.kVault) + `"}`) |
| 50 | aad := crypto.AAD(owner.vaultID, "vault.keyset", guest.id, 1) |
| 51 | envelope, err := crypto.SealTo(guestPublic, aad, keyset) |
| 52 | if err != nil { |
| 53 | t.Fatalf("SealTo: %v", err) |
| 54 | } |
| 55 | |
| 56 | rec := as(t, h, owner.cookie, http.MethodPost, "/api/members", map[string]any{ |
| 57 | "vault_id_hex": hex.EncodeToString(owner.vaultID), |
| 58 | "account_id_hex": hex.EncodeToString(guest.id), |
| 59 | "ephemeral_public_key_hex": hex.EncodeToString(envelope[:crypto.PublicKeyLen]), |
| 60 | "sealed": map[string]string{ |
| 61 | "nonce_hex": hex.EncodeToString( |
| 62 | envelope[crypto.PublicKeyLen : crypto.PublicKeyLen+crypto.NonceLen]), |
| 63 | "ciphertext_hex": hex.EncodeToString(envelope[crypto.PublicKeyLen+crypto.NonceLen:]), |
| 64 | }, |
| 65 | }) |
| 66 | if rec.Code != http.StatusCreated { |
| 67 | t.Fatalf("share: status %d, body %s", rec.Code, rec.Body.String()) |
| 68 | } |
| 69 | |
| 70 | // The recipient's bootstrap carries their wrap — and it opens under their |
| 71 | // account private key, which is the whole claim. |
| 72 | var boot struct { |
| 73 | Vaults []struct { |
| 74 | VaultIDHex string `json:"vault_id_hex"` |
| 75 | Owned bool `json:"owned"` |
| 76 | EphemeralPublicKeyHex string `json:"ephemeral_public_key_hex"` |
| 77 | Keyset struct { |
| 78 | NonceHex string `json:"nonce_hex"` |
| 79 | CiphertextHex string `json:"ciphertext_hex"` |
| 80 | } `json:"keyset"` |
| 81 | } `json:"vaults"` |
| 82 | } |
| 83 | decode(t, as(t, h, guest.cookie, http.MethodGet, "/api/bootstrap", nil), &boot) |
| 84 | |
| 85 | for _, vt := range boot.Vaults { |
| 86 | if vt.VaultIDHex != hex.EncodeToString(owner.vaultID) || vt.Owned { |
| 87 | continue |
| 88 | } |
| 89 | eph, _ := hex.DecodeString(vt.EphemeralPublicKeyHex) |
| 90 | nonce, _ := hex.DecodeString(vt.Keyset.NonceHex) |
| 91 | ct, _ := hex.DecodeString(vt.Keyset.CiphertextHex) |
| 92 | |
| 93 | rebuilt := append(append(append([]byte{}, eph...), nonce...), ct...) |
| 94 | got, err := crypto.OpenFrom(guest.accountScalar, guest.accountPublic, aad, rebuilt) |
| 95 | if err != nil { |
| 96 | t.Fatalf("the member cannot open the keyset sealed to them: %v", err) |
| 97 | } |
| 98 | if string(got) != string(keyset) { |
| 99 | t.Errorf("the member opened the wrong keyset\n got %s\n want %s", got, keyset) |
| 100 | } |
| 101 | return |
| 102 | } |
| 103 | t.Fatal("the shared vault is not in the member's bootstrap") |
| 104 | } |
| 105 | |
| 106 | // Sharing needs no unlock on the recipient's side and no key on the server's, |
| 107 | // which is what the agent surface depends on later. The lookup is by account |
| 108 | // id and NEVER by address — an address-to-account endpoint would be the |
| 109 | // existence oracle the blind index exists to prevent. |
| 110 | func TestThereIsNoWayToLookAnAccountUpByAddress(t *testing.T) { |
| 111 | h := newApp(t) |
| 112 | owner := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| 113 | provision(t, h, "guest@example.invalid", "a different generated one 4d2e9b") |
| 114 | |
| 115 | for _, q := range []string{ |
| 116 | "/api/account-key?account=guest@example.invalid", |
| 117 | "/api/account-key?email=guest@example.invalid", |
| 118 | "/api/account-key", |
| 119 | } { |
| 120 | if rec := as(t, h, owner.cookie, http.MethodGet, q, nil); rec.Code != http.StatusNotFound { |
| 121 | t.Errorf("%s: status %d, want 404", q, rec.Code) |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | // An id that does not exist is the same answer as one that does not have |
| 126 | // a public key yet. |
| 127 | if rec := as(t, h, owner.cookie, http.MethodGet, |
| 128 | "/api/account-key?account="+hex.EncodeToString(randBytes(t, 16)), nil); rec.Code != http.StatusNotFound { |
| 129 | t.Errorf("unknown account id: status %d, want 404", rec.Code) |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | func TestSharingRoutesRefuseAStranger(t *testing.T) { |
| 134 | h := newApp(t) |
| 135 | owner := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| 136 | |
| 137 | for _, c := range []struct { |
| 138 | method string |
| 139 | path string |
| 140 | }{ |
| 141 | {http.MethodGet, "/api/account-key?account=" + hex.EncodeToString(owner.id)}, |
| 142 | {http.MethodGet, "/api/members?vault=" + hex.EncodeToString(owner.vaultID)}, |
| 143 | {http.MethodPost, "/api/members"}, |
| 144 | {http.MethodPost, "/api/vault/rotate"}, |
| 145 | } { |
| 146 | rec := as(t, h, "", c.method, c.path, map[string]string{}) |
| 147 | if rec.Code == http.StatusOK || rec.Code == http.StatusCreated { |
| 148 | t.Errorf("%s %s served a stranger: status %d", c.method, c.path, rec.Code) |
| 149 | } |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | // Sharing a vault with yourself would create a member row shadowing the |
| 154 | // owner's own copy, and rotation would then have two answers for which keyset |
| 155 | // is yours. |
| 156 | func TestYouCannotShareAVaultWithYourself(t *testing.T) { |
| 157 | h := newApp(t) |
| 158 | owner := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| 159 | ownPublic := accountPublicKey(t, h, owner.cookie, owner.id) |
| 160 | |
| 161 | envelope, err := crypto.SealTo(ownPublic, |
| 162 | crypto.AAD(owner.vaultID, "vault.keyset", owner.id, 1), []byte(`{"k":"x"}`)) |
| 163 | if err != nil { |
| 164 | t.Fatal(err) |
| 165 | } |
| 166 | rec := as(t, h, owner.cookie, http.MethodPost, "/api/members", map[string]any{ |
| 167 | "vault_id_hex": hex.EncodeToString(owner.vaultID), |
| 168 | "account_id_hex": hex.EncodeToString(owner.id), |
| 169 | "ephemeral_public_key_hex": hex.EncodeToString(envelope[:crypto.PublicKeyLen]), |
| 170 | "sealed": map[string]string{ |
| 171 | "nonce_hex": hex.EncodeToString( |
| 172 | envelope[crypto.PublicKeyLen : crypto.PublicKeyLen+crypto.NonceLen]), |
| 173 | "ciphertext_hex": hex.EncodeToString(envelope[crypto.PublicKeyLen+crypto.NonceLen:]), |
| 174 | }, |
| 175 | }) |
| 176 | if rec.Code != http.StatusBadRequest { |
| 177 | t.Errorf("sharing with yourself: status %d, want 400", rec.Code) |
| 178 | } |
| 179 | } |
| 180 | |