🤖 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.