carlos / contra Public

Clone
git clone https://amadan.net/carlos/contra

Plain git — no account needed to clone.

Download

Download this file

1// The sharing API: who a vault is shared with, and the rotation that taking
2// somebody out of it really is.
3//
4// # How you find somebody to share with
5//
6// By account id, which the recipient reads off their own screen and gives you
7// however people give each other things. NOT by email address.
8//
9// That is not squeamishness. An endpoint that turned an address into an
10// account would be an existence oracle for every address anybody cared to
11// try, and the blind index exists precisely so the server cannot answer that
12// question. An account id is 128 bits of CSPRNG, so "does this id exist" is
13// only answerable by somebody who already has it — which is the same argument
14// that lets the handoff code be eight characters.
15//
16// What the lookup returns is a public key. There is nothing else it could
17// return: the server holds no name, no address and no key for that account.
18
19package vault
20
21import (
22 "bytes"
23 "encoding/hex"
24 "errors"
25 "net/http"
26
27 "gorm.io/gorm"
28
29 "github.com/carlosframework/contra/internal/contra/crypto"
30 "github.com/carlosframework/contra/internal/contra/store"
31)
32
33// AccountKey answers the public key to seal a vault keyset to.
34//
35// Signed-in only, and by id only. It confirms that an account id exists,
36// which is a fact worth exactly as much as the id already was.
37func (v *Vault) AccountKey(w http.ResponseWriter, r *http.Request) {
38 if _, ok := accountID(r); !ok {
39 notFound(w)
40 return
41 }
42 id, err := hex.DecodeString(r.URL.Query().Get("account"))
43 if err != nil || len(id) != 16 {
44 notFound(w)
45 return
46 }
47 acct, err := v.store.Account(r.Context(), id)
48 if err != nil || len(acct.PublicKey) != crypto.PublicKeyLen {
49 // An account that has not provisioned yet has no public key, and so
50 // cannot be shared with. Same answer as one that does not exist.
51 notFound(w)
52 return
53 }
54 writeJSON(w, http.StatusOK, map[string]string{
55 "account_id_hex": hex.EncodeToString(acct.ID),
56 "public_key_hex": hex.EncodeToString(acct.PublicKey),
57 })
58}
59
60// Members lists who a vault is shared with. Owner-only: a member learning the
61// whole membership list is a disclosure nobody asked for.
62func (v *Vault) Members(w http.ResponseWriter, r *http.Request) {
63 acct, ok := accountID(r)
64 if !ok {
65 notFound(w)
66 return
67 }
68 vaultID, err := hex.DecodeString(r.URL.Query().Get("vault"))
69 if err != nil || len(vaultID) != 16 {
70 notFound(w)
71 return
72 }
73 members, err := v.store.Members(r.Context(), acct, vaultID)
74 if err != nil {
75 notFound(w)
76 return
77 }
78 out := []map[string]string{}
79 for _, m := range members {
80 // The account id and nothing else. There is no name to send.
81 out = append(out, map[string]string{
82 "account_id_hex": hex.EncodeToString(m.AccountID),
83 })
84 }
85 writeJSON(w, http.StatusOK, map[string]any{"members": out})
86}
87
88// AddMember shares a vault. Owner-only.
89//
90// The keyset arrives already sealed to the recipient's account public key by
91// ephemeral-static ECDH — done in the owner's browser, where the keyset can
92// actually be opened. The server stores an envelope it has no key for, which
93// is why sharing needs no unlock on the recipient's side and no trust on the
94// server's.
95func (v *Vault) AddMember(w http.ResponseWriter, r *http.Request) {
96 acct, ok := accountID(r)
97 if !ok {
98 notFound(w)
99 return
100 }
101 var req struct {
102 VaultIDHex string `json:"vault_id_hex"`
103 AccountIDHex string `json:"account_id_hex"`
104 EphemeralPublicKeyHex string `json:"ephemeral_public_key_hex"`
105 Sealed sealedJSON `json:"sealed"`
106 }
107 if !readJSON(w, r, &req) {
108 return
109 }
110 vaultID, e1 := hex.DecodeString(req.VaultIDHex)
111 memberID, e2 := hex.DecodeString(req.AccountIDHex)
112 eph, e3 := hex.DecodeString(req.EphemeralPublicKeyHex)
113 sealed, e4 := req.Sealed.decode()
114 if e1 != nil || e2 != nil || e3 != nil || e4 != nil {
115 writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad share"})
116 return
117 }
118 if bytes.Equal(memberID, acct) {
119 // Sharing with yourself would create a member row shadowing the
120 // owner's own copy, and the rotation path would then have two
121 // answers for which keyset is yours.
122 writeJSON(w, http.StatusBadRequest,
123 map[string]string{"error": "you already have this vault"})
124 return
125 }
126
127 err := v.store.AddMember(r.Context(), acct, vaultID, store.MemberWrap{
128 AccountID: memberID,
129 EphemeralPublicKey: eph,
130 Sealed: sealed,
131 })
132 if err != nil {
133 if errors.Is(err, gorm.ErrRecordNotFound) {
134 notFound(w)
135 return
136 }
137 writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
138 return
139 }
140 writeJSON(w, http.StatusCreated, map[string]string{"account_id_hex": req.AccountIDHex})
141}
142
143// Rotate is revocation, and it is also how a vault heals after any exposure.
144//
145// Everything in the body was computed in the owner's browser: a new keyset, a
146// re-seal of every item under the new key, and a wrap of the new keyset for
147// each member who remains. Whoever is not in that list stops being a member,
148// which is why removal has no endpoint of its own — it IS this call with a
149// shorter list.
150//
151// PROTOCOL.md's three-part answer, none of which the copy may soften: access
152// dies immediately, the key rotates forward, and nothing already downloaded
153// is un-downloaded.
154func (v *Vault) Rotate(w http.ResponseWriter, r *http.Request) {
155 acct, ok := accountID(r)
156 if !ok {
157 notFound(w)
158 return
159 }
160 var req struct {
161 VaultIDHex string `json:"vault_id_hex"`
162 ExpectedEpoch uint64 `json:"expected_epoch"`
163 Keyset sealedJSON `json:"keyset"`
164 Items []struct {
165 ItemIDHex string `json:"item_id_hex"`
166 Version uint64 `json:"version"`
167 sealedJSON
168 } `json:"items"`
169 Members []struct {
170 AccountIDHex string `json:"account_id_hex"`
171 EphemeralPublicKeyHex string `json:"ephemeral_public_key_hex"`
172 sealedJSON
173 } `json:"members"`
174 }
175 if !readJSON(w, r, &req) {
176 return
177 }
178 vaultID, err := hex.DecodeString(req.VaultIDHex)
179 if err != nil || len(vaultID) != 16 {
180 notFound(w)
181 return
182 }
183 keyset, err := req.Keyset.decode()
184 if err != nil {
185 writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad keyset"})
186 return
187 }
188
189 rot := store.Rotation{Keyset: keyset}
190 for _, it := range req.Items {
191 id, e1 := hex.DecodeString(it.ItemIDHex)
192 sealed, e2 := it.sealedJSON.decode()
193 if e1 != nil || e2 != nil {
194 writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad item"})
195 return
196 }
197 rot.Items = append(rot.Items, store.RotatedItem{
198 ID: id, Version: it.Version, Sealed: sealed,
199 })
200 }
201 for _, m := range req.Members {
202 id, e1 := hex.DecodeString(m.AccountIDHex)
203 eph, e2 := hex.DecodeString(m.EphemeralPublicKeyHex)
204 sealed, e3 := m.sealedJSON.decode()
205 if e1 != nil || e2 != nil || e3 != nil {
206 writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad member wrap"})
207 return
208 }
209 rot.Members = append(rot.Members, store.MemberWrap{
210 AccountID: id, EphemeralPublicKey: eph, Sealed: sealed,
211 })
212 }
213
214 switch err := v.store.RotateVault(r.Context(), acct, vaultID, req.ExpectedEpoch, rot); {
215 case err == nil:
216 writeJSON(w, http.StatusOK, map[string]any{
217 "vault_id_hex": req.VaultIDHex,
218 "epoch": req.ExpectedEpoch + 1,
219 })
220 case errors.Is(err, store.ErrRotated):
221 // Not yours, gone, already rotated, or an item moved under it. All of
222 // them mean "refetch and compose it again", and separating them would
223 // answer questions about a vault the caller may not own.
224 writeJSON(w, http.StatusConflict, map[string]string{
225 "error": "this vault changed while you were preparing the rotation",
226 })
227 default:
228 writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
229 }
230}
231