Sealed-write — E2EE ingestion from a keyless writer
This is the answer to: can an external webhook post into an end-to-end-encrypted space without holding the space's keys? Yes — by publishing a space write key and letting the webhook encrypt to it.
The problem
Starfish's delegated encryption wraps content with a shared symmetric CEK that
every reader also holds. To produce a valid ciphertext you need the CEK — so in that
model, anyone who can write can also read. A webhook is exactly the party you do
not want to grant read access: it should be able to inject a message, never to
decrypt the history.
The mechanism
Sealed-write builds a write-only party on the keyring's existing anonymous-sender
primitive (seal/unseal: ephemeral X25519 → HKDF-SHA256 → AES-256-GCM):
-
Mint a space write key.
import { generateSpaceWriteKey } from "@drakkar.software/starfish-webhook"const { kemPubHex, kemPrivHex } = generateSpaceWriteKey()kemPubHex(public) is published openly — e.g. in a plaintext space-config document any reader (and the webhook) can fetch.kemPrivHex(private) is distributed to members out of band — in practice wrapped into the space keyring alongside the CEK, so every member recovers it exactly the way they already recover the CEK.
-
The webhook seals to the public key. Configure the route with
seal+ asealeridentity:routes: {bridge: {secret,transform,target: "/push/pubspaces/owner/space/streams/room",author: { edPubHex: BOT_PUB, edPrivHex: BOT_PRIV },seal: { recipientKemPubHex: kemPubHex },sealer: { edPubHex: BOT_PUB, edPrivHex: BOT_PRIV },},}The message is sealed at the receiver, so a plain (
none) collection stores only ciphertext ({ entry, ct }). The server never sees the cleartext. -
Members open it.
import { openSealedDocument, isSealedBlob } from "@drakkar.software/starfish-webhook"for (const el of pulledItems) {const msg = isSealedBlob(el.data)? await openSealedDocument(el.data, kemPrivHex, { requireSealer: BOT_PUB }): el.data // a plaintext write from a normal client}requireSealerpins provenance: the open throws unless the blob's wrap entry was signed by the webhook's key, so a member can trust who injected the message against any outside party. (The signature commits to the wrapped content key, not the message bytes; forging a different message under a pinned sealer would require the per-message content key, which is fresh-random and known only to the sealer and key-holders. A member — who can already post — is therefore out of this guarantee's scope; it defends against non-members, not against insiders.)
What each party can do
| Party | Holds | Can write? | Can read? |
|---|---|---|---|
| Webhook | kemPubHex + its own signing key | ✅ seal to the space | ❌ never |
| Member | kemPrivHex (via keyring) | ✅ (and decrypt) | ✅ |
| Server | nothing | — | ❌ ciphertext only |
Revocation
The space write key rotates like the CEK: mint a new pair, publish the new public
half, re-distribute the new private half to current members. A removed member who
kept the old kemPrivHex can read webhook messages only up to the rotation — the
same property the CEK already has.
Trust boundary
The E2EE guarantee is "from the receiver onward". An external sender already transmits cleartext to whatever URL it's given, so the webhook receiver is the encryption edge: it holds only the public key, seals there, and from that point the Starfish server stores and serves ciphertext that only members can open. For this to be real E2EE the sealing must happen at that edge — not inside the core sync server, which would otherwise observe plaintext. Metadata (timing, size, target room) remains visible, exactly as for any message.
Mixing sealed and plaintext
A collection can hold both: normal clients write plaintext (or CEK-encrypted, in a
delegated collection), and the webhook writes sealed blobs. isSealedBlob lets a
reader branch per element, so the two coexist in one log.