A protocol for place- and time-gated social event delivery with strong privacy guarantees. PPP enables encrypted, location-aware messaging without exposing message content, sender identity, or location data to relay servers or network observers. The current production stack is iOS plus a Rust relay; the protocol is designed for portability to Android, KaiOS, and J2ME devices, but those clients are not yet implemented — references to them throughout this document describe future targets, not shipped code.
| Term | Definition |
|---|---|
| Event | A place- and time-gated message from a sender to one or more recipients. |
| Presence Event | An event that recipients discover when physically near the specified location within the specified time window. |
| Relay | A server that stores and forwards encrypted events between clients via WebSocket or HTTP. |
| Courier | A client that fetches encrypted events on behalf of others and delivers them locally via BLE when internet is unavailable. |
| Carry Group | A set of users who opt in to having a designated courier fetch and deliver events for them. |
| Gift Wrap | A three-layer encryption pattern that hides both content and sender identity from intermediaries. |
| Envelope | The outer, relay-visible structure of an event (recipient pubkey, ephemeral key, encrypted blob, timestamps). |
| Seal | The middle encryption layer binding sender identity to encrypted content. |
| Rumor | The inner, unencrypted event content (only visible after full decryption by the intended recipient). |
| Delivery Token | A 12-byte token derived from a pairwise X25519 shared secret between two contacts, used to route anonymous Note submissions without revealing sender identity to the relay. |
| Sealed Sender | A submission mode where the sender POSTs an encrypted event via anonymous HTTP, identified only by a delivery token, so the relay cannot determine which pubkey submitted it. |
Think of PPP like sending sealed letters through a post office. The Relay is the post office — it knows where to deliver the letter but can never open it. A Courier is like a friend who picks up your mail and hand-delivers it when the post office can't reach you.
The three encryption layers (Rumor, Seal, Gift Wrap) are like putting a letter in an envelope, sealing it in a second envelope with your signature, then placing that inside a third, anonymous mailer. Even the post office doesn't know who sent it.
User identity MUST be based on Ed25519 public/private key pairs. The public key serves as the user's protocol-level identifier.
User identity MUST NOT be bound to any specific relay or server. A user MUST be able to switch relays without changing identity.
The protocol SHOULD support key rotation, allowing a user to migrate to a new key pair while maintaining continuity with existing contacts.
Each user generates an Ed25519 key pair on first launch:
seed = randombytes(32)
(pk, sk) = crypto_sign_ed25519_seed_keypair(seed)
pk (32 bytes): the user's public identity, used as their
protocol address.sk (64 bytes): stored in platform-secure storage (Keychain
on iOS, Keystore on Android).seed (32 bytes): the backup/recovery secret. Users
SHOULD be prompted to back up their seed
phrase (BIP-39 mnemonic or raw hex).Ed25519 keys are converted to X25519 for Diffie-Hellman key exchange:
x25519_pk = crypto_sign_ed25519_pk_to_curve25519(ed25519_pk)
x25519_sk = crypto_sign_ed25519_sk_to_curve25519(ed25519_sk)
This allows a single key pair to serve both signing (Ed25519) and encryption (X25519) purposes.
A key rotation event is signed by the old key and contains the new public key:
KeyRotation {
old_pubkey: bytes32 // signing key (== author)
new_pubkey: bytes32 // replacement key
timestamp: uint64
signature: bytes64 // sign(new_pubkey || timestamp, old_sk)
}
Recipients who receive and verify this event update their local contact mapping. The relay MAY store key rotation events for a configurable retention period to allow offline recipients to catch up.
Ed25519 was chosen for three reasons:
Your identity in PPP is a cryptographic key pair generated on your device — not a username, phone number, or account on a server. This means no one can impersonate you without access to your device, and you can switch between different relay servers without losing your identity.
The seed is like a master password that can regenerate your keys. If you lose your device, the seed is the only way to recover your identity.
All event content MUST be end-to-end encrypted between sender and recipient(s). No intermediary (relay, courier, network observer) SHALL be able to read event payloads.
All cryptographic operations use libsodium primitives. Implementations on platforms without libsodium MUST use compatible algorithms (X25519, XChaCha20-Poly1305, Ed25519) from equivalent libraries (e.g., TweetNaCl).
Algorithm: XChaCha20-Poly1305 (AEAD)
Nonce: 24 bytes, randomly generated per encryption
Key: 32 bytes
Auth tag: 16 bytes (appended to ciphertext by libsodium)
encrypt(plaintext, nonce, key) -> ciphertext || tag
decrypt(ciphertext || tag, nonce, key) -> plaintext | error
libsodium function: crypto_aead_xchacha20poly1305_ietf_encrypt/decrypt
Algorithm: X25519 Diffie-Hellman
Output: 32-byte shared secret
shared_secret = crypto_scalarmult(my_x25519_sk, their_x25519_pk)
The shared secret is passed through a KDF before use as an encryption key:
encryption_key = crypto_generichash(
32, // output length
shared_secret, // input
"ppp-event-key-v1" // context/salt
)
The Gift Wrap pattern uses three layers to hide both content and sender identity from the relay. This is the REQUIRED encryption method for all events.
Layer 1 — Rumor (cleartext content):
The rumor is the actual event payload. It is NOT signed, providing sender deniability at the content layer.
Rumor {
sender: bytes32 // author's real Ed25519 pubkey
kind: uint8 // event type (see S4)
content: EventContent // type-specific payload
created_at: uint64 // unix timestamp
}
Layer 2 — Seal (encrypted + signed by sender):
The seal encrypts the rumor with a shared secret derived from an ephemeral key and the recipient's key. It proves authorship to the recipient.
ephemeral_seal = crypto_sign_ed25519_seed_keypair(randombytes(32))
seal_shared = crypto_scalarmult(
ephemeral_seal.x25519_sk,
recipient.x25519_pk)
seal_key = kdf(seal_shared, "ppp-seal-v1")
seal_nonce = randombytes(24)
sealed_rumor = encrypt(serialize(rumor), seal_nonce, seal_key)
Seal {
ephemeral_pk: bytes32 // ephemeral_seal.ed25519_pk
nonce: bytes24
ciphertext: bytes // sealed_rumor
sender_sig: bytes64 // sign(sealed_rumor, sender.ed25519_sk)
}
The signature binds the sender's identity to the sealed content. The
recipient verifies sender_sig using rumor.sender
(available after decrypting the seal).
Layer 3 — Gift Wrap (encrypted with ephemeral key):
The wrap encrypts the seal with a SECOND ephemeral key pair. The relay sees only this outer layer.
ephemeral_wrap = crypto_sign_ed25519_seed_keypair(randombytes(32))
wrap_shared = crypto_scalarmult(
ephemeral_wrap.x25519_sk,
recipient.x25519_pk)
wrap_key = kdf(wrap_shared, "ppp-wrap-v1")
wrap_nonce = randombytes(24)
wrapped_seal = encrypt(serialize(seal), wrap_nonce, wrap_key)
GiftWrap {
recipient: bytes32 // recipient's Ed25519 pubkey
ephemeral_pk: bytes32 // ephemeral_wrap.ed25519_pk
nonce: bytes24
ciphertext: bytes // wrapped_seal
created_at: uint64 // relay-facing timestamp
expires_at: uint64 // event TTL
}
Decryption (recipient side):
1. wrap_shared = crypto_scalarmult(my_x25519_sk, giftwrap.ephemeral_pk)
2. wrap_key = kdf(wrap_shared, "ppp-wrap-v1")
3. seal = decrypt(giftwrap.ciphertext, giftwrap.nonce, wrap_key)
4. seal_shared = crypto_scalarmult(my_x25519_sk, seal.ephemeral_pk)
5. seal_key = kdf(seal_shared, "ppp-seal-v1")
6. rumor = decrypt(seal.ciphertext, seal.nonce, seal_key)
7. verify = crypto_sign_verify(seal.sender_sig,
seal.ciphertext, rumor.sender)
What each party sees:
| Party | Visible Data |
|---|---|
| Relay (auth'd STORE) | Sender pubkey (from auth session), recipient pubkey, ephemeral wrap key, opaque blob, timestamps |
| Relay (sealed sender) | Delivery token → recipient pubkey, ephemeral wrap key, opaque blob, timestamps. Sender pubkey NOT visible. |
| Courier | Recipient pubkey, ephemeral wrap key, opaque blob, timestamps |
| Network observer | Encrypted WebSocket/TLS traffic |
| Recipient | Full rumor: sender, content, location, time |
Two layers would suffice for confidentiality, but the third (Gift Wrap) layer provides sender anonymity. The Seal proves authorship to the recipient, while the GiftWrap hides the Seal's contents — including the sender signature — from the relay.
This pattern is adapted from NIP-59 (Nostr's Gift Wrap), extended with per-event ephemeral keys at both layers.
XChaCha20-Poly1305 was chosen over AES-GCM because:
The event format MUST be independent of transport mechanism. The same serialized event MUST be valid whether delivered via WebSocket, HTTP, BLE, or courier relay.
All protocol messages are serialized using MessagePack. Implementations for constrained devices MAY use CBOR as an alternative, provided field ordering and type mapping are preserved.
Field names are serialized as short integer keys to minimize payload size:
| Key | Field |
|---|---|
| 1 | recipient |
| 2 | ephemeral_pk |
| 3 | nonce |
| 4 | ciphertext |
| 5 | created_at |
| 6 | expires_at |
| 7 | sender |
| 8 | kind |
| 9 | content |
| 10 | sender_sig |
| 11 | group_id |
| 12 | hop_count |
| 13 | max_hops |
| 14 | region_type |
This summary lives inline for narrative flow; the canonical
enumeration is autogenerated in §C.5 from the
EventKind enum and is what you should treat as
authoritative when implementing.
| Kind | Value | Description |
|---|---|---|
| PRESENCE | 0x01 | Place- and time-gated event (primary use case) |
| REVOKE | 0x02 | Sender revokes a previously sent event |
| ACK | 0x03 | Recipient acknowledges receipt |
| KEY_ROTATION | 0x04 | Sender announces a new public key |
| CONTACT_DISCOVERY | 0x05 | Contact hash publication for discovery |
| CARRY_GROUP_INVITE | 0x06 | Invitation to join a carry group |
| CARRY_GROUP_ACCEPT | 0x07 | Acceptance of carry group invitation |
| KEY_EXCHANGE_RETURN | 0x08 | Responder returns their pubkey after a deep-link key exchange (TOFU completion) |
| GROUP_INVITE | 0x09 | Invitation to join a persistent encrypted group (carries room_id and room_key) |
| RELAY_HINT | 0x0a | Sender advertises the relay currently holding their events |
| MEMBER_CERT_REQUEST | 0x0b | Member → admin: request a membership cert over the member's per-room signing pubkey (threshold reveal) |
| MEMBER_CERT_GRANT | 0x0c | Admin → member: granted membership cert |
| ADMIN_DELEGATION_REQUEST | 0x0d | Delegate → admin: offer to co-admin a room (per-room admin keys, §7.8) |
| ADMIN_DELEGATION_GRANT | 0x0e | Admin → delegate: granted inline delegation cert (§7.8) |
PRESENCE (0x01):
PresenceContent {
event_id: bytes16 // unique event identifier (UUID)
message: string // user-visible message text
region: Region // geographic area (see S4.4)
time_window: {
starts_at: uint64 // unix timestamp
ends_at: uint64 // unix timestamp
timezone: string // IANA timezone identifier
}
recipients: [bytes32] // list of recipient pubkeys
}
REVOKE (0x02):
RevokeContent {
event_id: bytes16 // ID of the event to revoke
}
KEY_ROTATION (0x04):
KeyRotationContent {
new_pubkey: bytes32 // the replacement public key
migration_sig: bytes64 // sign(old_pk || new_pk, old_sk)
}
CARRY_GROUP_INVITE (0x06):
CarryGroupInviteContent {
group_id: bytes16 // carry group identifier
group_name: string // human-readable name
members: [bytes32] // current member pubkeys
couriers: [bytes32] // designated courier pubkeys
}
KEY_EXCHANGE_RETURN (0x08): Sent by the
responder back to the initiator after the responder imports the
initiator's public key from a deep link. Carries the responder's
public key so the initiator can auto-import it, completing the
bidirectional handshake without a second out-of-band exchange.
The initiator_pubkey field binds the response to a
specific exchange — an attacker who replays a captured
KEY_EXCHANGE_RETURN at a different initiator cannot make the
import succeed.
KeyExchangeReturnContent {
initiator_pubkey: bytes32 // initiator's Ed25519 pubkey (binds the response)
responder_pubkey: bytes32 // responder's Ed25519 pubkey (the key being returned)
}
GROUP_INVITE (0x09): Invitation to join a
persistent encrypted group (§7.6). Delivered via Gift Wrap
to an existing contact; carries the symmetric
room_key the recipient needs to decrypt subsequent
group messages. Any contact who can be reached via Gift Wrap can
be invited.
GroupInviteContent {
room_id: bytes16 // group room identifier
room_key: bytes32 // symmetric XChaCha20-Poly1305 key
group_name: string // human-readable name
}
RELAY_HINT (0x0a) through ADMIN_DELEGATION_GRANT (0x0e): content schemas for the remaining kinds are enumerated in §C.5; the membership-cert pair (0x0b/0x0c) belongs to the threshold-reveal reporting design and the delegation pair (0x0d/0x0e) to per-room admin authority (§7.8). All four are ordinary GiftWrapped events — the relay cannot distinguish them from any other Note.
Geographic coordinates and region data MUST NOT appear in plaintext in any protocol message visible to relays, couriers, or network observers.
The protocol MUST enforce minimum and maximum radius bounds on circle regions to prevent location deanonymisation and keep relay scope bounded.
The region defines the geographic area where an event is discoverable. The
protocol uses a circle as the baseline representation, with a
version-extensible region_type field for future area representations.
Region {
region_type: uint8 = 0 // 0 = circle
latitude: float64 // center latitude (WGS84)
longitude: float64 // center longitude (WGS84)
radius_m: uint32 // radius in meters [10 .. 50,000]
}
Wire overhead: 1 + 8 + 8 + 4 = 21 bytes.
| Bound | Value | Rationale |
|---|---|---|
| Minimum | 10 m | Prevents point-location deanonymisation. Sub-10 m radii effectively reveal exact GPS coordinates. |
| Maximum | 50 km | Keeps relay scope bounded. Broader coverage should use broadcast channels. |
Latitude MUST be in [-90, 90] and longitude MUST be in [-180, 180] (WGS84 decimal degrees).
Receiver-side presence check (haversine):
d = haversine(my_lat, my_lon, region.latitude, region.longitude)
is_inside = (d <= region.radius_m)
The haversine formula requires only basic trigonometry and is implementable in ~15 lines in any language, including J2ME CLDC 1.1.
| region_type | Representation | Use Case |
|---|---|---|
| 0 | Circle (lat/lon/radius) | v0.1 baseline |
| 1 | Geohash cell set | Arbitrary drawn areas |
| 2-127 | Reserved | Future protocol versions |
Compatibility rule: Clients that encounter an unknown
region_type MUST fall back to the
bounding circle if present, or discard the event if no bounding circle is
provided.
Event IDs are 32-byte BLAKE2b hashes of the GiftWrap envelope.
Two derivation forms exist, depending on whether the envelope arrives
via the authenticated WebSocket (STORE) or the
unauthenticated sealed-Note HTTP endpoint
(POST /v1/note/submit). Both are deterministic from
relay-visible fields, so the relay can deduplicate without
decrypting anything.
Authenticated path (STORE / FETCH / room messages):
event_id = BLAKE2b(32,
giftwrap.ephemeral_pk || giftwrap.nonce || giftwrap.ciphertext
)
Sealed-Note path (POST /v1/note/submit):
event_id = BLAKE2b(32,
"ppp-note-event-id-v1"
|| ephemeral_pk || nonce || ciphertext
|| delivery_token || created_at_be64
)
Both forms MUST produce a 32-byte
output. The relay MUST reject a
STORE for an event_id already present in storage.
Events are the core unit of the protocol — a message tied to a place and time. When you drop a note on the map, that's a PRESENCE event. The location data is always inside the encrypted layers, so the relay never knows where the note is placed.
Using integers (1, 2, 3...) instead of string field names ("recipient", "ephemeral_pk"...) saves 5-15 bytes per field in MessagePack encoding. For a ~500 byte event, this is a meaningful reduction — especially over BLE where every byte counts.
The authenticated path identifies the recipient by the
recipient field of the GiftWrap, so the
ephemeral_pk || nonce || ciphertext triple is already
unique within that recipient's mailbox. The sealed-Note path
never sees a recipient pubkey on the wire (that's the whole point
of sealed sender), so the same triple could collide if the same
ciphertext is replayed against two different delivery tokens.
Adding the delivery_token binds the ID to the
(sender, recipient) pair, and created_at_be64
disambiguates legitimate re-sends. The
"ppp-note-event-id-v1" domain tag prevents an
attacker from pre-computing IDs that would collide with the
authenticated path.
The relay stores only encrypted blobs. It cannot filter events by location or draw a map of where events are placed. All proximity checks happen on the recipient's device after decryption — the client computes whether it is inside the circle.
The 10m minimum radius prevents pinpointing someone's exact location. A 10m circle could be anywhere within a building; a 1m circle is a specific desk.
The protocol MUST define a WebSocket-based transport as the primary real-time channel between clients and relays.
Clients MUST authenticate to the relay to prevent unauthorized access to stored events. Authentication uses Ed25519 challenge-response.
Connection endpoint: wss://<relay-host>/v1/ws
All WebSocket frames are MessagePack-encoded and tagged with a
cmd discriminator. Client → relay commands are
named maps carrying a cmd field
({"cmd": "STORE", ...}); relay → client messages
are compact positional arrays whose first element is the
cmd tag (["STORED", <bin32>]).
The sketches below are written map-style for readability; field
order is normative for server messages.
On connection, the relay issues a challenge:
Relay -> Client:
{ cmd: "CHALLENGE", nonce: bytes32 }
Client -> Relay:
{ cmd: "AUTH", pubkey: bytes32, sig: bytes64 }
where sig = crypto_sign_detached(nonce, client_ed25519_sk)
Relay -> Client:
{ cmd: "AUTH_OK" }
or
{ cmd: "AUTH_FAIL", reason: string }
After AUTH_OK, all subsequent commands are scoped to the authenticated pubkey.
STORE — submit an event for delivery:
Client -> Relay:
{ cmd: "STORE", event: GiftWrap }
Relay -> Client:
{ cmd: "STORED", event_id: bytes32 }
or
{ cmd: "STORE_FAIL", reason: string, in_reply_to?: string }
The relay validates: the GiftWrap deserializes and its recipient
pubkey is well-formed; the event is not already expired
(expires_at is additionally clamped to
now + MAX_EVENT_TTL_SECS, 30 days); the ciphertext is
non-empty and at most MAX_CIPHERTEXT_SIZE (64 KiB);
the event's total MessagePack wire cost fits one FETCH page
(MAX_FETCH_PAGE_BYTES, 192 KiB) — an event that
could never be delivered is rejected at ingress rather than
wedging the mailbox; the recipient is under the
500-active-envelope quota
(MAX_ACTIVE_ENVELOPES_PER_RECIPIENT; hard rejection,
no eviction); and event_id is not already stored.
The relay does NOT need to decrypt the event.
STORE_FAIL is the generic failure reply despite its name:
ACK, CREATE_ROOM, KNOCK and most
other commands fail with it too. in_reply_to carries the
name of the client command being answered
("STORE", "ACK", …). A relay
SHOULD set it on every failure emitted
from the authenticated WebSocket dispatch path.
Clients with more than one request in flight
MUST NOT assume a failure belongs to the
oldest or the most recent one. Without in_reply_to the
reply is genuinely ambiguous — nothing else on the wire distinguishes
them — and a client that guesses will surface the error on the wrong
request. The failure of a fire-and-forget command such as
ACK has no waiter at all, so a guessing client mis-delivers
it every time.
The field is optional for compatibility: it is omitted when unset, and
because server messages encode as positional MessagePack arrays, the
omitted form is byte-identical to the pre-in_reply_to
encoding. Clients MUST accept both the
two- and three-element forms, and
SHOULD treat an absent value as
"unknown" rather than as a specific command.
FETCH — retrieve events for the authenticated pubkey:
Client -> Relay:
{ cmd: "FETCH", since: uint64, cursor?: FetchCursor }
Relay -> Client:
{ cmd: "EVENTS",
events: [bin], // MessagePack-serialized GiftWraps
cursor: FetchCursor | nil, // next page; always present
stored_ats: [uint64], // relay ingestion time per event
server_now: uint64, // relay clock at response time
receipts: [ReceiptWire] } // delivery receipt per event
FetchCursor { created_at: uint64, stored_at: uint64, event_id: bytes32 }
since is a unix timestamp; the relay returns events with
created_at > since. Pagination is dual-bounded: a
page ends after 100 events or once its MessagePack wire
cost would exceed MAX_FETCH_PAGE_BYTES (192 KiB),
whichever comes first — at least one event is always
emitted, which is safe because STORE rejects events larger than a
page. Clients MUST follow a non-nil
cursor by echoing it in the next FETCH rather than
adjusting since. stored_ats /
server_now let clients age events against the
relay's clock instead of the device clock, and
receipts carries the relay-signed delivery receipt
per event; both are empty on relays that pre-date them.
FETCH_CARRY Deferred — courier fetches for a carry group:
Client -> Relay:
{ cmd: "FETCH_CARRY", group_id: bytes16,
since: uint64, sig: bytes64 }
Relay -> Client:
{ cmd: "CARRY_EVENTS", events: [GiftWrap] }
The carry-group dispatch is part of the courier roadmap (see §8) and is not implemented in the current relay; FETCH_CARRY is unrouted today. Clients MUST NOT assume it works against a production relay.
ACK — confirm receipt of events:
Client -> Relay:
{ cmd: "ACK", event_ids: [bytes32] } // at most MAX_ACK_IDS (1024)
Relay -> Client:
{ cmd: "ACKED", count: uint }
An ACK carrying more than MAX_ACK_IDS (1024) ids is
rejected; the batch is applied in a single transaction
(§C.2 has the sizing rationale).
NOTIFY — relay pushes a notification:
Relay -> Client:
{ cmd: "NOTIFY", count: uint }
Sent when new events arrive for the authenticated pubkey. The client then issues a FETCH.
The HTTP polling transport is specified here so that future feature-phone and constrained-network clients have a target to implement against, but the current relay does not route the endpoints below. Today only the WebSocket transport (§5.1) and the two sealed-sender HTTP endpoints (§6.3, §7) are live. Adding HTTP-polling parity is tracked as part of Phase 4.
The protocol MUST define an HTTP polling API that provides equivalent functionality for devices without persistent connections.
Authentication via Authorization header:
Authorization: PPP-Ed25519 <pubkey_hex>:<sig_hex>
where sig = crypto_sign_detached(
request_body || timestamp, sk)
| Endpoint | Method | WS Equivalent |
|---|---|---|
/v1/events | POST | STORE |
/v1/events?since={ts} | GET | FETCH |
/v1/events/carry?group={id}&since={ts} | GET | FETCH_CARRY |
/v1/events/ack | POST | ACK |
Clients SHOULD poll at a default interval of
30 seconds. The relay MAY include a
Retry-After header.
Client -> Relay:
{ cmd: "REGISTER_PUSH",
platform: "apns" | "fcm",
token: string }
Push payloads MUST NOT contain event content — they serve only as wake signals for the client to FETCH.
The relay MUST validate the token's
alphabet, not merely its length, and per platform:
apns tokens MUST be
hexadecimal, fcm tokens
MUST be limited to alphanumerics and
- _ : . — FCM tokens are not hex, so a single
alphabet for both would reject every valid one. A relay
MUST reject a token failing its
platform's alphabet with STORE_FAIL.
This is not cosmetic. The token is interpolated into the push
provider's request path, so a token containing path syntax
(../../../../4/broadcasts/apps/…) can steer the
relay's request at a different provider endpoint. A relay
MUST additionally construct that URL
from percent-encoded path segments rather than by string
interpolation, so that neither control alone is load-bearing.
The relay MUST support
multiple registered tokens per pubkey: the
push_registrations table is keyed on
(pubkey, token), not on pubkey alone.
One identity can be active on a phone, a tablet, and a watch
simultaneously, and a wake signal fans out to every registered
token. When the push provider responds with a permanent failure
(APNs 410 Unregistered; FCM equivalent), the relay
MUST delete the affected
(pubkey, token) row so dead devices stop accruing
send attempts.
Pushes fire on the following triggers, and only when the target recipient or admin has no active WebSocket attached to the relay:
SMS bridging is in the design but is not implemented:
REGISTER_SMS is not in the wire surface (§C.1)
and the relay has no SMS provider integration. Specified here so
that a future feature-phone client has a target.
Client -> Relay:
{ cmd: "REGISTER_SMS", phone_hash: bytes32 }
SMS content is limited to a generic notification and MUST NOT contain event content, sender identity, or location data.
A single-instance relay uses SQLite (WAL mode) for event persistence. The relay stores only opaque GiftWrap envelopes — it cannot decrypt content, identify senders, or filter by location.
event_id primary key.(recipient, created_at)
with partial index excluding acknowledged events.acked_at timestamp; purge after
grace period.expires_at, acked events past
their grace period, expired rooms (cascading to messages,
tokens, knocks, and membership records), expired knocks, and
push registrations not refreshed within 90 days.The two unauthenticated sealed-sender HTTP endpoints
(POST /v1/room/submit and
POST /v1/note/submit) accept requests without an AUTH
handshake, so per-pubkey rate limits do not apply. The relay
MUST impose a sliding per-IP budget
on these endpoints (SEALED_IP_RATE_LIMIT in
§C.2). The per-token limit
(SEALED_TOKEN_RATE_LIMIT) remains the second line of
defense for callers behind shared NAT or proxies.
Because the relay is intended to sit behind a TLS-terminating
reverse proxy (Caddy in the reference deployment), the IP
attributed to a request is derived from the Forwarded
/ X-Forwarded-For chain rather than the connection
peer. The rules:
PPP_TRUSTED_PROXIES; defaults to loopback), the
peer's address is the client IP and any forwarding header is
ignored.X-Forwarded-For entries from rightmost to leftmost
and skip while the entry is itself in the trust set. The first
non-trusted address encountered is the client IP.Operators putting the relay behind a non-loopback proxy
MUST configure
PPP_TRUSTED_PROXIES — otherwise every sealed
request is attributed to the proxy and the IP budget is shared
across all clients.
For every authenticated WebSocket command the relay generates a
random 6-byte (12-hex-character) request_id and
includes it in structured server-side logs.
STORE_FAIL responses for internal errors
surface the request ID in the sanitized reason string
("internal error (req=<id>)") so that an
operator presented with a client-side error can grep server logs
without exposing user identifiers; validation failures pass
through with their descriptive reason and no ID. The ID is process-local and not signed; it is a
debugging aid, not a wire contract.
Request IDs are not generated for the sealed-sender HTTP endpoints. Logging an ID per sealed request would create a per-message side channel that partially defeats sender unlinkability; sealed endpoints log only coarse outcomes (token OK, token rejected, rate-limited) without per-request identifiers.
The relay is a simple store-and-forward server. Client A connects, proves identity via a cryptographic challenge, then submits an encrypted event. The relay holds it until Client B connects, proves their identity, and fetches it. The relay never sees the contents.
Think of it as a dead drop: you leave a locked box, someone else picks it up with their key.
SQLite provides proven B-tree lookups, fsync batching, and WAL-mode concurrency with zero external dependencies — the relay is a single binary with a single database file. No PostgreSQL, Redis, or message queue infrastructure required.
The relay MUST NOT be able to determine the sender of any event. Sender identity MUST be included only inside the encrypted payload.
The protocol SHOULD include mechanisms to reduce the relay's ability to correlate event delivery with user identity and activity patterns.
When a client submits a Note via authenticated STORE, the relay learns which pubkey sent it — breaking sender anonymity at the transport layer. Sealed sender eliminates this by allowing anonymous HTTP submission with a delivery token as the only identifier.
shared_secret = X25519(alice_x25519_sk, bob_x25519_pk)
= X25519(bob_x25519_sk, alice_x25519_pk)
delivery_token = BLAKE2b(
output_len: 12,
key: shared_secret,
input: recipient_pk
|| "ppp-note-delivery-v1"
)
Properties:
Client -> Relay:
{ cmd: "REGISTER_DELIVERY_TOKEN",
delivery_token: bytes12 }
Relay -> Client:
{ cmd: "DELIVERY_TOKEN_REGISTERED" }
MAX_DELIVERY_TOKENS_PER_RECIPIENT, §C.2 —
lifted from 100 on 2026-05-01 for courier delegation).POST /v1/note/submit
Content-Type: application/msgpack
{
delivery_token: bytes12,
ephemeral_pk: bytes32,
nonce: bytes24,
ciphertext: bytes,
created_at: uint64,
expires_at: uint64
}
Processing flow:
delivery_token → recipient_pk.
Unknown token → 401.SEALED_TOKEN_RATE_LIMIT
in §C.2). Exceeded → 429.expires_at > now; clamp
expires_at to at most
now + MAX_EVENT_TTL_SECS (§C.2).event_id per §4.5
— this includes the delivery_token and
created_at, and is not the same formula as
the authenticated path.MAX_ACTIVE_ENVELOPES_PER_RECIPIENT) → 507.Sealed sender is an optimization. Clients MUST support fallback to authenticated STORE when:
Fallback degrades privacy but never blocks delivery. Clients SHOULD retry sealed submission before falling back.
The anonymity set is bounded by the number of contacts with registered tokens for the recipient. If Bob has only 3 contacts, the relay knows the sender is one of 3 people.
| Level | Hides | Mechanism | Status |
|---|---|---|---|
| 1 | Sender identity | Delivery tokens + anonymous HTTP POST | Implemented |
| 2 | Recipient identity | Blinded mailbox IDs + epoch rotation | Deferred |
| 3 | Both | Level 1 + Level 2 combined | Deferred |
The relay also carries one additional class of opaque encrypted
payload on behalf of the user: a single per-pubkey
backup blob, used so that a client can restore
its event history on a new device without re-fetching every
recipient mailbox. The relay sees only ciphertext, a version
counter, and a server-stamped stored_at; everything
else — the structure of the backed-up data, the symmetric
key, the device-side recovery flow — is client-side and
opaque to the relay.
Three commands form the surface (autogen'd into §C.1):
STORE_BACKUP { ciphertext } — upserts the
single per-pubkey row, increments the version counter. The
ciphertext is bounded by the larger backup-only body limit
(5 MiB) since a backup is a one-shot upload, not a fan-out
event stream.FETCH_BACKUP — returns the latest
{ ciphertext, version, stored_at } for the
authenticated pubkey, or BACKUP_EMPTY if none.DELETE_BACKUP — removes the row.The backup key itself is derived client-side from the user's
BIP-39 mnemonic via BLAKE2b with the
"ppp-backup-v1" domain tag, so a clean device with
only the recovery phrase can decrypt the blob without ever
presenting the long-term identity key to the relay. The relay
rate-limits both endpoints tightly
(STORE_BACKUP and FETCH_BACKUP at
5/hour per pubkey, see §C.3), since legitimate clients
back up infrequently and the larger payload size makes
amplification more attractive than for normal events.
A client can keep its own backups locally or in any cloud storage of its choosing — the relay is not a privileged backup destination. We host the blob path because the relay is already the user's trust-minimized integration point: the same pubkey that authenticates STORE / FETCH authenticates backup, so onboarding a new device after losing the old one does not require provisioning a separate backup credential. The relay learns nothing it didn't already know from the user's session presence.
Normally when you send a message, you first prove your identity to the relay (like showing your ID at the post office). The relay then knows both who sent it and who receives it.
Sealed sender is like using an anonymous drop box — you place the envelope in a slot labeled with a code that only you and the recipient know. The post office can deliver it but has no idea who dropped it off.
96 bits gives negligible collision probability for the expected scale (up to 500 tokens per user). It matches room member token size for consistency, and keeps the HTTP POST payload compact for bandwidth-constrained clients.
Rooms are location-anchored group messaging spaces managed by an admin.
Unlike point-to-point events (which use Gift Wrap encryption addressed to a
single recipient), room messages are encrypted with a symmetric room key
shared among members. The relay stores opaque ciphertext and routes messages
by room_id — it cannot read content or identify senders.
Location representation — circles vs S2 cells: Notes and rooms use different spatial representations because they serve different purposes with different privacy properties:
This means DISCOVER_ROOMS queries reveal to the relay which S2 cell the client is interested in (approximate location), while note regions remain fully confidential. This asymmetry is inherent: rooms trade some location privacy for discoverability by nearby strangers, while notes are exchanged only between known contacts.
Creation:
Client -> Relay:
{ cmd: "CREATE_ROOM",
room_id: bytes16,
s2_cell_id: string, // S2 fine cell for discovery
s2_coarse_id: string, // S2 coarse parent cell
gated: bool, // require membership token
proximity_required: bool, // reserved for future use
ble_enabled: bool, // BLE knock/admit support
max_members: uint32, // 1-100 (schema CHECK)
expires_at: uint64, // must be > now
name?: string, // public room name
description?: string,
description_public?: bool, // default true
admin_auth_pubkey?: bytes32 } // per-room admin authority key A0 (§7.8)
Relay -> Client:
{ cmd: "ROOM_CREATED", room_id: bytes16 }
The authenticated caller becomes the room admin. When
admin_auth_pubkey is supplied the relay records it as
the room's pseudonymous authority key A0 and discovery returns
it rather than the creator's long-term key (§7.8).
The relay indexes the room by S2 cell for geospatial
discovery.
Discovery:
Client -> Relay:
{ cmd: "DISCOVER_ROOMS", s2_cell_id: string }
Relay -> Client:
{ cmd: "ROOMS", rooms: [RoomInfo] }
RoomInfo contains: room_id,
s2_cell_id, admin_pubkey (the room's
per-room authority key A0 when present, else the creator's key
— §7.8), gated,
proximity_required, ble_enabled,
member_count, created_at,
expires_at, name?,
description? (withheld unless
description_public).
The queried token is matched against each room's coarse
cell (s2_coarse_id) and nothing else, so the token a
client sends MUST be at S2 level
DISCOVERY_S2_LEVEL (see the constants table): a token at
any other level resolves to zero rooms, which is indistinguishable
from “no rooms near you”. Clients MUST send a single
token and MUST NOT fan out to neighbouring cells — which neighbours a
client asks for varies with its position inside the cell, so a
fan-out reveals a finer fix than any one token would. Responses
still carry the precise s2_cell_id for client-side
filtering. Only non-expired rooms are returned
(never groups), newest first, at most 100 per query.
Update (admin only):
Client -> Relay:
{ cmd: "UPDATE_ROOM",
room_id: bytes16,
gated: bool | null,
proximity_required: bool | null }
Relay -> Client:
{ cmd: "ROOM_UPDATED", room_id: bytes16 }
Expiry: Rooms expire at expires_at. Expired rooms
reject FETCH and STORE. A background task purges expired rooms, cascading to
messages, tokens, knocks, and membership records
(room_members rows delete with their room, so the
co-presence graph does not outlive it).
Members prove group membership via 12-byte tokens derived from a symmetric room key:
member_token = BLAKE2b(
key: room_key, // 32 bytes
input: member_signing_pk
|| room_id
|| "ppp-room-member-v1",
output_len: 12
)
Where member_signing_pk is a per-room Ed25519 key derived from
the member's long-term secret:
seed = BLAKE2b(
key: ed25519_sk[0..32],
input: room_id || "ppp-room-sign-v1",
output_len: 32
)
(pk, sk) = Ed25519_from_seed(seed)
This provides unlinkability: the per-room signing key cannot be linked to the member's long-term identity by the relay.
Registration (admin only):
Client -> Relay:
{ cmd: "REGISTER_TOKEN",
room_id: bytes16,
member_token: bytes12,
admin_signature?: bytes64, // legacy path
acting_key?/admin_sig?/cmd_ts?/delegation_cert? } // envelope (§7.8)
Relay -> Client:
{ cmd: "TOKEN_REGISTERED" }
Authority is proven either by the admin envelope (§7.8) or,
legacy path, by the caller being the admin connection with
admin_signature valid for
member_token || room_id under the admin pubkey.
Either way the token count must be < max_members.
Registration is idempotent.
Revocation (admin only):
Client -> Relay:
{ cmd: "REVOKE_TOKENS", room_id: bytes16 }
Relay -> Client:
{ cmd: "TOKENS_REVOKED", count: uint32 }
Key Rotation with Grace Period (admin only):
Client -> Relay:
{ cmd: "ROTATE_ROOM_KEY",
room_id: bytes16,
grace_seconds: uint32 } // clamped to [0, 600]
Relay -> Client:
{ cmd: "KEY_ROTATED",
room_id: bytes16,
revoked_count: uint32,
grace_until: uint64 }
All existing tokens are marked with grace_until. During the
grace period tokens remain valid; after, they are rejected. The relay
broadcasts a KEY_ROTATION notification to all room subscribers.
Authenticated submission (WebSocket):
Client -> Relay:
{ cmd: "STORE_ROOM",
room_id: bytes16,
nonce: bytes24,
ciphertext: bytes }
Relay -> Client:
{ cmd: "ROOM_STORED",
room_id: bytes16,
sequence_number: int64 }
Sealed submission (anonymous HTTP POST):
POST /v1/room/submit
Content-Type: application/msgpack
{
room_id: bytes16,
member_token: bytes12,
nonce: bytes24,
ciphertext: bytes
}
Processing: validate lengths, verify token for room_id (respecting grace period), per-token rate limit (2/sec), verify room not expired, store with auto-increment sequence number, notify subscribers.
Fetch:
Client -> Relay:
{ cmd: "FETCH_ROOM",
room_id: bytes16,
since_id: int64 }
Relay -> Client:
{ cmd: "ROOM_MESSAGES",
room_id: bytes16,
messages: [RoomMessage] }
RoomMessage: id (sequence number), nonce,
ciphertext, stored_at, and an optional
trailing relay-signed delivery receipt. Returns
messages with id > since_id, bounded like FETCH:
at most 100 messages and at most
MAX_FETCH_PAGE_BYTES (192 KiB) of wire cost per
page. For members admitted with
share_history: false (§7.4) the relay raises the
effective since_id to the member's history floor, so
messages stored before their admission are never returned to them.
The admin's own fetch is never filtered.
Message deletion (admin):
DELETE_ROOM_MESSAGE removes a single room message.
Authority is proven either by the per-room admin envelope
(§7.8) with message_id (big-endian) as the bound
params, or by the legacy bespoke signature over domain-separated
material:
ctx = "PPP:DELETE_ROOM_MESSAGE:v1\0"
body = room_id (16) ‖ message_id.to_be_bytes() (8) ‖ timestamp.to_be_bytes() (8)
sig = Ed25519.sign(admin_sk, ctx ‖ body)
On the legacy path the WS-authenticated pubkey
MUST equal the room's admin key and
timestamp is bounded by a ±300 s skew
window. The relay dedupes on
(room_id, message_id, timestamp) for 600 s so
retries are idempotent. The dedup entry
MUST be recorded only after
authorization and signature verification succeed. Recording it
earlier lets any authenticated non-admin pre-poison the window
with chosen triples, after which the real admin's delete
short-circuits on the poisoned entry and receives a success reply
while the message remains — a forged confirmation, worse
than an error because nothing prompts a retry.
For BLE-enabled rooms, prospective members request admission without prior knowledge of the room key:
Knock:
Client -> Relay:
{ cmd: "KNOCK",
room_id: bytes16,
request_id: bytes16, // client-generated
knocker_eph_x25519_pk: bytes32,
handle?: string } // display handle, ≤ 64 bytes
Relay -> Client:
{ cmd: "KNOCK_OK", request_id: bytes16 } // echo
The client generates request_id; the relay stores the
knock under it with a 120-second TTL and notifies the admin via
KNOCK_NOTIFY (carrying the ephemeral key and optional
handle) or push fallback.
Admit (admin only):
Client -> Relay:
{ cmd: "ADMIT",
room_id: bytes16,
request_id: bytes16,
encrypted_grant?: bytes, // ≤ 128 B, relayed live, never stored
share_history?: bool, // default true; see below
acting_key?/admin_sig?/cmd_ts?/delegation_cert? } // envelope (§7.8)
Relay -> Client (admin):
{ cmd: "ADMIT_OK", request_id: bytes16, encrypted_grant?: bytes }
Relay -> Knocker:
{ cmd: "ADMITTED",
room_id: bytes16,
request_id: bytes16,
admin_pubkey: bytes32,
encrypted_grant?: bytes }
The relay authorizes the caller (envelope or legacy admin check),
marks the knock admitted — atomically, a knock can be
admitted once — and forwards the grant, if present, live to
the knocker inside ADMITTED. The grant is never
persisted. The admin encrypts the room key for the knocker using
the ephemeral X25519 key:
grant = ble_grant_encrypt(
admin_x25519_sk,
knocker_eph_x25519_pk,
room_id,
room_key
)
// Wire: nonce (24B) || ciphertext (room_id
// || room_key + 16B auth tag)
The grant travels in-band through the relay (ADMIT →
ADMITTED, above) or out-of-band via BLE. The knocker decrypts to
obtain room_id and room_key, then
derives their member token.
History sharing: ADMIT may carry
share_history?: bool. Absent or true (the default), the
admitted member can fetch the room's full retained backlog —
identical to pre-flag behavior. When false, the relay records the
room's current highest message id as the member's history
floor, and FETCH_ROOM returns only newer messages to that member.
Re-admission keeps the most permissive floor: a later admit that
shares history unlocks it, while a later withholding admit cannot
retroactively hide messages an earlier admit already granted.
The floor is relay-enforced policy, not cryptography: rooms use a single long-lived key, so ciphertext a member obtains out of band remains decryptable regardless of the floor. Room expiry stays the hard bound on backlog exposure. Cryptographic pre-join secrecy (per-epoch room keys) is a groups-v2 item Deferred.
Notification (wire cmd) | Recipients | Trigger |
|---|---|---|
| ROOM_NOTIFY | All room subscribers | STORE_ROOM or sealed submit |
| KNOCK_NOTIFY | Room admin | KNOCK command |
| ADMITTED | Knocker | ADMIT command |
| KEY_ROTATION_NOTIFY | All room subscribers | ROTATE_ROOM_KEY |
A group is a persistent variant of a room. The wire
surface and storage layout are shared with rooms — the same
rooms table holds both, distinguished by a
room_type column — but groups differ on three
axes:
expires_at in the year 9999 and are excluded from
the room expiry purge job. They live until explicitly deleted.s2_cell_id and MUST NOT
be returned by DISCOVER_ROOMS. Joining requires an
explicit invite (or a knock against a known group ID), not a
map lookup.DELETE_GROUP is admin-only and cascade-deletes
messages, members, and tokens in one transaction. There is no
equivalent for time-bounded rooms; rooms expire on their own.Group invites are delivered out-of-band via Gift Wrap (event kind
0x09 GroupInvite, see §C.5) carrying the
room_id and the symmetric room_key. The
recipient stores the invite locally and can then participate in
the group via the same room-message commands as a discovered
room. Two join paths exist in v1: a Gift-Wrap invite to an
existing contact, or the BLE knock/admit handshake (§7.4)
against a group ID shared by another channel.
Implementations MUST NOT assume
forward secrecy: room_key is a long-lived symmetric
key with no automatic ratchet, and a leaked key compromises all
past and future group messages until the admin deletes and
recreates the group. Invite links (a recipient-bound token a
sender can share without prior contact) are
MAY in v1 but are
not defined here; the v1 surface only supports
(a) Gift-Wrap invite to a known contact and (b) knock/admit
against a known group ID. Multi-admin operation is also out of
scope; v1 has a single admin per group.
The wire commands KICK_MEMBER and
BAN_MEMBER exist in the protocol surface (§C.1)
and the relay implements both, but the feature is gated behind a
compile-time flag that is off by default in
shipped builds. The current product position is that rooms are
short-lived enough that mid-session moderation is rarely needed,
and the privacy cost of letting an admin link a member's pubkey
to in-room behavior is not worth the convenience for v1.
Specified here because the wire surface is publicly observable
and because we want clients to be able to introspect whether the
relay they are talking to has the feature enabled. A relay that
ships with the feature on MUST
accept KICK_MEMBER / BAN_MEMBER from
the room admin's authenticated WebSocket and
MUST reject them from anyone else.
A relay that ships with the feature off
MUST respond
STORE_FAIL with the stable reason string
"kick/ban is disabled" so clients can detect
unavailability without inferring it from generic errors.
Admin commands (ADMIT, REGISTER_TOKEN,
REVOKE_TOKENS, ROTATE_ROOM_KEY,
DELETE_ROOM_MESSAGE, UPDATE_ROOM,
DELETE_GROUP, …) accept an optional
admin envelope: acting_key,
admin_sig, cmd_ts and an optional
delegation_cert. When the envelope is present,
admin authority is a capability proven by signature,
never by the WebSocket connection identity — the relay
verifies the envelope without consulting the authenticated
pubkey, which keeps the check transport-agnostic (the same
verification would run for a future unauthenticated OHTTP
submission path). When the envelope is absent — or the room
predates per-room admin keys and has no A0 —
the relay falls back to the legacy transitional check: the
WS-authenticated pubkey must equal the room's
admin_pubkey (the creator's long-term key). The
legacy path is retired once all clients supply envelopes.
admin_sig is an Ed25519 signature by
acting_key over canonical, command-tag-bound
material:
material = "PPP:ROOM_ADMIN_CMD:v1\0"
‖ len(cmd_tag) (1) ‖ cmd_tag
‖ room_id (16)
‖ cmd_ts.to_be_bytes() (8)
‖ len(params).to_be_bytes() (4) ‖ params
The length-prefixed cmd_tag (e.g.
"ADMIT") binds the signature to one command so it
cannot be replayed as another; params are the
command's mutable fields. cmd_ts is bounded by a
±300 s skew window, and the relay keeps a replay LRU
keyed on BLAKE2b(cmd_tag ‖ room_id ‖ params
‖ cmd_ts) — recorded, as in §7.3, only
after verification succeeds.
Authority is membership in the room's admin set:
the room's root admin key A0
(rooms.admin_auth_pubkey — a
pseudonymous per-room key derived from the creator's
secret key and the room_id, so
DISCOVER_ROOMS no longer exposes the admin's
long-term identity key), or any key carrying an inline
single-hop delegation cert signed by
A0 over
"PPP:ROOM_ADMIN_DELEG:v1\0"-separated material
binding the room and the delegate key. Certs carry
issued_at/expires_at; a cert
MUST NOT outlive its room, and for
persistent groups its lifetime is capped at 30 days. Single-hop
is enforced by verifying the cert against A0 only
— a delegate-signed cert cannot verify. Destructive,
creator-only commands (e.g. DELETE_GROUP)
MUST reject delegates.
Rooms are like location-based group chats. An admin creates a room pinned to a spot on the map (using S2 geometry cells). Anyone nearby can discover it. Members share a secret key so they can all read each other's messages, but the relay sees only encrypted blobs.
The admin controls who joins by issuing member tokens — cryptographic tickets that prove membership without revealing identity. The relay checks the ticket is valid but can't tell which member presented it.
Point-to-point events use Gift Wrap (3-layer encryption per recipient). Rooms use symmetric-key encryption with a shared room key — more efficient for groups but with different trust properties:
This is a deliberate asymmetry, not an inconsistency:
| Notes | Rooms | |
|---|---|---|
| Relay sees location? | No | Yes (S2 cell) |
| Purpose | Proximity gate | Discovery index |
| Device constraint | J2ME compatible | SQLite indexable |
| Representation | Circle (haversine) | S2 cell ID (string) |
Notes keep location fully encrypted because they're exchanged between known contacts — no discovery needed. Rooms must be discoverable by strangers nearby, so the relay needs a spatial index. The privacy cost is that DISCOVER_ROOMS reveals the queried S2 cell to the relay.
S2 cells provide a hierarchical spatial index — the relay can efficiently answer "what rooms are near this location?" without storing or processing actual GPS coordinates. The coarse cell enables broader discovery searches, while the fine cell gives precise placement.
Imagine arriving at a venue with a PPP room. You don't know the room key yet, so you "knock" — sending an ephemeral key to the relay. The admin sees your request and "admits" you. The room key is then encrypted specifically for your ephemeral key and delivered over BLE or the relay. Once you have the key, you can derive your member token and participate.
The 120-second TTL on knocks prevents stale requests from accumulating.
This section describes the planned offline-courier and BLE-delivery design. The relay does not route carry-group registration, courier fetch, or any of the BLE-delivery flows below; the only BLE behavior in scope today is the room-level KNOCK / ADMIT handshake (§7.4), which uses BLE proximity for membership signaling but delivers messages over the relay's WebSocket transport. The full courier/BLE roadmap is Phase 7.
The protocol MUST define a carry group concept: a set of users who consent to having their encrypted events carried by designated couriers.
The protocol MUST define a Bluetooth Low Energy transport for courier-to-recipient event delivery without internet connectivity.
The BLE delivery handshake MUST NOT reveal the courier's carried recipient list to bystanders or unauthenticated devices.
Creation: A user generates a random group_id
and distributes CARRY_GROUP_INVITE events (Gift Wrapped) to members.
Joining: A recipient sends CARRY_GROUP_ACCEPT back to the inviter with their pubkey consent.
Registration with relay:
Client -> Relay:
{ cmd: "REGISTER_CARRY_GROUP",
group_id: bytes16,
members: [bytes32],
couriers: [bytes32],
sig: bytes64 }
Leaving: A member sends a signed leave message. The relay MUST stop including departed members' events in subsequent FETCH_CARRY responses.
When a courier has internet connectivity:
CarriedEvent {
event: GiftWrap
fetched_at: uint64
hop_count: uint8 // starts at 0
max_hops: uint8 // default: 3
delivered: bool // until recipient ACKs
recipient: bytes32 // for local lookup
}
Service UUID: 0xPPP1 (short) or 128-bit TBD
Characteristics:
HANDSHAKE_NONCE (read): bytes32, random per-session
AUTH_RESPONSE (write): bytes32 (pk) || bytes64 (sig)
EVENT_STREAM (notify): chunked event data
EVENT_ACK (write): bytes32 (event_id)
Courier Recipient
| |
|<-- BLE scan, discover service -----|
| |
|--- read HANDSHAKE_NONCE ---------->|
| |
|<-- write AUTH_RESPONSE ------------|
| pubkey || sign(nonce, sk) |
| |
| verify sig, check carry group |
| |
|--- EVENT_STREAM notifications ---->|
| (chunked GiftWrap events) |
| |
|<-- EVENT_ACK per event_id ---------|
| |
BLE MTU is negotiable (20-512 bytes). Events larger than (MTU - 3) are chunked:
Chunk {
sequence: uint16 // chunk index (0-based)
total: uint16 // total chunk count
data: bytes // chunk payload
}
The protocol SHOULD support multi-hop courier relay, where a courier transfers carried events to another courier for onward delivery.
hop_count incremented on transfer.hop_count >= max_hops are NOT relayed
further.Couriers solve the “offline last mile” problem. Imagine you're in a remote area with no internet. A friend who was recently in town has your encrypted messages on their phone. When you're within Bluetooth range, their phone automatically hands the messages to yours — without either of you doing anything.
The courier never sees the message contents. They're carrying sealed envelopes they can't open.
Bluetooth Low Energy is available on virtually every modern smartphone and many feature phones. Unlike Wi-Fi Direct or NFC:
The max_hops limit (default 3) prevents events from
circulating indefinitely. Each hop increases latency and widens the
set of devices that see the encrypted envelope. Three hops balances
reach with privacy.
The DISCOVERY_PUBLISH / DISCOVERY_QUERY /
DISCOVERY_MATCHES commands specified below are not in
the wire surface (§C.1) and the relay implements no pepper
endpoint; this section is the design for a future phase.
Contact discovery MUST NOT transmit plaintext phone numbers, email addresses, or other personally identifiable information to the relay.
Contact discovery MUST use cryptographic hashes of contact identifiers to match users without exposing raw contact data.
The relay MUST rate-limit discovery queries to prevent enumeration attacks.
normalized = lowercase(strip_whitespace(identifier))
// phone: E.164 format
// email: lowercase, remove dots in local part
hash = crypto_generichash(
32,
normalized,
relay_pepper // relay-provided, rotated
)
The relay pepper prevents offline rainbow table attacks. It is fetched from the relay on each discovery cycle.
1. Client -> Relay:
{ cmd: "DISCOVERY_PUBLISH",
hashes: [bytes32],
pubkey: bytes32 }
2. Client -> Relay:
{ cmd: "DISCOVERY_QUERY",
hashes: [bytes32] }
3. Relay -> Client:
{ cmd: "DISCOVERY_MATCHES",
matches: [{ hash: bytes32,
pubkey: bytes32 }] }
Phone numbers have low entropy (~10 billion values per country code). This makes fast hashes vulnerable even with a pepper:
| Attack | Feasible? | Mitigation |
|---|---|---|
| Pre-computed rainbow table | No (with pepper) | Relay pepper invalidates pre-computed tables |
| Online enumeration | Bounded | Rate limiting (DR-03) |
| Offline brute-force (with pepper) | Yes — BLAKE2b is fast | Slow-hash upgrade (S8.5) |
| Relay-side brute-force | Trivial for operator | PSI migration (S8.5) |
Contact discovery answers: “which of my phone contacts are also on PPP?” Instead of sending your contacts' phone numbers to the server, you send scrambled (hashed) versions. The server compares scrambled values without ever seeing the real numbers.
The relay adds a secret “pepper” to the hash so that even if someone steals the hash database, they can't reverse-engineer the phone numbers without also having the pepper.
The v0.1 pepper model does NOT protect against a malicious relay operator — they have the pepper and can enumerate all ~10 billion phone numbers in minutes with BLAKE2b. This is a documented trade-off. The hardening roadmap addresses this progressively.
OOB verification (S9) provides defense-in-depth: even if an attacker maps a hash to a phone number, they cannot forge the verified identity binding.
The protocol MUST define an out-of-band verification mechanism independent of any specific messaging channel.
Both invite and accept payloads MUST be cryptographically signed to prove key ownership and prevent impersonation.
Contact discovery finds candidates; verification confirms them. The protocol defines the payload format and crypto handshake but does NOT prescribe which channel to use.
| Level | Name | Method | Trust Basis |
|---|---|---|---|
| 0 | Discovered | Relay hash lookup | TOFU — relay honesty |
| 1 | OOB Verified | Message via authenticated channel | Channel's identity binding |
| 2 | In-Person | QR code scan, NFC tap, verbal comparison | Physical co-presence |
VerificationInvite (sent by initiator via OOB channel):
VerificationInvite {
protocol: "ppp-verify-v1"
sender_pk: bytes32
invite_token: bytes16 // random, single-use
relay_url: string
sender_sig: bytes64 // sign(invite_token, sk)
timestamp: uint64
}
VerificationAccept (sent by responder):
VerificationAccept {
protocol: "ppp-verify-v1"
responder_pk: bytes32
invite_token: bytes16 // echoed from invite
responder_sig: bytes64 // sign(sender_pk
// || invite_token, sk)
}
The responder_sig signs both sender_pk AND
invite_token, preventing forwarding attacks.
Invite:
https://<domain>/v?p=<b64(sender_pk)>
&t=<b64(invite_token)>
&r=<b64(relay_url)>
&s=<b64(sender_sig)>
&ts=<timestamp>
Accept:
https://<domain>/va?p=<b64(responder_pk)>
&t=<b64(invite_token)>
&s=<b64(responder_sig)>
Alice (initiator) Bob (responder)
| |
| 1. Generate invite_token |
| Sign with alice_sk |
| |
|-- 2. Send invite via OOB -------->|
| |
| 3. Verify sender_sig |
| 4. Sign acceptance |
| |
|<- 6. Send accept via OOB ---------|
| |
| 7. Verify responder_sig |
| against invite_token |
| |
| 8. Both pin keys at level 1 |
| |
|== 9. PPP communication begins ====|
After successful verification, the client MUST pin the contact's pubkey locally and track the verification level and method.
PinnedContact {
contact_id: string // local only
pubkey: bytes32
verification_level: uint8 // 0, 1, or 2
verified_at: uint64
verified_via: string // channel name
relay_url: string
}
This record is stored locally and is NEVER transmitted to the relay.
Clients MUST alert users when a verified contact's pubkey changes, with alert prominence proportional to the verification level.
| Level | Behavior on Key Change |
|---|---|
| 0 (Discovered) | MAY silently accept (TOFU). SHOULD show subtle indicator. |
| 1 (OOB Verified) | MUST show prominent warning. User must approve or re-verify. |
| 2 (In-Person) | MUST show prominent warning. SHOULD require in-person re-verification. |
| Channel | Confidentiality | Identity Binding |
|---|---|---|
| iMessage | E2EE | Apple ID / phone |
| E2EE | Phone number | |
| Signal | E2EE | Phone number |
| Email (TLS) | Transit only | Email address |
| SMS | None | Phone (weak — SIM swap risk) |
| QR code | N/A (physical) | Co-presence (strongest) |
Verification invites MUST expire and MUST NOT be reusable.
For level 2, both parties scan QR codes or compare a verification fingerprint:
fingerprint = base64(
BLAKE2b(sort(alice_pk, bob_pk), 16))
Displayed as: aBc1 dEf2 gHi3 jKl4
Equivalent to Signal's safety number comparison.
Discovery tells you “someone with this phone number is on PPP.” But how do you know it's really your friend and not an impersonator? Verification answers this by exchanging signed tokens through a channel you already trust (like iMessage or WhatsApp).
The strongest verification is in-person: you meet face-to-face and scan each other's QR codes, or compare a short code displayed on both phones.
The responder signs both the invite token AND the initiator's pubkey. This means if Alice forwards Bob's acceptance to Carol, Carol will see that Bob's signature explicitly names Alice — it can't be repurposed to verify with Carol.
The protocol SHOULD define an SMS notification mechanism for devices without data connectivity.
The event format MUST be independent of transport mechanism.
The CAPABILITIES command is not in the wire surface
(§C.1); push registration happens via
REGISTER_PUSH (§5.3). Retained as the design for
a future multi-transport negotiation step.
Client -> Relay:
{ cmd: "CAPABILITIES",
transports: ["ws", "http", "ble"],
push_platform: "apns" | "fcm" | null,
push_token: string | null }
| Tier | Transport | Devices | Real-time | Offline Send |
|---|---|---|---|---|
| 1 | WebSocket | iOS, Android, KaiOS, Web | Yes (NOTIFY) | Queue + reconnect |
| 2 | HTTP poll | J2ME, constrained HTTP | No (polling) | Queue + next poll |
| 3 | SMS notify | Any phone with SMS | Notification only | N/A |
| 4 | BLE courier | Any BLE device | Local only | Courier carries |
A single event may be delivered via multiple transports simultaneously. The recipient deduplicates by event_id regardless of delivery path. The first successful ACK is authoritative.
PPP is designed to work everywhere — from a modern iPhone to a basic feature phone. Each device advertises what it can do, and the relay adapts its delivery strategy accordingly. A smartphone gets real-time WebSocket delivery; a feature phone might rely on SMS pings and HTTP polling.
The protocol targets extreme device diversity. WebSocket covers most modern devices, HTTP polling handles legacy browsers and constrained environments, SMS reaches dumb phones, and BLE courier handles the fully offline case. No single transport reaches everyone.
The protocol MUST minimize metadata visible to relays and intermediaries.
Event payloads MUST be padded to fixed bucket sizes before outer encryption to prevent size-based correlation.
No padding/bucket code exists in the reference client stack yet; GiftWrap ciphertext length currently tracks plaintext length. The scheme below is the normative design clients MUST follow once padding ships.
| Bucket | Typical Content |
|---|---|
| 256 B | ACKs, revocations, key rotations |
| 1024 B | Short presence events (text only) |
| 4096 B | Longer messages, carry group invites |
| 16384 B | Reserved for future media-bearing events |
padded_length = next_bucket(plaintext_length)
padding = randombytes(
padded_length - plaintext_length - 2)
padded_input = plaintext || 0x01 || padding || 0x00
^^^^ ^^^^
delimiter terminator
Instead of fetching by long-term pubkey, clients use rotating mailbox identifiers:
epoch = floor(unix_time / 86400) // daily
mailbox_seed = X25519(user_sk, relay_pk)
mailbox_id = BLAKE2b(32,
mailbox_seed || epoch)
The relay cannot link Monday's mailbox_id to Tuesday's without
knowing the user's private key.
Not implemented: the relay currently notifies immediately on STORE. Batch windows and dummy notifications remain roadmap.
The relay SHOULD support configurable batch windows (default: 30s) to break timing correlation between event storage and notification.
| Technique | Why Deferred |
|---|---|
| Cover traffic | High bandwidth/battery cost, incompatible with BLE/offline devices |
| Mixnet relay chaining | Requires relay federation, premature for v0.1 |
| Private Information Retrieval | O(N) server work per query, infeasible on feature phones |
Even with perfect encryption, the relay can learn things from patterns: who messages whom, how often, and message sizes. Metadata protection adds countermeasures:
Cover traffic and mixnets are the gold standard but require always-on connectivity and multiple relay operators. PPP targets feature phones and offline scenarios where these are impractical. The deferred techniques may be offered as an opt-in “high privacy mode” for WiFi-connected clients.
| Adversary | Capabilities | Mitigations |
|---|---|---|
| Relay operator | Sees envelopes: recipient, ephemeral key, timestamps, blob | Gift Wrap hides sender/content. Sealed sender hides submitter. Blinded mailboxes reduce linkage. |
| Network observer | Sees TLS-encrypted traffic | Standard TLS. Padding prevents size fingerprinting. Batching weakens timing analysis. |
| Malicious courier | Has carried encrypted blobs | Cannot decrypt. Cannot determine sender. Can see recipient pubkeys. |
| BLE bystander | In BLE range | Generic service UUID. Handshake requires valid keypair. Rate limiting prevents enumeration. |
| Malicious recipient | Decrypts received events | By design. No forward secrecy in this version. |
| Discovery spoofer | Registers fake hash-to-pubkey mapping | OOB verification detects. Level 0 contacts marked in UI. |
| OOB interceptor | Intercepts verification URLs | Payloads contain only public data. Modification detected by signatures. |
| Hash brute-forcer | Obtains pepper, enumerates phone numbers | Partial only in v0.1. See hardening roadmap (S8.5). |
No system is perfectly secure. This section is honest about what PPP protects against and what it doesn't. The key insight: the relay is treated as an honest but curious adversary — it will follow the protocol but might try to learn what it can from the metadata it handles.
PPP does not implement a Double Ratchet (like Signal). This is a deliberate trade-off: the protocol prioritizes simplicity and cross-platform portability (including J2ME feature phones) over ratchet-based forward secrecy. Adding a ratchet would significantly increase state management complexity and is deferred to a future version.
Field Type Description
───────────── ──────── ──────────────────────────────
recipient bytes32 Recipient pubkey or mailbox ID
ephemeral_pk bytes32 Ephemeral wrap public key
nonce bytes24 XChaCha20 nonce
ciphertext bytes Encrypted Seal
created_at uint64 Unix timestamp
expires_at uint64 Event expiry
Overhead: 32 + 32 + 24 + 8 + 8 = 104 bytes + ciphertext
Field Type Description
───────────── ──────── ──────────────────────────────
ephemeral_pk bytes32 Ephemeral seal public key
nonce bytes24 XChaCha20 nonce
ciphertext bytes Encrypted Rumor
sender_sig bytes64 Sender signature over ciphertext
Overhead: 32 + 24 + 64 = 120 bytes + ciphertext
Field Type Description
───────────── ──────── ──────────────────────────────
sender bytes32 Sender Ed25519 public key
kind uint8 Event kind (S4.2)
content varies Kind-specific payload (S4.3)
created_at uint64 Sender-asserted timestamp
Field Type Description
───────────── ──────── ──────────────────────────────
region_type uint8 0 = circle (v0.1 baseline)
latitude float64 Center latitude (WGS84)
longitude float64 Center longitude (WGS84)
radius_m uint32 Radius in meters
Overhead: 1 + 8 + 8 + 4 = 21 bytes
For a typical PRESENCE event with 140-byte message and circle region:
Rumor: 32 + 1 + ~200 (16 id + 140 msg
+ 21 region + 24 time) + 8 = ~241 B
Seal: 32 + 24 + (241 + 16 tag) + 64 = ~377 B
GiftWrap: 32 + 32 + 24 + (377 + 16) + 16 = ~497 B
Under 500 bytes per event — well within BLE MTU negotiation range and efficient for constrained networks.
At ~500 bytes, a PPP event is:
This compactness is critical for BLE courier delivery and bandwidth-constrained feature phones.
Reference implementations MUST pass these vectors to confirm cross-platform interoperability.
Published vectors (JSON on disk, exercised by CI, deterministic seeds and nonces):
ppp-core/test-vectors/vector-01-keygen.json).vector-02-giftwrap.json).vector-03-event-id.json).vector-04-haversine.json).test-vectors/vector-05-wire-protocol.json at the
workspace root; regenerated by
scripts/generate-test-vectors.sh).Planned: CHALLENGE-AUTH handshake, BLE handshake, and OOB verification (invite generation, accept signature verification, forwarding-attack rejection) vectors.
All published vectors were regenerated for the MessagePack
bin encoding migration.
PPP targets four platforms (iOS/Swift, Android/Kotlin, KaiOS/JS, potentially J2ME). Test vectors ensure that an event encrypted on one platform can be decrypted on any other — a single bit difference in serialization or crypto would break interoperability.
Vectors 1–4 run in ppp-core/tests/test_vectors.rs;
vector 5 runs in ppp-relay/tests/wire_vectors.rs.
Swift and Kotlin binding verification is pending.
These requirements derive from the NowHere app spec and brainstorming sessions on cross-platform protocol design.
| PPP Requirement | NowHere Origin |
|---|---|
| PR-01, PR-03 | NFR-02 (Privacy & Security) |
| PR-04 | Product Principle: Privacy by default |
| PR-06, PR-07 | New: relay metadata protection |
| IR-01, IR-02 | Open Decision: migrate from CloudKit |
| TR-01, TR-05 | FR-04 (Save + Share Flow), FR-08 (Offline) |
| CR-01..CR-05 | New: courier relay concept |
| DR-01..DR-03 | FR-07 (Contact Discovery Refresh) |
| SR-01, SR-02 | FR-08 (Offline-First Operation) |
| SR-03 | FR-09 (Expiration and Cleanup) |
| SE-01..SE-03 | NFR-02, security improvements |
| VR-01..VR-06 | New: OOB verification |
PPP was extracted from NowHere, an iOS app for place-gated social notes. The protocol generalizes the app's features into a platform-independent specification while adding new capabilities (courier delivery, contact discovery, OOB verification) that the original CloudKit-based implementation couldn't support.
Clients MUST support creating and queuing events while offline, with automatic delivery when connectivity is restored.
The protocol MUST guarantee eventual delivery of events to recipients, provided the relay is reachable within the event's TTL.
since timestamp.Events MUST carry an expiry timestamp. Relays and couriers MUST discard expired events.
expires_at is REQUIRED on
all events.PPP is designed for unreliable networks. You can create a message on an airplane, and it will be delivered when you land and get signal. Messages that aren't picked up before their expiry time are automatically cleaned up everywhere — on the relay, on couriers, and on your device.
The protocol MUST prevent replay attacks where a previously valid event is resubmitted.
The protocol MUST resist attempts to enumerate registered users or stored events.
Replay protection ensures that capturing an encrypted event and re-sending it doesn't work — the relay recognizes the duplicate and rejects it. Enumeration resistance means an attacker can't probe the relay to discover who uses the system.
This appendix is generated from the relay source by
cargo run -p spec-gen -- generate. CI rejects pull
requests that change the wire surface without regenerating it, so
the tables below are authoritative — when prose elsewhere in the
spec disagrees with this appendix, the appendix is correct.
| Command | Body | Description |
|---|---|---|
AUTH |
pubkey: bytes, sig: bytes |
Authentication response (after receiving Challenge). |
STORE |
event: bytes |
Submit an encrypted event for delivery. |
FETCH |
since: uint64, cursor?: FetchCursor |
Fetch events for the authenticated pubkey. |
ACK |
event_ids: [bytes] |
Acknowledge receipt of events. |
CREATE_ROOM |
room_id: bytes, s2_cell_id: string, s2_coarse_id: string, gated: bool, proximity_required: bool, ble_enabled: bool, max_members: uint32, expires_at: uint64, name?: string, description?: string, description_public: bool, admin_auth_pubkey?: bytes |
Create a new room. |
FETCH_ROOM |
room_id: bytes, since_id: int64 |
Fetch messages from a room. |
DISCOVER_ROOMS |
s2_cell_id: string |
Discover rooms in an S2 cell. |
KNOCK |
room_id: bytes, knocker_eph_x25519_pk: bytes, request_id: bytes, handle?: string |
BLE knock request. |
ADMIT |
room_id: bytes, request_id: bytes, encrypted_grant?: bytes, share_history?: bool, acting_key?: bytes, admin_sig?: bytes, cmd_ts?: int64, delegation_cert?: bytes |
Admit a knock request (admin only). |
REGISTER_TOKEN |
room_id: bytes, member_token: bytes, admin_signature?: bytes, acting_key?: bytes, admin_sig?: bytes, cmd_ts?: int64, delegation_cert?: bytes |
Register an admin-signed member token. |
REVOKE_TOKENS |
room_id: bytes, reason?: string, acting_key?: bytes, admin_sig?: bytes, cmd_ts?: int64, delegation_cert?: bytes |
Revoke all member tokens for a room (admin only). |
ROTATE_ROOM_KEY |
room_id: bytes, grace_seconds: uint32, acting_key?: bytes, admin_sig?: bytes, cmd_ts?: int64, delegation_cert?: bytes |
Revoke all tokens and notify members of key rotation (admin only). |
DELETE_ROOM_MESSAGE |
room_id: bytes, message_id: int64, timestamp: int64, admin_signature?: bytes, acting_key?: bytes, admin_sig?: bytes, cmd_ts?: int64, delegation_cert?: bytes |
Admin-signed deletion of a single room message; timestamp-bound and idempotent (spec §7.3). |
STORE_ROOM |
room_id: bytes, nonce: bytes, ciphertext: bytes |
Store a room message via authenticated WebSocket (fallback for sealed sender). |
UPDATE_ROOM |
room_id: bytes, gated?: bool, proximity_required?: bool, acting_key?: bytes, admin_sig?: bytes, cmd_ts?: int64, delegation_cert?: bytes |
Update room flags (admin only). |
REGISTER_PUSH |
platform: string, token: string |
Register a push notification token. |
REGISTER_DELIVERY_TOKEN |
delivery_token: bytes |
Register a delivery token for sealed Note submission. |
DEREGISTER_DELIVERY_TOKEN |
delivery_token: bytes |
Deregister a delivery token (e.g. contact removed). |
KICK_MEMBER |
room_id: bytes, member_pubkey: bytes, reason?: string, acting_key?: bytes, admin_sig?: bytes, cmd_ts?: int64, delegation_cert?: bytes |
Kick a member from a room (admin only). Temporary — can re-knock. |
BAN_MEMBER |
room_id: bytes, member_pubkey: bytes, reason?: string, acting_key?: bytes, admin_sig?: bytes, cmd_ts?: int64, delegation_cert?: bytes |
Ban a member from a room (admin only). Permanent — cannot re-knock. |
STORE_BACKUP |
ciphertext: bytes |
Upload an encrypted backup (upserts: replaces any existing backup). |
FETCH_BACKUP |
(no fields) | Download the latest backup for the authenticated pubkey. |
DELETE_BACKUP |
(no fields) | Delete the stored backup for the authenticated pubkey. |
CREATE_GROUP |
room_id: bytes, gated: bool, max_members: uint32, name?: string, description?: string, description_public: bool, admin_auth_pubkey?: bytes |
Create a persistent group (room_type='group', no expiry, no S2 discovery). |
DELETE_GROUP |
room_id: bytes, acting_key?: bytes, admin_sig?: bytes, cmd_ts?: int64, delegation_cert?: bytes |
Delete a group (admin only). CASCADE deletes messages, members, tokens. |
Public limits and TTLs that clients can rely on. The arithmetic
form is preserved for the constants whose value is most readable
that way (e.g. 30 * 24 * 60 * 60 reads as “30
days”).
| Constant | Value | Type | Description |
|---|---|---|---|
KNOCK_TTL_SECS |
120 |
u64 |
Knock TTL: how long an unanswered BLE/relay knock stays in the knock queue before the purge job sweeps it. User-initiated, low volume. |
MAX_DELIVERY_TOKENS_PER_RECIPIENT |
500 |
u32 |
Maximum number of registered Note delivery tokens per pubkey. Each token is one symmetric per-contact derivation. Two distinct populations register tokens under a pubkey: - **Direct recipients** (Phase 3a, Sealed Sender L1): one token per contact pair. A typical user has ~50–100 contacts. - **Couriers** (Phase 7): one token per *(contact, recipient)* pair they're delegated to carry for. A courier serving 5–10 recipients with ~10 contacts each can hit 50–100 tokens just from delegations, plus their own personal contacts. 500 covers both populations comfortably while bounding the relay's per-pubkey storage footprint. Lifted from 100 on 2026-05-01 to support Phase 7 courier delegation; see design.md §6.1.4 / CR-02. |
MAX_EVENT_TTL_SECS |
30 * 24 * 60 * 60 |
u64 |
Maximum event TTL (seconds): server-side clamp on client-supplied `expires_at`. Prevents permanent storage via unbounded values. |
MAX_ACTIVE_ENVELOPES_PER_RECIPIENT |
500 |
u32 |
Maximum number of active (un-acked, unexpired) envelopes that may be stored at the relay for a single recipient routing tag. Global recipient quota — combines with [`MAX_CIPHERTEXT_SIZE`] to bound the relay's per-recipient storage footprint. Once reached, both the authenticated `STORE` and the anonymous `POST /v1/note/submit` paths reject new submissions for that routing tag with a `quota exceeded` reason. ACKing or letting envelopes expire frees slots. Hard rejection (no FIFO eviction): an attacker filling a victim's mailbox with junk must not be able to silently evict legitimate notes. The pairwise per-delivery-token quota proposed alongside this would require VOPRF-based per-sender token granularity (see VOPRF deferral, `2026-03-18-defer-voprf-recipient-hiding.md`); shipping the global half now caps the worst-case fill regardless. 500 matches the per-recipient ceiling already in use for [`MAX_DELIVERY_TOKENS_PER_RECIPIENT`]. See loop §L2 in `systems/plans/architectural_recommendations_loop.md`. |
MAX_CIPHERTEXT_SIZE |
64 * 1024 |
usize |
Maximum ciphertext size for STORE / STORE_ROOM / sealed submissions. Bounds amplification via Sybil-churned identities filling storage. |
SEALED_TOKEN_RATE_LIMIT |
RateBudget { max_count: 2, window_secs: 1 } |
RateBudget |
Per-token rate limit for the unauthenticated sealed HTTP endpoints (`POST /v1/room/submit`, `POST /v1/note/submit`). The token is the only identifier on these endpoints; this is the per-token analogue of [`COMMAND_RATE_LIMITS`]. |
SEALED_IP_RATE_LIMIT |
RateBudget { max_count: 120, window_secs: 60 } |
RateBudget |
Per-IP rate limit for the unauthenticated sealed HTTP endpoints. First line of DoS defense; the per-token limit is the second. Trusted-proxy XFF model: the rightmost trusted entry is the client IP. |
SEALED_BODY_LIMIT_BYTES |
128 * 1024 |
usize |
Maximum HTTP body size for sealed submission endpoints. |
MAX_BACKUP_SIZE |
5 * 1024 * 1024 |
usize |
Maximum encrypted backup ciphertext accepted by STORE_BACKUP, in bytes. One backup slot per pubkey (upsert), so this also bounds per-user backup storage on the relay. |
MAX_ROOM_NAME_BYTES |
128 |
usize |
Maximum room / group `name`, in bytes of UTF-8. Names are returned unconditionally in DISCOVER_ROOMS results, so this also bounds discovery response egress per room. |
MAX_ROOM_DESCRIPTION_BYTES |
2048 |
usize |
Maximum room / group `description`, in bytes of UTF-8. |
MAX_S2_TOKEN_BYTES |
16 |
usize |
Maximum `s2_cell_id` / `s2_coarse_id` token, in bytes. An S2 cell token is at most 16 lowercase-hex characters (a level-30 cell); groups use the empty string. |
MAX_KNOCK_HANDLE_BYTES |
64 |
usize |
Maximum knock `handle`, in bytes of UTF-8. Registered client handles are capped at 20 ASCII characters; the headroom allows future unicode handles without a wire change. |
MAX_PUSH_TOKEN_BYTES |
256 |
usize |
Maximum push `token`, in bytes. The relay treats the token as opaque: APNs device tokens are 64 hex characters today, FCM registration tokens run ~140-200. |
MAX_ENCRYPTED_GRANT_BYTES |
128 |
usize |
Maximum ADMIT `encrypted_grant`, in bytes. The v1 BLE room grant is a fixed 88 bytes (24-byte XChaCha nonce + 48-byte room_id||room_key plaintext + 16-byte tag); the headroom allows future grant versions. The grant is relayed live and never persisted. |
MAX_PUSH_REGISTRATIONS_PER_PUBKEY |
10 |
u32 |
Maximum push registrations (devices) per pubkey. Registrations beyond the cap are rejected; re-registering an existing (pubkey, token) pair always succeeds and refreshes its `registered_at`. |
PUSH_REGISTRATION_TTL_SECS |
90 * 24 * 60 * 60 |
u64 |
Push registrations whose `registered_at` is older than this are purged. Clients re-register on every connect, so only churned or deleted accounts go stale. |
MAX_DISCOVER_ROOMS_RESULTS |
100 |
usize |
Maximum rooms returned by one DISCOVER_ROOMS query, newest first. |
DISCOVERY_S2_LEVEL |
13 |
u8 |
S2 level a DISCOVER_ROOMS query token MUST be at (~2.4 km per cell). The relay matches the query against each room's `s2_coarse_id` and nothing else, so a token at any other level silently returns zero rooms — indistinguishable from "no rooms near you". That makes this a hard interop constant, not a client-side preference: it lived only in Swift until 2026-07-30, which is exactly the kind of unwritten contract a second implementation gets wrong with no error to debug against. Rooms are *stored* with a precise `s2_cell_id` (level 16, ~300 m) as well, because that is what the response carries and what the proximity check measures. Only the query is coarsened — the relay learns a browsing user's location to ~2.4 km rather than ~300 m, a 64x larger anonymity set at the same user density. Clients MUST NOT fan out to neighbouring cells: which neighbours a client asks for varies with its position inside the cell, so a fan-out hands the relay a finer fix than any single token would. |
ROOM_S2_LEVEL |
16 |
u8 |
S2 level a room's own `s2_cell_id` is stored at (~300 m per cell). This is the map centre and the proximity gate, not a query key. |
MAX_FETCH_PAGE_BYTES |
192 * 1024 |
usize |
Budget for one FETCH / FETCH_ROOM page, in **MessagePack wire bytes** — not payload bytes. Also the per-event ceiling enforced at STORE. A page count alone does not bound the response. `FETCH_PAGE_SIZE` is 100 and [`MAX_CIPHERTEXT_SIZE`] is 64 KiB, so a worst-case page serialises to megabytes — far past what any client accepts. That is not a degraded experience but a permanent one: the client rejects the frame, reconnects, re-fetches the same page and rejects it again, so a mailbox holding enough large events can never be drained. Anyone able to STORE to a routing tag can inflict it deliberately. **Wire bytes, not payload bytes.** Since the `serde_bytes` migration these are nearly the same number — `bin` carries the payload verbatim plus a 2–5 byte header — but the distinction is kept deliberately. It was not always so: under the old array-of-integers encoding a byte >= 0x80 cost two wire bytes, and the relay never decrypts, so the sender chose them. That made the true cost up to 2x per nesting level, and FETCH nests twice, so up to 4x. A first version of this budget counted payload length and measured 131,000 payload bytes into a 278,683-byte frame — over the client cap it existed to stay under. Costs are computed by `msgpack_bytes_wire_len` and include each message's nonce and receipt. **STORE enforces it too.** A page always emits at least one event so a mailbox can always drain, which is only safe if one event can never exceed a page by itself. Under the old encoding it could: one GiftWrap at `MAX_CIPHERTEXT_SIZE` with all-high bytes measured 262,678 wire bytes — unfetchable, therefore un-ACKable, wedging the recipient forever from a single STORE. `bin` makes that unreachable, and the ingress check is what keeps it unreachable if `MAX_CIPHERTEXT_SIZE` or this constant ever moves. 192 KiB leaves ~64 KiB under the 256 KiB client cap for the response envelope, and is 3x the largest single event `MAX_CIPHERTEXT_SIZE` allows. |
MAX_AUTH_FRAME_BYTES |
512 |
usize |
Maximum WebSocket frame accepted before authentication completes. The only frame a client may legitimately send pre-auth is AUTH, which is **121 bytes, invariant** — the pubkey and signature are `bin`-encoded, so the size no longer depends on byte values. (Before the `serde_bytes` migration it ranged 123–219, because each byte >= 0x80 cost two wire bytes.) 512 is 4.2x that, leaving room for a protocol version string, a client id, and one extra key plus signature. Why cap at all: `ClientMessage` is `#[serde(tag = "cmd")]`, and serde's internally-tagged representation buffers the whole MessagePack map into a `Content` tree before selecting a variant — a measured **32x** heap amplification, independent of payload shape. Uncapped, one 8 MiB pre-auth frame costs ~268 MB of heap in ~55 ms from a peer with no identity, no signature, no rate limit and no connection cap. At 512 bytes it costs ~15 KB. Field-level caps cannot substitute: the tree is built before any field visitor runs. A post-quantum AUTH would need this raised (ML-DSA-44 encodes to ~7.7 KiB) *and* a pre-auth concurrent-connection cap — at 8 KiB the per-connection worst case is ~261 KB, which is 2.5 GiB across 10k connections. |
MAX_ACK_IDS |
1024 |
usize |
Maximum number of event ids accepted in one ACK. `ACK` was the only client-supplied *collection* on the wire without a length cap, and each element drives one indexed UPDATE while the process-global SQLite connection mutex is held — so an 8 MiB frame of ~8.4M one-byte elements stalls every other command, both sealed HTTP endpoints and `/v1/health` for tens of seconds. Sized against the largest batch a shipped client can produce, which is **500**: Phase 7 carry tokens are registered under the *courier's own* pubkey, so carried events occupy the courier's mailbox and are therefore bounded by [`MAX_ACTIVE_ENVELOPES_PER_RECIPIENT`]. (The `FETCH`-driven path is bounded far lower, at one page — 100.) 1024 leaves room for the churn tail where a relay-side expiry frees a mailbox slot while the client's local row survives until its next sweep. **If [`MAX_ACTIVE_ENVELOPES_PER_RECIPIENT`] is ever raised above this value, raise this one with it** — otherwise a courier's ACK is rejected, its events are never marked acked, and the relay redelivers them forever. |
Authenticated WebSocket commands are rate-limited per pubkey using a sliding window. Commands not listed below use the default budget (30 requests per 60 seconds). Per-token (sealed HTTP) and per-IP limits live in §C.2 above.
| Command | Max | Window | Note |
|---|---|---|---|
STORE |
60 | 60s (1m) | |
FETCH |
30 | 60s (1m) | |
ACK |
60 | 60s (1m) | |
STORE_BACKUP |
5 | 3600s (1h) | Backups are infrequent on legitimate clients; tight budget bounds storage churn. |
FETCH_BACKUP |
5 | 3600s (1h) | FETCH_BACKUP returns up to 5 MiB; tight budget prevents egress amplification via repeated fetches. |
DISCOVER_ROOMS |
120 | 60s (1m) | Bounded by the iOS map view (~9 cells x 12 batches/min worst case); 120/60s leaves ~10% headroom. |
| Message | Body | Description |
|---|---|---|
CHALLENGE |
nonce: bytes |
Authentication challenge — client must sign the nonce. |
AUTH_OK |
(no fields) | Authentication succeeded. |
AUTH_FAIL |
reason: string |
Authentication failed. |
STORED |
event_id: bytes |
Event stored successfully. |
STORE_FAIL |
reason: string, in_reply_to: string|nil |
Generic command failure; in_reply_to names the client command being answered. |
EVENTS |
events: [bytes], cursor: FetchCursor|nil, stored_ats: [uint64], server_now: uint64, receipts: [ReceiptWire] |
Events returned in response to FETCH. |
ACKED |
count: uint32 |
Events acknowledged. |
NOTIFY |
count: uint32 |
New events available — client should FETCH. |
ROOM_CREATED |
room_id: bytes |
Room created successfully. |
ROOM_MESSAGES |
room_id: bytes, messages: [RoomMessageWire] |
Room messages response. |
ROOMS |
rooms: [RoomInfoWire] |
Discovered rooms response. |
KNOCK_OK |
request_id: bytes |
Knock stored successfully. |
KNOCK_NOTIFY |
room_id: bytes, knocker_eph_x25519_pk: bytes, request_id: bytes, handle: string|nil |
Knock notification to room admin. |
ADMIT_OK |
request_id: bytes, encrypted_grant: bytes|nil |
Knock admitted successfully. |
ADMITTED |
room_id: bytes, request_id: bytes, admin_pubkey: bytes, encrypted_grant: bytes|nil |
Notification to knocker that they've been admitted. |
TOKEN_REGISTERED |
(no fields) | Member token registered successfully. |
TOKENS_REVOKED |
count: uint32 |
Room tokens revoked. |
ROOM_MESSAGE_DELETED |
message_id: int64 |
Room message deleted by admin (or already gone from a previous |
KEY_ROTATED |
room_id: bytes, revoked_count: uint32, grace_until: uint64 |
Room key rotation completed. |
KEY_ROTATION_NOTIFY |
room_id: bytes, grace_until: uint64 |
Notification to members that room key was rotated (re-register tokens). |
ROOM_STORED |
room_id: bytes, sequence_number: int64 |
Room message stored successfully via authenticated channel. |
ROOM_UPDATED |
room_id: bytes |
Room flags updated. |
PUSH_REGISTERED |
(no fields) | Push token registered. |
ROOM_NOTIFY |
room_id: bytes, count: uint32 |
New room messages available. |
DELIVERY_TOKEN_REGISTERED |
(no fields) | Delivery token registered successfully. |
DELIVERY_TOKEN_DEREGISTERED |
(no fields) | Delivery token deregistered successfully. |
MEMBER_KICKED |
room_id: bytes, member_pubkey: bytes |
Member kicked from room (admin confirmation). |
MEMBER_BANNED |
room_id: bytes, member_pubkey: bytes |
Member banned from room (admin confirmation). |
KICKED |
room_id: bytes |
You were kicked from a room (sent to kicked member). |
BACKUP_STORED |
version: uint32, stored_at: uint64 |
Backup stored successfully. |
BACKUP |
ciphertext: bytes, version: uint32, stored_at: uint64 |
Backup returned in response to FETCH_BACKUP. |
BACKUP_EMPTY |
(no fields) | No backup exists for this pubkey. |
BACKUP_DELETED |
(no fields) | Backup deleted successfully. |
BACKUP_FAIL |
reason: string |
Backup operation failed. |
GROUP_CREATED |
room_id: bytes |
Group created successfully. |
GROUP_DELETED |
room_id: bytes |
Group deleted successfully. |
The kind field of a Rumor (§4.3) takes one of
these uint8 discriminants. New kinds are appended;
deprecated ones stay reserved.
| Kind | Discriminant | Description |
|---|---|---|
Presence |
0x01 |
Place- and time-gated message from sender to recipients. |
Revoke |
0x02 |
Sender revokes a previously sent event. |
Ack |
0x03 |
Application-level acknowledgement of a received event. |
KeyRotation |
0x04 |
Sender announces migration to a new public key (proves ownership of old key). |
ContactDiscovery |
0x05 |
Discovery handshake message (deferred — see Phase 6). |
CarryGroupInvite |
0x06 |
Recipient → courier carry-delegation manifest (Phase 7; reframed 2026-05-01 — see design.md §6.1.4). |
CarryGroupAccept |
0x07 |
Courier → recipient signed acceptance of a carry delegation (Phase 7; reframed 2026-05-01 — see design.md §6.1.4). |
KeyExchangeReturn |
0x08 |
Responder's key returned to the initiator after a deep-link key exchange. |
GroupInvite |
0x09 |
Invitation to join a persistent encrypted group (carries room_id and room_key). |
RelayHint |
0x0A |
Sender advertises the relay currently holding their events. See design.md §4.3 (RELAY_HINT) and §8.11 (Cross-Relay Routing). |
MemberCertRequest |
0x0B |
Member → admin: request a membership cert over the member's per-room signing pubkey (Phase B threshold reveal). GiftWrapped. |
MemberCertGrant |
0x0C |
Admin → member: granted membership cert (Phase B). GiftWrapped. |
AdminDelegationRequest |
0x0D |
Delegate → admin: offer to co-admin a room; carries the delegate's per-room admin pubkey to be certified (per-room admin keys / AD5). GiftWrapped. |
AdminDelegationGrant |
0x0E |
Admin → delegate: granted inline `AdminDelegationCert` (AD5). GiftWrapped. |
Sealed-sender HTTP endpoints; see §6 for the design and §C.2 for the per-token and per-IP rate limits that gate them.
| Method | Path | Body | Description |
|---|---|---|---|
POST |
/v1/room/submit |
SealedRoomSubmit |
Anonymous sealed room message submission. Verified by admin-signed 12-byte member token; no auth handshake. |
POST |
/v1/note/submit |
SealedNoteSubmit |
Anonymous sealed Note submission. Verified by per-contact 12-byte delivery token derived from X25519 shared secret; no auth handshake. |
SealedRoomSubmit (POST /v1/room/submit body)| Field | Type | Description |
|---|---|---|
room_id |
bytes |
Room identifier (16 bytes). |
member_token |
bytes |
Admin-signed member delivery token (12 bytes). |
nonce |
bytes |
XChaCha20-Poly1305 nonce (24 bytes). |
ciphertext |
bytes |
Encrypted message ciphertext. |
SealedNoteSubmit (POST /v1/note/submit body)| Field | Type | Description |
|---|---|---|
delivery_token |
bytes |
Per-contact delivery token (12 bytes). |
ephemeral_pk |
bytes |
GiftWrap ephemeral public key (32 bytes). |
nonce |
bytes |
XChaCha20-Poly1305 nonce (24 bytes). |
ciphertext |
bytes |
GiftWrap ciphertext. |
created_at |
uint64 |
Event creation timestamp (unix seconds). |
expires_at |
uint64 |
Event expiration timestamp (unix seconds). |