| 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. |
| 14 | package vault |
| 15 | |
| 16 | import ( |
| 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 | |
| 30 | type Vault struct{ store *store.Store } |
| 31 | |
| 32 | func 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. |
| 41 | func 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 | |
| 53 | func 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 | |
| 60 | func notFound(w http.ResponseWriter) { |
| 61 | writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) |
| 62 | } |
| 63 | |
| 64 | func 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 | |
| 74 | type sealedJSON struct { |
| 75 | NonceHex string `json:"nonce_hex"` |
| 76 | CiphertextHex string `json:"ciphertext_hex"` |
| 77 | } |
| 78 | |
| 79 | func (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 | |
| 91 | func 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. |
| 101 | func (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 | |
| 200 | type 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. |
| 207 | func (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 | |
| 235 | type 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. |
| 250 | func (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. |
| 290 | func (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 | |
| 335 | type deleteItemRequest struct { |
| 336 | VaultIDHex string `json:"vault_id_hex"` |
| 337 | ItemIDHex string `json:"item_id_hex"` |
| 338 | } |
| 339 | |
| 340 | func (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. |
| 371 | func (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. |
| 411 | func (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 | |