web/8: the sharing API — share by account id, revoke by rotating
The HTTP half. **You find somebody by account id, never by address.** An address-to-account endpoint would be an existence oracle for every address anybody cared to try, and the blind index exists precisely so the server cannot answer that question. An account id is 128 bits of CSPRNG, so "does this id exist" is only answerable by somebody who already has it — the same argument that lets the handoff code be eight characters. A test asserts there is no address-shaped way in. What the lookup returns is a public key, because there is nothing else it could return: the server holds no name, no address and no key for that account. **Removal has no endpoint.** It is POST /api/vault/rotate with a shorter member list, because in a seal-to-public-key model revocation IS rotation — taking somebody's row away without changing the key changes nothing about what they can already decrypt. Giving removal its own route would invite exactly the implementation that does not rotate. Everything in a rotation body is computed in the owner's browser: the new keyset, every item re-sealed, and a wrap for each remaining member. The server stores envelopes it has no key for. The API test is the one that matters and it is written to be hard to fake: it looks the recipient's public key up over HTTP, seals with the real ECIES, posts it, then opens the result with the recipient's own scalar. A test that posted opaque bytes and read them back would prove the rows move and nothing else — which is exactly what passed while wrap ids were being minted server-side and every stored wrap was unopenable. Also refuses sharing a vault with yourself, which would put a member row in front of the owner's own copy and leave rotation with two answers for which keyset is yours.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4 files changed,
+434
−5
internal/contra/app.go+16 −5internal/contra/vault/sharing.go+230 −0internal/contratest/sharing_api_test.go+179 −0internal/contratest/vault_test.go+9 −0
CI passed — run details
CI log
=== vet === go vet ./... === fmt === === test === go test ./... ? github.com/carlosframework/contra/cmd/contra [no test files] ? github.com/carlosframework/contra/internal/contra [no test files] ? github.com/carlosframework/contra/internal/contra/auth [no test files] ok github.com/carlosframework/contra/internal/contra/crypto (cached) ? github.com/carlosframework/contra/internal/contra/icons [no test files] ? github.com/carlosframework/contra/internal/contra/mail [no test files] ? github.com/carlosframework/contra/internal/contra/model [no test files] ? github.com/carlosframework/contra/internal/contra/store [no test files] ? github.com/carlosframework/contra/internal/contra/vault [no test files] ok github.com/carlosframework/contra/internal/contratest 16.724s === js-test === node --test test/crypto.test.mjs test/ecies.test.mjs test/items.test.mjs TAP version 13 # Subtest: HKDF: every contra/v1 label ok 1 - HKDF: every contra/v1 label --- duration_ms: 10.137017 type: 'test' ... # Subtest: KCV ok 2 - KCV --- duration_ms: 2.304062 type: 'test' ... # Subtest: P-256 from a seed, including the rejection branch ok 3 - P-256 from a seed, including the rejection branch --- duration_ms: 12.721896 type: 'test' ... # Subtest: signed challenge round-trips against the derived public key ok 4 - signed challenge round-trips against the derived public key --- duration_ms: 5.019623 type: 'test' ... # Subtest: Crockford: alphabet, encoding, normalisation, strict decoding ok 5 - Crockford: alphabet, encoding, normalisation, strict decoding --- duration_ms: 0.988391 type: 'test' ... # Subtest: the printed code, and the O/I/L confusions it has to survive ok 6 - the printed code, and the O/I/L confusions it has to survive --- duration_ms: 2.07122 type: 'test' ... # Subtest: repair recovers one substituted character ok 7 - repair recovers one substituted character --- duration_ms: 47.262587 type: 'test' ... # Subtest: AEAD: the AAD binding and every wrap ok 8 - AEAD: the AAD binding and every wrap --- duration_ms: 2.601528 type: 'test' ... # Subtest: AEAD: the item, scoped to its vault ok 9 - AEAD: the item, scoped to its vault --- duration_ms: 1.102731 type: 'test' ... # Subtest: a transplanted ciphertext does not open ok 10 - a transplanted ciphertext does not open --- duration_ms: 2.656449 type: 'test' ... # Subtest: padding quantises into 256-byte buckets and round-trips ok 11 - padding quantises into 256-byte buckets and round-trips --- duration_ms: 0.884813 type: 'test' ... # Subtest: the KDF parameter floor refuses what a malicious server would offer ok 12 - the KDF parameter floor refuses what a malicious server would offer --- duration_ms: 0.558899 type: 'test' ... # Subtest: Argon2id matches the published PHC reference vector ok 13 - Argon2id matches the published PHC reference vector --- duration_ms: 1811.087198 type: 'test' ... # Subtest: Argon2id at the corners the 64 MiB case does not reach ok 14 - Argon2id at the corners the 64 MiB case does not reach --- duration_ms: 211.817694 type: 'test' ... # Subtest: Argon2id matches the golden vector at the parameters DESIGN.md pins ok 15 - Argon2id matches the golden vector at the parameters DESIGN.md pins --- duration_ms: 2557.659084 type: 'test' ... # Subtest: the account chain, end to end ok 16 - the account chain, end to end --- duration_ms: 2561.157973 type: 'test' ... # Subtest: handoff: both keypairs derive from their published seeds ok 17 - handoff: both keypairs derive from their published seeds --- duration_ms: 16.835506 type: 'test' ... # Subtest: handoff: ECDH agrees from both sides ok 18 - handoff: ECDH agrees from both sides --- duration_ms: 8.427855 type: 'test' ... # Subtest: handoff: the derived key, the AAD and the sealed bytes ok 19 - handoff: the derived key, the AAD and the sealed bytes --- duration_ms: 2.534267 type: 'test' ... # Subtest: handoff: an envelope opens for its device and for nobody else ok 20 - handoff: an envelope opens for its device and for nobody else --- duration_ms: 17.706103 type: 'test' ... # Subtest: handoff: the Go and JS halves seal for each other ok 21 - handoff: the Go and JS halves seal for each other --- duration_ms: 8.348673 type: 'test' ... # Subtest: handoff: a truncated envelope is refused before any key work ok 22 - handoff: a truncated envelope is refused before any key work --- duration_ms: 0.568635 type: 'test' ... # Subtest: an item round-trips through create, list and update ok 23 - an item round-trips through create, list and update --- duration_ms: 11.146608 type: 'test' ... # Subtest: nothing readable reaches the network ok 24 - nothing readable reaches the network --- duration_ms: 2.609295 type: 'test' ... # Subtest: item ciphertexts are quantised into 256-byte buckets ok 25 - item ciphertexts are quantised into 256-byte buckets --- duration_ms: 1.642245 type: 'test' ... # Subtest: an item does not open in another slot ok 26 - an item does not open in another slot --- duration_ms: 2.794558 type: 'test' ... # Subtest: every field is present whether or not it was filled in ok 27 - every field is present whether or not it was filled in --- duration_ms: 1.246012 type: 'test' ... # Subtest: the Account Key cache is storage handed in, not reached for ok 28 - the Account Key cache is storage handed in, not reached for --- duration_ms: 0.34215 type: 'test' ... 1..28 # tests 28 # suites 0 # pass 28 # fail 0 # cancelled 0 # skipped 0 # todo 0 # duration_ms 7297.499328 === migration-check === go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check rastrillo migration check: models and migrations agree
diff --git a/internal/contra/app.go b/internal/contra/app.go| index 2f022ba..ec869e3 100644 |
| --- a/internal/contra/app.go |
| +++ b/internal/contra/app.go |
| @@ -119,11 +119,17 @@ func App(d *db.DB, cfg Config, logger *slog.Logger) (*http.ServeMux, error) { |
| r.Post("/api/items/delete", items.DeleteItem) |
| r.Post("/api/passkey", items.AddPasskey) |
| - // The device handoff, DESIGN.md's rung 1. Both ends are signed in as |
| - // the same account — a fresh browser signs in with the passphrase |
| - // alone and only then finds it has no Account Key — so every one of |
| - // these is scoped to the session, which is what makes a code short |
| - // enough to read aloud safe to use. |
| + // Sharing. AccountKey is the lookup that makes it possible: by |
| + // account id, never by address, because an address-to-account |
| + // endpoint would be the existence oracle the blind index exists to |
| + // prevent. Removal has no route of its own — it is a rotation with a |
| + // shorter member list, because in a seal-to-public-key model |
| + // revocation IS rotation. |
| + r.Get("/api/account-key", items.AccountKey) |
| + r.Get("/api/members", items.Members) |
| + r.Post("/api/members", items.AddMember) |
| + r.Post("/api/vault/rotate", items.Rotate) |
| + |
| // The printed kit: reprint the Account Key card exactly, or reissue |
| // the recovery code — which regenerates rather than reprints, because |
| // only its wrap is stored. DESIGN.md wants this obvious rather than |
| @@ -132,6 +138,11 @@ func App(d *db.DB, cfg Config, logger *slog.Logger) (*http.ServeMux, error) { |
| r.Get("/kit", a.kit) |
| r.Post("/api/recovery", items.RegenerateRecovery) |
| + // The device handoff, DESIGN.md's rung 1. Both ends are signed in as |
| + // the same account — a fresh browser signs in with the passphrase |
| + // alone and only then finds it has no Account Key — so every one of |
| + // these is scoped to the session, which is what makes a code short |
| + // enough to read aloud safe to use. |
| r.Get("/device", a.device) |
| r.Post("/api/link/start", items.StartLink) |
| r.Get("/api/link/pending", items.PendingLink) |
diff --git a/internal/contra/vault/sharing.go b/internal/contra/vault/sharing.go| new file mode 100644 |
| index 0000000..344acff |
| --- /dev/null |
| +++ b/internal/contra/vault/sharing.go |
| @@ -0,0 +1,230 @@ |
| +// The sharing API: who a vault is shared with, and the rotation that taking |
| +// somebody out of it really is. |
| +// |
| +// # How you find somebody to share with |
| +// |
| +// By account id, which the recipient reads off their own screen and gives you |
| +// however people give each other things. NOT by email address. |
| +// |
| +// That is not squeamishness. An endpoint that turned an address into an |
| +// account would be an existence oracle for every address anybody cared to |
| +// try, and the blind index exists precisely so the server cannot answer that |
| +// question. An account id is 128 bits of CSPRNG, so "does this id exist" is |
| +// only answerable by somebody who already has it — which is the same argument |
| +// that lets the handoff code be eight characters. |
| +// |
| +// What the lookup returns is a public key. There is nothing else it could |
| +// return: the server holds no name, no address and no key for that account. |
| + |
| +package vault |
| + |
| +import ( |
| + "bytes" |
| + "encoding/hex" |
| + "errors" |
| + "net/http" |
| + |
| + "gorm.io/gorm" |
| + |
| + "github.com/carlosframework/contra/internal/contra/crypto" |
| + "github.com/carlosframework/contra/internal/contra/store" |
| +) |
| + |
| +// AccountKey answers the public key to seal a vault keyset to. |
| +// |
| +// Signed-in only, and by id only. It confirms that an account id exists, |
| +// which is a fact worth exactly as much as the id already was. |
| +func (v *Vault) AccountKey(w http.ResponseWriter, r *http.Request) { |
| + if _, ok := accountID(r); !ok { |
| + notFound(w) |
| + return |
| + } |
| + id, err := hex.DecodeString(r.URL.Query().Get("account")) |
| + if err != nil || len(id) != 16 { |
| + notFound(w) |
| + return |
| + } |
| + acct, err := v.store.Account(r.Context(), id) |
| + if err != nil || len(acct.PublicKey) != crypto.PublicKeyLen { |
| + // An account that has not provisioned yet has no public key, and so |
| + // cannot be shared with. Same answer as one that does not exist. |
| + notFound(w) |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]string{ |
| + "account_id_hex": hex.EncodeToString(acct.ID), |
| + "public_key_hex": hex.EncodeToString(acct.PublicKey), |
| + }) |
| +} |
| + |
| +// Members lists who a vault is shared with. Owner-only: a member learning the |
| +// whole membership list is a disclosure nobody asked for. |
| +func (v *Vault) Members(w http.ResponseWriter, r *http.Request) { |
| + acct, ok := accountID(r) |
| + if !ok { |
| + notFound(w) |
| + return |
| + } |
| + vaultID, err := hex.DecodeString(r.URL.Query().Get("vault")) |
| + if err != nil || len(vaultID) != 16 { |
| + notFound(w) |
| + return |
| + } |
| + members, err := v.store.Members(r.Context(), acct, vaultID) |
| + if err != nil { |
| + notFound(w) |
| + return |
| + } |
| + out := []map[string]string{} |
| + for _, m := range members { |
| + // The account id and nothing else. There is no name to send. |
| + out = append(out, map[string]string{ |
| + "account_id_hex": hex.EncodeToString(m.AccountID), |
| + }) |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"members": out}) |
| +} |
| + |
| +// AddMember shares a vault. Owner-only. |
| +// |
| +// The keyset arrives already sealed to the recipient's account public key by |
| +// ephemeral-static ECDH — done in the owner's browser, where the keyset can |
| +// actually be opened. The server stores an envelope it has no key for, which |
| +// is why sharing needs no unlock on the recipient's side and no trust on the |
| +// server's. |
| +func (v *Vault) AddMember(w http.ResponseWriter, r *http.Request) { |
| + acct, ok := accountID(r) |
| + if !ok { |
| + notFound(w) |
| + return |
| + } |
| + var req struct { |
| + VaultIDHex string `json:"vault_id_hex"` |
| + AccountIDHex string `json:"account_id_hex"` |
| + EphemeralPublicKeyHex string `json:"ephemeral_public_key_hex"` |
| + Sealed sealedJSON `json:"sealed"` |
| + } |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + vaultID, e1 := hex.DecodeString(req.VaultIDHex) |
| + memberID, e2 := hex.DecodeString(req.AccountIDHex) |
| + eph, e3 := hex.DecodeString(req.EphemeralPublicKeyHex) |
| + sealed, e4 := req.Sealed.decode() |
| + if e1 != nil || e2 != nil || e3 != nil || e4 != nil { |
| + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad share"}) |
| + return |
| + } |
| + if bytes.Equal(memberID, acct) { |
| + // Sharing with yourself would create a member row shadowing the |
| + // owner's own copy, and the rotation path would then have two |
| + // answers for which keyset is yours. |
| + writeJSON(w, http.StatusBadRequest, |
| + map[string]string{"error": "you already have this vault"}) |
| + return |
| + } |
| + |
| + err := v.store.AddMember(r.Context(), acct, vaultID, store.MemberWrap{ |
| + AccountID: memberID, |
| + EphemeralPublicKey: eph, |
| + Sealed: sealed, |
| + }) |
| + if err != nil { |
| + if errors.Is(err, gorm.ErrRecordNotFound) { |
| + notFound(w) |
| + return |
| + } |
| + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) |
| + return |
| + } |
| + writeJSON(w, http.StatusCreated, map[string]string{"account_id_hex": req.AccountIDHex}) |
| +} |
| + |
| +// Rotate is revocation, and it is also how a vault heals after any exposure. |
| +// |
| +// Everything in the body was computed in the owner's browser: a new keyset, a |
| +// re-seal of every item under the new key, and a wrap of the new keyset for |
| +// each member who remains. Whoever is not in that list stops being a member, |
| +// which is why removal has no endpoint of its own — it IS this call with a |
| +// shorter list. |
| +// |
| +// DESIGN.md's three-part answer, none of which the copy may soften: access |
| +// dies immediately, the key rotates forward, and nothing already downloaded |
| +// is un-downloaded. |
| +func (v *Vault) Rotate(w http.ResponseWriter, r *http.Request) { |
| + acct, ok := accountID(r) |
| + if !ok { |
| + notFound(w) |
| + return |
| + } |
| + var req struct { |
| + VaultIDHex string `json:"vault_id_hex"` |
| + ExpectedEpoch uint64 `json:"expected_epoch"` |
| + Keyset sealedJSON `json:"keyset"` |
| + Items []struct { |
| + ItemIDHex string `json:"item_id_hex"` |
| + Version uint64 `json:"version"` |
| + sealedJSON |
| + } `json:"items"` |
| + Members []struct { |
| + AccountIDHex string `json:"account_id_hex"` |
| + EphemeralPublicKeyHex string `json:"ephemeral_public_key_hex"` |
| + sealedJSON |
| + } `json:"members"` |
| + } |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + vaultID, err := hex.DecodeString(req.VaultIDHex) |
| + if err != nil || len(vaultID) != 16 { |
| + notFound(w) |
| + return |
| + } |
| + keyset, err := req.Keyset.decode() |
| + if err != nil { |
| + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad keyset"}) |
| + return |
| + } |
| + |
| + rot := store.Rotation{Keyset: keyset} |
| + for _, it := range req.Items { |
| + id, e1 := hex.DecodeString(it.ItemIDHex) |
| + sealed, e2 := it.sealedJSON.decode() |
| + if e1 != nil || e2 != nil { |
| + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad item"}) |
| + return |
| + } |
| + rot.Items = append(rot.Items, store.RotatedItem{ |
| + ID: id, Version: it.Version, Sealed: sealed, |
| + }) |
| + } |
| + for _, m := range req.Members { |
| + id, e1 := hex.DecodeString(m.AccountIDHex) |
| + eph, e2 := hex.DecodeString(m.EphemeralPublicKeyHex) |
| + sealed, e3 := m.sealedJSON.decode() |
| + if e1 != nil || e2 != nil || e3 != nil { |
| + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad member wrap"}) |
| + return |
| + } |
| + rot.Members = append(rot.Members, store.MemberWrap{ |
| + AccountID: id, EphemeralPublicKey: eph, Sealed: sealed, |
| + }) |
| + } |
| + |
| + switch err := v.store.RotateVault(r.Context(), acct, vaultID, req.ExpectedEpoch, rot); { |
| + case err == nil: |
| + writeJSON(w, http.StatusOK, map[string]any{ |
| + "vault_id_hex": req.VaultIDHex, |
| + "epoch": req.ExpectedEpoch + 1, |
| + }) |
| + case errors.Is(err, store.ErrRotated): |
| + // Not yours, gone, already rotated, or an item moved under it. All of |
| + // them mean "refetch and compose it again", and separating them would |
| + // answer questions about a vault the caller may not own. |
| + writeJSON(w, http.StatusConflict, map[string]string{ |
| + "error": "this vault changed while you were preparing the rotation", |
| + }) |
| + default: |
| + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) |
| + } |
| +} |
diff --git a/internal/contratest/sharing_api_test.go b/internal/contratest/sharing_api_test.go| new file mode 100644 |
| index 0000000..2c7fa65 |
| --- /dev/null |
| +++ b/internal/contratest/sharing_api_test.go |
| @@ -0,0 +1,179 @@ |
| +package contratest |
| + |
| +import ( |
| + "encoding/hex" |
| + "net/http" |
| + "testing" |
| + |
| + "github.com/carlosframework/contra/internal/contra/crypto" |
| +) |
| + |
| +// The sharing API over real HTTP, driven the way the browser drives it: |
| +// look the recipient's public key up by account id, seal the keyset to it |
| +// with the real ECDH, post it, and have the recipient open what comes back. |
| +// |
| +// That last part is the point. A test that posts opaque bytes and reads them |
| +// back proves the rows move; only sealing with one key and opening with the |
| +// other proves the sharing actually works — which is the class of bug that |
| +// bit us when wrap ids were minted server-side. |
| + |
| +func accountPublicKey(t *testing.T, h http.Handler, cookie string, id []byte) []byte { |
| + t.Helper() |
| + rec := as(t, h, cookie, http.MethodGet, |
| + "/api/account-key?account="+hex.EncodeToString(id), nil) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("account-key: status %d, body %s", rec.Code, rec.Body.String()) |
| + } |
| + var out struct { |
| + PublicKeyHex string `json:"public_key_hex"` |
| + } |
| + decode(t, rec, &out) |
| + key, err := hex.DecodeString(out.PublicKeyHex) |
| + if err != nil || len(key) != crypto.PublicKeyLen { |
| + t.Fatalf("account-key returned a bad public key: %q", out.PublicKeyHex) |
| + } |
| + return key |
| +} |
| + |
| +func TestSharingSealsToTheRecipientAndOpensForThem(t *testing.T) { |
| + h := newApp(t) |
| + owner := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| + guest := provision(t, h, "guest@example.invalid", "a different generated one 4d2e9b") |
| + |
| + // The owner looks the recipient up by the account id they were given. |
| + guestPublic := accountPublicKey(t, h, owner.cookie, guest.id) |
| + |
| + // And seals the vault keyset to it, in the browser's shape: ECIES with |
| + // the AAD naming the vault and the recipient. |
| + keyset := []byte(`{"name":"Household","k_vault":"` + |
| + hex.EncodeToString(owner.kVault) + `"}`) |
| + aad := crypto.AAD(owner.vaultID, "vault.keyset", guest.id, 1) |
| + envelope, err := crypto.SealTo(guestPublic, aad, keyset) |
| + if err != nil { |
| + t.Fatalf("SealTo: %v", err) |
| + } |
| + |
| + rec := as(t, h, owner.cookie, http.MethodPost, "/api/members", map[string]any{ |
| + "vault_id_hex": hex.EncodeToString(owner.vaultID), |
| + "account_id_hex": hex.EncodeToString(guest.id), |
| + "ephemeral_public_key_hex": hex.EncodeToString(envelope[:crypto.PublicKeyLen]), |
| + "sealed": map[string]string{ |
| + "nonce_hex": hex.EncodeToString( |
| + envelope[crypto.PublicKeyLen : crypto.PublicKeyLen+crypto.NonceLen]), |
| + "ciphertext_hex": hex.EncodeToString(envelope[crypto.PublicKeyLen+crypto.NonceLen:]), |
| + }, |
| + }) |
| + if rec.Code != http.StatusCreated { |
| + t.Fatalf("share: status %d, body %s", rec.Code, rec.Body.String()) |
| + } |
| + |
| + // The recipient's bootstrap carries their wrap — and it opens under their |
| + // account private key, which is the whole claim. |
| + var boot struct { |
| + Vaults []struct { |
| + VaultIDHex string `json:"vault_id_hex"` |
| + Owned bool `json:"owned"` |
| + EphemeralPublicKeyHex string `json:"ephemeral_public_key_hex"` |
| + Keyset struct { |
| + NonceHex string `json:"nonce_hex"` |
| + CiphertextHex string `json:"ciphertext_hex"` |
| + } `json:"keyset"` |
| + } `json:"vaults"` |
| + } |
| + decode(t, as(t, h, guest.cookie, http.MethodGet, "/api/bootstrap", nil), &boot) |
| + |
| + for _, vt := range boot.Vaults { |
| + if vt.VaultIDHex != hex.EncodeToString(owner.vaultID) || vt.Owned { |
| + continue |
| + } |
| + eph, _ := hex.DecodeString(vt.EphemeralPublicKeyHex) |
| + nonce, _ := hex.DecodeString(vt.Keyset.NonceHex) |
| + ct, _ := hex.DecodeString(vt.Keyset.CiphertextHex) |
| + |
| + rebuilt := append(append(append([]byte{}, eph...), nonce...), ct...) |
| + got, err := crypto.OpenFrom(guest.accountScalar, guest.accountPublic, aad, rebuilt) |
| + if err != nil { |
| + t.Fatalf("the member cannot open the keyset sealed to them: %v", err) |
| + } |
| + if string(got) != string(keyset) { |
| + t.Errorf("the member opened the wrong keyset\n got %s\n want %s", got, keyset) |
| + } |
| + return |
| + } |
| + t.Fatal("the shared vault is not in the member's bootstrap") |
| +} |
| + |
| +// Sharing needs no unlock on the recipient's side and no key on the server's, |
| +// which is what the agent surface depends on later. The lookup is by account |
| +// id and NEVER by address — an address-to-account endpoint would be the |
| +// existence oracle the blind index exists to prevent. |
| +func TestThereIsNoWayToLookAnAccountUpByAddress(t *testing.T) { |
| + h := newApp(t) |
| + owner := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| + provision(t, h, "guest@example.invalid", "a different generated one 4d2e9b") |
| + |
| + for _, q := range []string{ |
| + "/api/account-key?account=guest@example.invalid", |
| + "/api/account-key?email=guest@example.invalid", |
| + "/api/account-key", |
| + } { |
| + if rec := as(t, h, owner.cookie, http.MethodGet, q, nil); rec.Code != http.StatusNotFound { |
| + t.Errorf("%s: status %d, want 404", q, rec.Code) |
| + } |
| + } |
| + |
| + // An id that does not exist is the same answer as one that does not have |
| + // a public key yet. |
| + if rec := as(t, h, owner.cookie, http.MethodGet, |
| + "/api/account-key?account="+hex.EncodeToString(randBytes(t, 16)), nil); rec.Code != http.StatusNotFound { |
| + t.Errorf("unknown account id: status %d, want 404", rec.Code) |
| + } |
| +} |
| + |
| +func TestSharingRoutesRefuseAStranger(t *testing.T) { |
| + h := newApp(t) |
| + owner := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| + |
| + for _, c := range []struct { |
| + method string |
| + path string |
| + }{ |
| + {http.MethodGet, "/api/account-key?account=" + hex.EncodeToString(owner.id)}, |
| + {http.MethodGet, "/api/members?vault=" + hex.EncodeToString(owner.vaultID)}, |
| + {http.MethodPost, "/api/members"}, |
| + {http.MethodPost, "/api/vault/rotate"}, |
| + } { |
| + rec := as(t, h, "", c.method, c.path, map[string]string{}) |
| + if rec.Code == http.StatusOK || rec.Code == http.StatusCreated { |
| + t.Errorf("%s %s served a stranger: status %d", c.method, c.path, rec.Code) |
| + } |
| + } |
| +} |
| + |
| +// Sharing a vault with yourself would create a member row shadowing the |
| +// owner's own copy, and rotation would then have two answers for which keyset |
| +// is yours. |
| +func TestYouCannotShareAVaultWithYourself(t *testing.T) { |
| + h := newApp(t) |
| + owner := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| + ownPublic := accountPublicKey(t, h, owner.cookie, owner.id) |
| + |
| + envelope, err := crypto.SealTo(ownPublic, |
| + crypto.AAD(owner.vaultID, "vault.keyset", owner.id, 1), []byte(`{"k":"x"}`)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + rec := as(t, h, owner.cookie, http.MethodPost, "/api/members", map[string]any{ |
| + "vault_id_hex": hex.EncodeToString(owner.vaultID), |
| + "account_id_hex": hex.EncodeToString(owner.id), |
| + "ephemeral_public_key_hex": hex.EncodeToString(envelope[:crypto.PublicKeyLen]), |
| + "sealed": map[string]string{ |
| + "nonce_hex": hex.EncodeToString( |
| + envelope[crypto.PublicKeyLen : crypto.PublicKeyLen+crypto.NonceLen]), |
| + "ciphertext_hex": hex.EncodeToString(envelope[crypto.PublicKeyLen+crypto.NonceLen:]), |
| + }, |
| + }) |
| + if rec.Code != http.StatusBadRequest { |
| + t.Errorf("sharing with yourself: status %d, want 400", rec.Code) |
| + } |
| +} |
diff --git a/internal/contratest/vault_test.go b/internal/contratest/vault_test.go| index d99e86c..562062d 100644 |
| --- a/internal/contratest/vault_test.go |
| +++ b/internal/contratest/vault_test.go |
| @@ -61,6 +61,12 @@ type provisioned struct { |
| cookie string |
| vaultID []byte |
| kVault []byte |
| + |
| + // The account keypair, kept so a sharing test can open a keyset sealed to |
| + // this account's public half — which is the only way to prove sharing |
| + // works rather than that rows moved. |
| + accountScalar []byte |
| + accountPublic []byte |
| } |
| func provision(t *testing.T, h http.Handler, email, passphrase string) provisioned { |
| @@ -102,6 +108,9 @@ func provision(t *testing.T, h http.Handler, email, passphrase string) provision |
| "nonce_hex": hex.EncodeToString(nonce), "ciphertext_hex": hex.EncodeToString(ct)} |
| } |
| + p.accountScalar = acctKey.Bytes() |
| + p.accountPublic = acctKey.PublicKey().Bytes() |
| + |
| rec := as(t, h, cookie, http.MethodPost, "/api/provision", map[string]any{ |
| "public_key_hex": hex.EncodeToString(acctKey.PublicKey().Bytes()), |
| "account_keyset": sealUnder("account.keyset", a.id, |