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 "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
20func 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
38func 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.
110func 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
133func 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.
156func 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