carlos / contra Public

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>
Paul Campbell pushed by paul@keymail.dev d11737c8c99e36745f690b0d9396336733794b47 parent 4a618f1
4 files changed, +434 −5
  • internal/contra/app.go +16 −5
  • internal/contra/vault/sharing.go +230 −0
  • internal/contratest/sharing_api_test.go +179 −0
  • internal/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,