Skip to content

Envelopes

The core path. A server accepts CryptidEnvelopes, queues them against the address they name, and hands them to whoever proves ownership of that mailbox. It never learns what any of them contain, who sent them, or which belong together.

CryptidEnvelope Structure
struct CryptidEnvelope {
// Routing string only, "prefix@server". The server needs nothing more.
// Mailbox public key is verified once at registration, not per message.
recipient_address: String,
// HPKE Base mode (ephemeral-static) to the recipient's mailbox public key,
// with recipient_address bound as AAD so a captured envelope cannot be
// re-injected at a different mailbox. No sender key is involved, so the
// sender stays anonymous even if the mailbox key is compromised.
encrypted_blob: Vec<u8>,
}

Queues one envelope for delivery.

  • Input: CryptidEnvelope
  • Output: SubmitReceipt { message_id, accepted_at }
  • Auth: PrivacyPass
  • Idempotent: No
  • Errors: TOKEN_INVALID, TOKEN_REPLAYED, TOKEN_MISSING, MAILBOX_UNKNOWN, MAILBOX_OVERLOADED, PAYLOAD_TOO_LARGE
  • Submission MUST spend one token
  • Servers MUST NOT inspect, parse, or validate encrypted_blob
  • Servers MUST reject an envelope naming an unregistered mailbox
  • Servers MUST assign accepted_at themselves and MUST NOT accept a sender-supplied timestamp
  • Servers MUST assign message_id as a UUIDv7
  • Servers MUST forward an envelope naming a remote address to that server
  • Servers MUST NOT record anything about the submitter

Fan-out happens on the client: MLS encrypts once for the group, then the outer layer is sealed once per recipient device. A sender submits one envelope per recipient, each sealed to that recipient’s mailbox key with its address as AAD.

Failover is not fan-out. A device holding several mailboxes for one group publishes them in preference order, and a sender attempts the primary first - but a secondary is a fallback, not an additional recipient.

  • Senders MUST attempt the primary address first
  • Senders SHOULD fail over on MAILBOX_UNKNOWN, a connection failure, or a timeout
  • Senders MUST NOT fail over on MAILBOX_OVERLOADED or SERVICE_UNAVAILABLE, and SHOULD retry the primary
  • Each attempt requires re-sealing, since both the AAD and the mailbox key differ per address

Tokens are spent at redemption, so an attempt that never reached a server costs nothing. Consult token_consumed on the error rather than assuming.

POST /api/v1/envelopes
Authorization: PrivateToken token="<base64url>"
Content-Type: application/cryptid+tls

Retrieves queued envelopes for one mailbox.

  • Input: FetchRequest { since, limit }
  • Output: Vec<QueuedEnvelope>
  • Auth: Mailbox ownership session
  • Idempotent: Yes
  • Errors: SESSION_INVALID, MAILBOX_UNKNOWN
  • The session MUST authorize exactly the mailbox being fetched
  • Servers MUST return envelopes in queue order
  • Servers MUST NOT delete on read; deletion is by acknowledgement or expiry
  • Servers MUST NOT log the source address of a fetch
  • Fetching spends no token

Each returned envelope carries its message_id, its encrypted_blob, and the server’s received_at. It does not carry recipient_address or expires_at - the session already fixes the mailbox, and retention is the server’s bookkeeping.

since is the last message_id the client received. Because message_id is a UUIDv7, it sorts by creation time, so the cursor needs no separate sequence number and no server-side per-client state.

  • since MUST be a message_id the server previously issued for this mailbox
  • Omitting since MUST return from the head of the queue
  • Servers MUST treat an unknown since as MAILBOX_UNKNOWN rather than silently returning from the head, which would replay the whole queue
  • Servers MUST NOT store a per-client cursor; the client holds it
GET /api/v1/envelopes?since={cursor}&limit={n}
Authorization: MailboxSession <token>

Confirms envelopes have been durably received, so the server can discard them.

  • Input: AckRequest { message_ids }
  • Output: AckReceipt { discarded }
  • Auth: Mailbox ownership session
  • Idempotent: Yes
  • Errors: SESSION_INVALID
  • Servers MUST discard an acknowledged envelope
  • Servers MUST treat an unknown or already-acknowledged message_id as success
  • Servers MUST NOT require acknowledgement for correctness; unacknowledged envelopes expire at MAX_MESSAGE_RETENTION regardless
  • Clients SHOULD acknowledge only after the envelope is durably stored locally, not on receipt
POST /api/v1/envelopes/ack
Authorization: MailboxSession <token>

Opens a live stream of envelopes for one mailbox.

  • Input: SubscribeRequest { } - the session names the mailbox
  • Output: A stream of QueuedEnvelope
  • Auth: Mailbox ownership session
  • Idempotent: N/A
  • Errors: SESSION_INVALID, MAILBOX_UNKNOWN
  • A subscription MUST cover exactly one mailbox
  • Servers MUST NOT accept a subscription list, and MUST NOT accept one session as authorization for another mailbox
  • Servers MUST deliver the backlog before live envelopes, in queue order
  • Servers MUST accept acknowledgements over the same stream
  • Subscribing spends no token

This operation is defined over a session, not a connection. It says nothing about sockets, streams, or framing - a binding decides how many connections carry how many subscriptions. See Transports.

WebTransport is the primary binding, WebSocket the fallback where UDP is blocked, and FetchEnvelopes on a timer the last resort. All three carry the same operation.