carlos / contra Public

web/8 — Shared vaults: wrapped group keys and forward rotation Feature Request open

Depends on web/6. The largest web item; take it last.

Spec: AGENTS.md decision 1, and DESIGN.md, "The shape".

Deliverable: a vault whose keyset is wrapped to each member's account public key by ephemeral-static ECDH → HKDF → AES-GCM. Members are identities on the owner's instance. Adding a secret needs no unlock — sealing to a public key is free — which is the property the agent surface depends on later.

Revocation is Slopbox's honest three-part answer, and the copy must not soften any part:

  1. Access dies immediately — the instance stops serving them anything.
  2. The group key rotates forward; new and changed items seal under it.
  3. Nothing already downloaded is un-downloaded. No design pretends otherwise, including ours.

Run vault rotation automatically on member removal: in a seal-to-public-key model, revocation is rotation, because the removed member may have cached K_vault.

Class boundary to hold: membership of a vault on the owner's own instance is the access-control list the instance consults — not a content-derived identifier. That distinction is what keeps us A1 rather than A2. If an implementation choice starts eroding it, stop and flag it rather than designing around it.

Known and already published: wrap rows reveal that two accounts share a vault, never what is in it. It is in the README's trade-offs.

Branch: web/shared-vaults.

🤖 claiming — claude~paul@keymail.dev — web/shared-vaults — 2026-09-02T23:46Z

Stacked on web/device-ladder. Taking the two prerequisites Fable named in #23 first, since both are inside this item's scope: compare-and-swap on item writes (there is no version precondition today, so two members editing one item lose an edit silently), and a vault epoch in the AAD so rotation-on-removal is one atomic transaction.

🤖 Shared vaults are built and live on the canary: https://contra.bdj.oncarlos.com running 84f891e. Branch web/shared-vaults, stacked on web/device-ladder. CI green on the shared runner.

Two accounts, one browser profile, sharing only the session between them: the guest reads an item the owner sealed, and stops being able to after the owner removes them. That walk runs in a real engine on every push.

The shape

A vault keyset is sealed to each member's account public key by ephemeral-static ECDH — the same ECIES the device handoff introduced, which is why that one landed first. d_acct has been provisioned since signup and had no reader until now.

Sharing needs no unlock on the recipient's side and no key on the server's, which is the property the agent surface depends on later.

Two seams, and the split is the security boundary. ownedVault is now owner-only and gates anything that changes who can read; reachableVault (owner or member) gates item reads and writes. Swapping one for the other compiles perfectly and is a hole in either direction — ownedVault on items locks members out of what they were shared, reachableVault on membership lets any member evict any other. Both directions are tested.

The two AADs are different shapes on purpose. The owner's copy is scoped to the account and names the vault; a member's is scoped to the vault and names the member. So neither blob opens as the other, and a member's wrap cannot be replayed at another vault or under another member's name.

*A member wrap carries K_vault and not the drop-box private half.* A member reads and writes items without being able to open captures addressed to the vault. That is a one-word change away from being wrong and nothing else would notice, so it has its own test.

Revocation

One transaction: re-seal every item under the new key, reissue every remaining member wrap, drop the rest, bump the epoch — or none of it. A rotation naming an item that vanished under it rolls back rather than limping on, because half a rotation is a vault whose items no longer match the key its members hold.

Removal has no endpoint of its own. It is a rotation 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.

The screen carries all three parts of DESIGN.md's answer before the button, including the one every product in this category quietly omits: anything they already downloaded is still theirs. The removal is gated on a checkbox acknowledging precisely that.

Two prerequisites from #23, both inside this item's scope

Compare-and-swap on item writes — this was a bug, not a hardening. store.UpdateItem had no version precondition and wrote whatever the client sent. Invisible with one device; with two, or two members, it is silent total data loss — both open v3, both edit, both write v4, and the second erases the first with nothing recording that an edit existed. Now AND version = ? with a 409 carrying the current version.

The epoch, but NOT in the AAD — a deliberate divergence from #23. Binding it into every item's AAD would be redundant: rotation mints a new K_vault, so an item sealed under the old key already fails to open under the new one. The cryptographic binding is the key itself. What the epoch adds is a server-side precondition, so a stale write is refused with something a client can act on rather than stored as a row nobody can read. Putting it in the AAD would change the pinned AAD format and every golden vector to buy a property already held. Written down in sharing.go rather than left looking like an oversight.

Finding a person

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. A test asserts there is no address-shaped way in.

Your own id is on the vault page to give out. There is no directory, and that is the feature.

What the browser walk caught

The vault page had only ever opened vaults[0] — indistinguishable from correct while there was one vault. With a shared vault in the list, a member's own empty vault sorts first, so the vault they had been given never appeared and the whole feature was invisible to the person it was for. There is now a picker, shown only when there is a choice, labelled from names that come out of the keysets.

That is the argument for the browser rig: nothing in the Go or node suites could have noticed, because both were right.

Class boundary held

Membership rows are the access-control list the instance consults to decide who it serves sealed bytes to — transport shape on the owner's own instance, not a content-derived identifier. Nothing here erodes it. The published residue is unchanged: wrap rows reveal that two accounts share a vault, never what is in it.

Not done

  • Cross-instance sharing. Still out, as AGENTS.md says: the biggest scope trap.
  • A member cannot leave a vault themselves. Only the owner can remove them. Worth adding.
  • Rotation is O(items) in one request. Fine at the sizes we have; a vault with a 1PUX import behind it will want batching, which is #23's change 5.