carlos / contra Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Package vault is the item API: a sealed-blob store with an owner filter.
2//
3// Every handler here hands out or takes in ciphertext. None of them can read
4// an item, and none of them is one refactor away from being able to — there
5// is no key on this side and no parameter that could carry one.
6//
7// # Not yours is not found
8//
9// A vault or item belonging to somebody else answers 404, never 403. A 403
10// confirms the row exists, which is a fact about another account's vault and
11// therefore a fact we do not have. The owner filter lives in the SQL rather
12// than in a check beside it, so a later refactor cannot turn a read into an
13// IDOR by dropping a line.
14package vault
15
16import (
17 "bytes"
18 "encoding/hex"
19 "encoding/json"
20 "errors"
21 "net/http"
22
23 "gorm.io/gorm"
24
25 "github.com/carlosframework/rastrillo/sessions"
26
27 "github.com/carlosframework/contra/internal/contra/store"
28)
29
30type Vault struct{ store *store.Store }
31
32func New(db *gorm.DB) *Vault { return &Vault{store: store.New(db)} }
33
34// accountID reads the viewer from the session.
35//
36// Not sessions.UserID: that returns an int64 and only works for a plugin
37// whose Subject is a numeric user id. CONTRA's subject is a hex account id,
38// so UserID would answer (0, false) and — if its ok were dropped, which is
39// exactly the mistake the framework's own notes warn about — scope every
40// query to a nonexistent account and quietly return nothing.
41func accountID(r *http.Request) ([]byte, bool) {
42 sess, ok := sessions.Current(r)
43 if !ok {
44 return nil, false
45 }
46 id, err := hex.DecodeString(sess.Subject)
47 if err != nil || len(id) != 16 {
48 return nil, false
49 }
50 return id, true
51}
52
53func writeJSON(w http.ResponseWriter, status int, v any) {
54 w.Header().Set("Content-Type", "application/json")
55 w.Header().Set("Cache-Control", "no-store")
56 w.WriteHeader(status)
57 _ = json.NewEncoder(w).Encode(v)
58}
59
60func notFound(w http.ResponseWriter) {
61 writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
62}
63
64func readJSON(w http.ResponseWriter, r *http.Request, v any) bool {
65 dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
66 dec.DisallowUnknownFields()
67 if err := dec.Decode(v); err != nil {
68 writeJSON(w, http.StatusBadRequest, map[string]string{"error": "malformed request"})
69 return false
70 }
71 return true
72}
73
74type sealedJSON struct {
75 NonceHex string `json:"nonce_hex"`
76 CiphertextHex string `json:"ciphertext_hex"`
77}
78
79func (s sealedJSON) decode() (store.Sealed, error) {
80 nonce, err := hex.DecodeString(s.NonceHex)
81 if err != nil {
82 return store.Sealed{}, err
83 }
84 ct, err := hex.DecodeString(s.CiphertextHex)
85 if err != nil {
86 return store.Sealed{}, err
87 }
88 return store.Sealed{Nonce: nonce, Ciphertext: ct}, nil
89}
90
91func sealedOf(nonce, ct []byte) sealedJSON {
92 return sealedJSON{NonceHex: hex.EncodeToString(nonce), CiphertextHex: hex.EncodeToString(ct)}
93}
94
95// Bootstrap is everything a freshly signed-in browser needs to unlock: the
96// wraps of RK, the account keyset, and the vaults with their sealed keysets.
97//
98// All of it is ciphertext. Handing it over to a signed-in session is not a
99// disclosure — the same bytes are in the bucket, and the whole design assumes
100// an attacker who has them. What it saves is a round trip per unlock.
101func (v *Vault) Bootstrap(w http.ResponseWriter, r *http.Request) {
102 id, ok := accountID(r)
103 if !ok {
104 notFound(w)
105 return
106 }
107 ctx := r.Context()
108
109 acct, err := v.store.Account(ctx, id)
110 if err != nil {
111 notFound(w)
112 return
113 }
114 wraps, err := v.store.Wraps(ctx, id)
115 if err != nil {
116 writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "lookup failed"})
117 return
118 }
119 // Everything this account can open: what it owns, and what is shared with
120 // it. A member's copy of a shared keyset is a different blob under a
121 // different key from the owner's, so which one is sent depends on who is
122 // asking — see the loop below.
123 vaults, err := v.store.ReachableVaults(ctx, id)
124 if err != nil {
125 writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "lookup failed"})
126 return
127 }
128
129 type wrapOut struct {
130 Kind string `json:"kind"`
131 WrapIDHex string `json:"wrap_id_hex"`
132 Version uint64 `json:"version"`
133 KDFParams string `json:"kdf_params"`
134 KCVHex string `json:"kcv_hex"`
135 sealedJSON
136 }
137 type vaultOut struct {
138 VaultIDHex string `json:"vault_id_hex"`
139 Keyset sealedJSON `json:"keyset"`
140
141 // Epoch is the compare-and-swap token for writes into this vault. A
142 // client that writes against a stale one is refused rather than
143 // storing an item sealed under a key that has just been revoked.
144 Epoch uint64 `json:"epoch"`
145
146 // Owned says which key opens Keyset. The owner's copy is sealed under
147 // RK; a member's is sealed to their account public key by
148 // ephemeral-static ECDH, and needs EphemeralPublicKeyHex to open.
149 Owned bool `json:"owned"`
150
151 EphemeralPublicKeyHex string `json:"ephemeral_public_key_hex,omitempty"`
152 }
153
154 out := struct {
155 AccountIDHex string `json:"account_id_hex"`
156 SaltHex string `json:"salt_hex"`
157 Keyset sealedJSON `json:"keyset"`
158 Wraps []wrapOut `json:"wraps"`
159 Vaults []vaultOut `json:"vaults"`
160 }{
161 AccountIDHex: hex.EncodeToString(acct.ID),
162 SaltHex: hex.EncodeToString(acct.KDFSalt),
163 Keyset: sealedOf(acct.KeysetNonce, acct.KeysetCiphertext),
164 Wraps: []wrapOut{},
165 Vaults: []vaultOut{},
166 }
167 for _, wr := range wraps {
168 out.Wraps = append(out.Wraps, wrapOut{
169 Kind: wr.Kind, WrapIDHex: hex.EncodeToString(wr.ID), Version: wr.Version,
170 KDFParams: wr.KDFParams, KCVHex: hex.EncodeToString(wr.KCV),
171 sealedJSON: sealedOf(wr.Nonce, wr.Ciphertext),
172 })
173 }
174 for _, vt := range vaults {
175 row := vaultOut{
176 VaultIDHex: hex.EncodeToString(vt.ID),
177 Epoch: vt.Epoch,
178 Owned: bytes.Equal(vt.AccountID, id),
179 }
180 if row.Owned {
181 row.Keyset = sealedOf(vt.KeysetNonce, vt.KeysetCiphertext)
182 } else {
183 // A member gets THEIR wrap, never the owner's — the owner's is
184 // sealed under the owner's RK and would be an unopenable blob to
185 // anybody else, which is a confusing way to say "not for you".
186 m, err := v.store.MemberWrapOf(ctx, vt.ID, id)
187 if err != nil || m == nil {
188 // The membership row vanished between listing and reading it.
189 // Dropping the vault is right: there is nothing to send.
190 continue
191 }
192 row.Keyset = sealedOf(m.Nonce, m.Ciphertext)
193 row.EphemeralPublicKeyHex = hex.EncodeToString(m.EphemeralPublicKey)
194 }
195 out.Vaults = append(out.Vaults, row)
196 }
197 writeJSON(w, http.StatusOK, out)
198}
199
200type itemOut struct {
201 ItemIDHex string `json:"item_id_hex"`
202 Version uint64 `json:"version"`
203 sealedJSON
204}
205
206// Items lists a vault's sealed items.
207func (v *Vault) Items(w http.ResponseWriter, r *http.Request) {
208 acct, ok := accountID(r)
209 if !ok {
210 notFound(w)
211 return
212 }
213 vaultID, err := hex.DecodeString(r.URL.Query().Get("vault"))
214 if err != nil || len(vaultID) != 16 {
215 notFound(w)
216 return
217 }
218 items, err := v.store.Items(r.Context(), acct, vaultID)
219 if err != nil {
220 // Including "the vault is not yours": not found is the honest answer
221 // and the only one that says nothing about another account.
222 notFound(w)
223 return
224 }
225 out := []itemOut{}
226 for _, it := range items {
227 out = append(out, itemOut{
228 ItemIDHex: hex.EncodeToString(it.ID), Version: it.Version,
229 sealedJSON: sealedOf(it.Nonce, it.Ciphertext),
230 })
231 }
232 writeJSON(w, http.StatusOK, map[string]any{"items": out})
233}
234
235type putItemRequest struct {
236 VaultIDHex string `json:"vault_id_hex"`
237 ItemIDHex string `json:"item_id_hex"`
238 Version uint64 `json:"version"`
239
240 // ExpectedVersion is the version this write believes it is replacing —
241 // the compare-and-swap precondition. Ignored on create, where there is
242 // nothing to replace.
243 ExpectedVersion uint64 `json:"expected_version"`
244
245 sealedJSON
246}
247
248// CreateItem stores a new sealed item. The item id is the client's, because
249// it is bound into the AAD before the server sees the ciphertext.
250func (v *Vault) CreateItem(w http.ResponseWriter, r *http.Request) {
251 acct, ok := accountID(r)
252 if !ok {
253 notFound(w)
254 return
255 }
256 var req putItemRequest
257 if !readJSON(w, r, &req) {
258 return
259 }
260 vaultID, e1 := hex.DecodeString(req.VaultIDHex)
261 itemID, e2 := hex.DecodeString(req.ItemIDHex)
262 sealed, e3 := req.sealedJSON.decode()
263 if e1 != nil || e2 != nil || e3 != nil || len(vaultID) != 16 || len(itemID) != 16 {
264 writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad item"})
265 return
266 }
267 it, err := v.store.PutItem(r.Context(), acct, vaultID, itemID, req.Version, sealed)
268 if err != nil {
269 if errors.Is(err, gorm.ErrRecordNotFound) {
270 notFound(w)
271 return
272 }
273 writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
274 return
275 }
276 writeJSON(w, http.StatusCreated, itemOut{
277 ItemIDHex: hex.EncodeToString(it.ID), Version: it.Version,
278 sealedJSON: sealedOf(it.Nonce, it.Ciphertext),
279 })
280}
281
282// UpdateItem replaces an item's sealed body and bumps its version.
283//
284// A compare-and-swap: the client says which version it is replacing, and a
285// write that lost the race gets 409 with the version that actually won, so it
286// can refetch and merge rather than silently erase somebody. That matters
287// from the moment a second device or a second member exists — before this,
288// two writers who both opened v3 both wrote v4 and the loser vanished with
289// nothing recording that it had ever been there.
290func (v *Vault) UpdateItem(w http.ResponseWriter, r *http.Request) {
291 acct, ok := accountID(r)
292 if !ok {
293 notFound(w)
294 return
295 }
296 var req putItemRequest
297 if !readJSON(w, r, &req) {
298 return
299 }
300 vaultID, e1 := hex.DecodeString(req.VaultIDHex)
301 itemID, e2 := hex.DecodeString(req.ItemIDHex)
302 sealed, e3 := req.sealedJSON.decode()
303 if e1 != nil || e2 != nil || e3 != nil || len(vaultID) != 16 || len(itemID) != 16 {
304 writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad item"})
305 return
306 }
307
308 err := v.store.UpdateItem(r.Context(), acct, vaultID, itemID,
309 req.ExpectedVersion, req.Version, sealed)
310 switch {
311 case err == nil:
312 writeJSON(w, http.StatusOK, map[string]string{"item_id_hex": req.ItemIDHex})
313 case errors.Is(err, gorm.ErrRecordNotFound):
314 notFound(w)
315 case errors.Is(err, store.ErrStale):
316 // Tell them what to merge against. A bare 409 leaves the client
317 // guessing whether to refetch or to retry the same bytes.
318 current, lookupErr := v.store.ItemVersion(r.Context(), acct, vaultID, itemID)
319 if lookupErr != nil {
320 // It was deleted between the failed write and this read, which is
321 // the other reason the CAS missed.
322 notFound(w)
323 return
324 }
325 writeJSON(w, http.StatusConflict, map[string]any{
326 "error": "this item changed since you read it",
327 "item_id_hex": req.ItemIDHex,
328 "current_version": current,
329 })
330 default:
331 writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
332 }
333}
334
335type deleteItemRequest struct {
336 VaultIDHex string `json:"vault_id_hex"`
337 ItemIDHex string `json:"item_id_hex"`
338}
339
340func (v *Vault) DeleteItem(w http.ResponseWriter, r *http.Request) {
341 acct, ok := accountID(r)
342 if !ok {
343 notFound(w)
344 return
345 }
346 var req deleteItemRequest
347 if !readJSON(w, r, &req) {
348 return
349 }
350 vaultID, e1 := hex.DecodeString(req.VaultIDHex)
351 itemID, e2 := hex.DecodeString(req.ItemIDHex)
352 if e1 != nil || e2 != nil {
353 notFound(w)
354 return
355 }
356 if err := v.store.DeleteItem(r.Context(), acct, vaultID, itemID); err != nil {
357 notFound(w)
358 return
359 }
360 w.WriteHeader(http.StatusNoContent)
361}
362
363// AddPasskey stores a passkey wrap. The browser has already done the whole
364// ceremony — created the credential, evaluated the PRF, derived the KEK and
365// sealed RK under it — so what arrives here is ciphertext and two public
366// identifiers.
367//
368// A passkey is additive, always. It never replaces the passphrase wrap or the
369// recovery wrap, and neither is ever removed, so losing or deleting a passkey
370// cannot strand anybody.
371func (v *Vault) AddPasskey(w http.ResponseWriter, r *http.Request) {
372 acct, ok := accountID(r)
373 if !ok {
374 notFound(w)
375 return
376 }
377 var req struct {
378 WrapIDHex string `json:"wrap_id_hex"`
379 CredentialIDHex string `json:"credential_id_hex"`
380 CredSaltHex string `json:"cred_salt_hex"`
381 KCVHex string `json:"kcv_hex"`
382 KDFParams string `json:"kdf_params"`
383 Sealed sealedJSON `json:"sealed"`
384 }
385 if !readJSON(w, r, &req) {
386 return
387 }
388 wrapID, e1 := hex.DecodeString(req.WrapIDHex)
389 credentialID, e2 := hex.DecodeString(req.CredentialIDHex)
390 credSalt, e3 := hex.DecodeString(req.CredSaltHex)
391 kcv, e4 := hex.DecodeString(req.KCVHex)
392 sealed, e5 := req.Sealed.decode()
393 if e1 != nil || e2 != nil || e3 != nil || e4 != nil || e5 != nil {
394 writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad passkey wrap"})
395 return
396 }
397 if err := v.store.PutPasskeyWrap(r.Context(), acct, wrapID, credentialID,
398 credSalt, kcv, req.KDFParams, sealed); err != nil {
399 writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
400 return
401 }
402 writeJSON(w, http.StatusCreated, map[string]string{"wrap_id_hex": req.WrapIDHex})
403}
404
405// Provision is the one write that is not an item: it records the account
406// keyset and the first vault, both sealed in the browser at signup.
407//
408// Separate from signup because it needs a session — signup ends before one
409// exists, and a keyset posted without one would be a write nobody had proved
410// they were entitled to make.
411func (v *Vault) Provision(w http.ResponseWriter, r *http.Request) {
412 acct, ok := accountID(r)
413 if !ok {
414 notFound(w)
415 return
416 }
417 var req struct {
418 PublicKeyHex string `json:"public_key_hex"`
419 AccountKeyset sealedJSON `json:"account_keyset"`
420 VaultIDHex string `json:"vault_id_hex"`
421 DropBoxPublicKeyHex string `json:"drop_box_public_key_hex"`
422 VaultKeyset sealedJSON `json:"vault_keyset"`
423 }
424 if !readJSON(w, r, &req) {
425 return
426 }
427 pub, e1 := hex.DecodeString(req.PublicKeyHex)
428 acctKeyset, e2 := req.AccountKeyset.decode()
429 vaultID, e3 := hex.DecodeString(req.VaultIDHex)
430 dropBox, e4 := hex.DecodeString(req.DropBoxPublicKeyHex)
431 vaultKeyset, e5 := req.VaultKeyset.decode()
432 if e1 != nil || e2 != nil || e3 != nil || e4 != nil || e5 != nil {
433 writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad provisioning"})
434 return
435 }
436
437 ctx := r.Context()
438 if err := v.store.SetAccountKeyset(ctx, acct, pub, acctKeyset); err != nil {
439 writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
440 return
441 }
442 if _, err := v.store.CreateVault(ctx, vaultID, acct, dropBox, vaultKeyset); err != nil {
443 writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
444 return
445 }
446 writeJSON(w, http.StatusCreated, map[string]string{"vault_id_hex": req.VaultIDHex})
447}
448