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. Not part of v1: the schema is reserved (§4.3) with no senders or verifiers, and BIP-39 same-seed recovery is the sole v1 continuity mechanism (§2.3).
Each user generates an Ed25519 key pair on first launch, from a BIP-39 recovery phrase:
entropy = randombytes(16) // 128 bits
mnemonic = BIP39_English(entropy) // 12 words, single spaces
bip39_seed = PBKDF2-HMAC-SHA512(mnemonic,
salt = "mnemonic" || "", // EMPTY passphrase
iterations = 2048, output = 64)
seed = bip39_seed[0..32] // the Ed25519 seed
(pk, sk) = Ed25519_keypair_from_seed(seed)
A second client MUST reproduce this
chain exactly, or the same 12 words restore a different identity on
it; vector-13-mnemonic.json pins it, including the
64-byte intermediate. The seed is not the entropy, and a
seed cannot be turned back into words: a client that stores only the
seed cannot show the recovery phrase again (the iOS client keeps the
16-byte entropy for that).
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): derived as above; the recovery
secret users back up is the 12-word phrase. Users
SHOULD be prompted to back it up.Ed25519 keys are converted to X25519 for Diffie-Hellman key exchange:
Key imports and Ed25519-to-X25519 conversion reject invalid or small-order Ed25519 public keys. Every X25519 operation must reject a non-contributory (all-zero) shared secret before key derivation, including GiftWrap, BLE grants, and targeted encryption. Valid generated keys and their wire encodings are unchanged.
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.
BIP-39 same-seed recovery is the sole v1 continuity mechanism. An identity is its Ed25519 seed: re-entering the mnemonic on a device regenerates the same keypair, and the encrypted backup (addressed by pubkey, keyed from the seed) restores everything else. There is no key rotation and no cross-device delegation in v1.
One identity = one active device. Nothing on the wire carries a device dimension: event delivery is destructive per identity (an ACK purges the mailbox row for everyone holding the key), and the relay keeps exactly one backup row per pubkey, overwritten on every upload. Two devices running the same seed therefore race — whichever fetches and ACKs first consumes the event, and whichever backs up last wins the backup row. Clients MUST treat the mnemonic as a move, not a copy: restoring on a new device retires the old one. Sequential restore is the supported flow; concurrent same-seed devices are out of contract.
Key rotation
Deferred.
A KeyRotationContent schema is reserved — defined
once, in §4.3, matching the implemented struct — but no
client emits it and no code verifies it. (An earlier revision of
this section defined a second, incompatible
KeyRotation shape with an old_pubkey
and timestamp; that definition is gone — §4.3 is the only
one.) A BLE delegation-cert migration was designed and its
primitives exist in ppp-core, but its relay submission path was
never built and it is structurally incompatible with seed-derived
backups — a delegated fresh seed can neither address nor decrypt
the user's backup — so it is not part of v1 either.
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.
The algorithms are Ed25519, X25519, XChaCha20-Poly1305 and BLAKE2b. The
reference implementation (ppp-core) uses pure-Rust libraries
(ed25519-dalek, x25519-dalek,
chacha20poly1305, blake2b_simd), not libsodium;
libsodium, TweetNaCl or any equivalent library interoperates as long as
it implements the same constructions. The one place libraries
genuinely differ is signature verification of degenerate keys (see
the implementer notes after §3.3). All key derivations use the
protocol's own keyed-BLAKE2b kdf (§3.2), not
libsodium's crypto_kdf.
Algorithm: XChaCha20-Poly1305 (AEAD)
Nonce: 24 bytes, randomly generated per encryption
Key: 32 bytes
Auth tag: 16 bytes (appended to the ciphertext)
encrypt(plaintext, nonce, key) -> ciphertext || tag
decrypt(ciphertext || tag, nonce, key) -> plaintext | error
Equivalent to libsodium's crypto_aead_xchacha20poly1305_ietf_encrypt/decrypt
with empty associated data.
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. The KDF is keyed BLAKE2b: the shared secret is the BLAKE2b key, and the ASCII context string is the message:
kdf(shared_secret, context) = BLAKE2b(
key: shared_secret, // 32 bytes
input: context, // ASCII context string
out_len: 32
)
Each layer supplies its own context —
"ppp-seal-v1" for the Seal,
"ppp-wrap-v1" for the wrap (§3.3) — so the
two layer keys differ even though both come from a DH against the
same recipient key. The argument order is load-bearing: keying
BLAKE2b with the secret is not interchangeable with
hashing a concatenation of secret and context, and an
implementation that concatenates derives wrong keys for every
event, failing vector-02. (An earlier revision of
this section described exactly that inversion, citing a
"ppp-event-key-v1" context that has never existed;
crypto.rs::kdf is authoritative.)
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
content: EventContent // type-tagged payload (§4.3)
created_at: uint64 // unix timestamp
}
Three fields — there is no kind byte.
content is externally tagged by its variant-name
string (§4.1); the numeric kind ids of §4.2 are
internal identifiers that never appear in a serialized event.
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 { // positional array, field order is the contract (§14.1)
version: uint8 // 1
routing_tag: bytes32 // v1: recipient's Ed25519 pubkey
ephemeral_pk: bytes32 // ephemeral_wrap.ed25519_pk
nonce: bytes24
ciphertext: bytes // wrapped_seal
created_at: uint64 // copied from rumor.created_at
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)
Implementer notes.
GiftWrap.created_at is copied verbatim from
rumor.created_at — there is no jitter, so the relay
sees the sender's clock.seal.ciphertext only, not
the recipient or either ephemeral key.ed25519-dalek's
verify (the standard cofactorless equation), which does
not reject a small-order rumor.sender key;
libsodium's crypto_sign_verify_detached does. Honestly
generated keys and signatures verify identically under both, so the
difference matters only for degenerate sender keys. The sender key is
never used in a Diffie-Hellman step (wrapping uses ephemeral keys),
so the weak-key and non-contributory checks of §2.2 do not
cover it; the relay rejects weak keys at AUTH.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 as MessagePack, and the encoding is normative byte-for-byte — the test vectors (§C) are the conformance criterion. First-party clients consume this layer through ppp-core (UniFFI bindings) rather than reimplementing it; a from-scratch implementation validates against the vectors.
Three serialization shapes exist, all produced by serde:
Rumor,
Seal, GiftWrap) and
relay→client messages: positional arrays
in declared field order. No field names and no integer keys
appear on the wire; field order is the contract, and
evolution appends fields.EventContent: an
externally-tagged enum — a single-entry map whose key is
the variant-name string
("Presence", "Revoke", …).
The twelve variant names are frozen v1 wire constants,
CI-pinned in ppp-core.cmd field (§5.1).Two further shapes appear inside event content:
Region is an
internally-tagged enum: a positional array whose first
element is the tag as a MessagePack string
("0" for a circle), not an integer (§4.4).PresenceContent
is 5 elements for a text note and 6 when it carries a
payload (§4.3). Decoders
MUST accept both lengths.Byte fields use the MessagePack bin family, so
framing size is independent of byte values — with two
exceptions that are now frozen wire format:
PresenceContent.recipients ([bytes32]) and
CarryGroupInviteContent.carry_tokens
([bytes12]) encode each key as an array of
integers, so a byte ≥ 0x80 costs two bytes
(cc xx). Decoders MUST
read them that way; changing them would break every existing
client. An earlier revision of this section specified integer field
keys 1–14; that scheme was never implemented anywhere —
nothing on the wire has ever used it.
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.
The numeric values are internal and FFI identifiers, used by the ppp-core API and by this document's prose. They are not wire bytes: a serialized event carries the variant-name string tag (§4.1), never the kind number.
| Kind | Value | Description |
|---|---|---|
| PRESENCE | 0x01 | Place- and time-gated event (primary use case) |
| REVOKE | 0x02 | Sender revokes a previously sent event |
| ACK | 0x03 | Reserved — no content variant; acknowledgement is the relay ACK command (§5.1.2) |
| KEY_ROTATION | 0x04 | Sender announces a new public key |
| CONTACT_DISCOVERY | 0x05 | Reserved — no content variant; contact discovery is deferred (§9) |
| CARRY_GROUP_INVITE | 0x06 | Recipient → courier carry delegation (cert + carry tokens, §8.1) |
| CARRY_GROUP_ACCEPT | 0x07 | Courier → recipient signed acceptance of a carry delegation |
| 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 §4.4)
time_window: {
starts_at: uint64 // unix timestamp
ends_at: uint64 // unix timestamp
timezone: string // IANA timezone identifier
}
recipients: [bytes32] // list of recipient pubkeys (int arrays, §4.1)
payload?: PresencePayload // OMITTED when Text — 5 or 6 elements
}
PresencePayload = // externally tagged, like EventContent
"Text" // default; never serialized (field omitted)
| { "Mood": [ id: string ] } // e.g. "cheers"; message SHOULD be empty
Events written before payload existed have five
elements; a text note is still written that way, byte for byte. A
decoder MUST accept both lengths and
treat a missing payload as Text. Mood ids
are a renderer-defined catalogue; the protocol does not enforce
membership.
REVOKE (0x02):
RevokeContent {
event_id: bytes16 // ID of the event to revoke
}
KEY_ROTATION (0x04) Deferred: reserved schema — no client emits it, nothing verifies it; v1 continuity is BIP-39 recovery (§2.3). This is the single authoritative definition.
KeyRotationContent {
new_pubkey: bytes32 // the replacement public key
migration_sig: bytes64 // sign(old_pk || new_pk, old_sk)
}
CARRY_GROUP_INVITE (0x06):
CarryGroupInviteContent {
delegation_cert: CarryDelegationCert // §8.1
carry_tokens: [bytes12] // one per carried contact; array of
// uint arrays, not bin (§4.1)
contact_hints: [ContactHint] // may be empty
}
ContactHint { token: bytes12, display_name: string }
The name is historical: the kind was reframed on 2026-05-01 from a carry-group invite to a pairwise carry delegation.
CARRY_GROUP_ACCEPT (0x07):
CarryGroupAcceptContent {
delegation_id: bytes16 // matches the cert
courier_pk: bytes32
accepted_at: uint64
sig: bytes64 // Ed25519(courier_sk, BLAKE2b(key: courier_pk,
// msg: delegation_id || accepted_at_le64
// || "ppp-carry-accept-v1", 32))
}
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). 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 are ordinary GiftWrapped events — the relay cannot distinguish them from any other Note.
RelayHintContent {
relay_url: string // relay currently holding the sender's events
issued_at: uint64
expires_at: uint64
}
MemberCertRequestContent { // member → admin
room_id: bytes16
signing_pk: bytes32 // member's per-room signing key (§7.2)
}
MemberCertGrantContent { // admin → member
room_id: bytes16
signing_pk: bytes32
cert: bytes64 // Ed25519(admin, "PPP:ROOM_MEMBER_CERT:v1\0"
// || room_id || signing_pk)
}
AdminDelegationRequestContent { // prospective delegate → admin
room_id: bytes16
delegate_admin_pk: bytes32
}
AdminDelegationGrantContent { // admin → delegate
room_id: bytes16
delegate_admin_pk: bytes32
cert: bytes // MessagePack AdminDelegationCert (§7.8)
}
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. Enforcement is at region creation (sender side); the relay never sees regions.
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 = [ // positional array of 4 (0x94)
region_type: string "0" // MessagePack STRING "0" (a1 30), not an integer
latitude: float64 // center latitude (WGS84), cb + 8 bytes
longitude: float64 // center longitude (WGS84), cb + 8 bytes
radius_m: uint32 // meters [10 .. 50,000], smallest uint encoding
]
The tag is a string because Region is a serde
internally-tagged enum whose circle variant is renamed
"0". Wire size for r = 500: 1 + 2 + 9 + 9 + 3
= 24 bytes (vector-07 shows
94 a1 30 cb… cb… cd 01f4).
| 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). These bounds and the radius bounds are enforced where a region is created (the sender side); a decoder accepts whatever arrives, so a receiver that cares must check them itself.
Receiver-side presence check (haversine):
R = 6_371_008.8 // metres (IUGG mean radius)
a = sin²(Δlat/2) + cos(lat1)·cos(lat2)·sin²(Δlon/2)
d = R · 2 · atan2(√a, √(1−a))
is_inside = (d <= region.radius_m)
Use exactly this radius and the atan2 form:
vector-04's tolerance is 0.001 m, which a different Earth radius
fails.
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: today an unknown
region_type makes the whole Rumor undecodable —
there is only one variant and no bounding-circle field to fall back
to — so a client discards the event. A future region type
therefore needs either a coordinated client upgrade or a new field
that carries a bounding circle alongside it.
Event IDs are 32-byte BLAKE2b hashes of the GiftWrap envelope.
There is one formula, used by both the authenticated
WebSocket (STORE) and the unauthenticated sealed-Note
endpoint (POST /v1/note/submit). It is deterministic
from relay-visible fields, so the relay can deduplicate without
decrypting anything — and it is computable from the bytes
FETCH returns, which is how a recipient names an event
in ACK, in a delivery receipt (§6.8), and in a
report. FETCH carries no ids.
event_id = BLAKE2b(32,
giftwrap.ephemeral_pk || giftwrap.nonce || giftwrap.ciphertext
)
Room messages are not named by this formula; the relay assigns them an integer sequence number (§7.3).
The output is 32 bytes. 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.
Event-layer structs are encoded as arrays in field order, with no field names or keys at all (§4.1). That saves 5–15 bytes per field against named maps — meaningful for a ~500-byte event, especially over BLE — at the price that field order is the contract and new fields can only be appended.
Until 2026-09 sealed Notes had their own id, domain-separated and
bound to the delivery_token and
created_at. No recipient could compute it — the
recipient never learns which token a sender used, and
FETCH returns no ids — so sealed Notes could not
be acknowledged and their delivery receipts matched no report. Its
replay protection did not hold either: created_at is
chosen by the submitter, so a replay simply picks a new one.
Squatting an id before the real envelope arrives needs the exact
ephemeral_pk || nonce || ciphertext, which only the
sender and the relay hold — the same exposure the
authenticated path has always accepted. Replay protection is the
recipient's local deduplication.
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.
The WebSocket upgrade carries no protocol semantics. All
authentication, versioning, and capability signaling is in-band
— inside MessagePack frames after the upgrade completes.
Relays MUST NOT condition protocol
behavior on upgrade headers (Origin, User-Agent, cookies, or any
custom header), and future protocol revisions
MUST NOT move semantics into the
handshake. Rationale: the browser WebSocket API
cannot set custom upgrade headers, so a header-carried
requirement would lock out every web-platform client while
remaining invisible from native ones.
Connection endpoint: wss://<relay-host>/v1/ws
The upgrade itself is a bare RFC 6455 handshake (TR-06): it carries
no protocol semantics, and everything protocol-relevant —
authentication (§5.1.1), versioning, capabilities —
travels in-band after it completes. A client connecting with a bare
browser new WebSocket(url) reaches full protocol
function. The relay does read the client's IP address at the upgrade
(from the reverse proxy's X-Forwarded-For) for
connection admission: over the session or upgrade-rate budgets it
refuses with HTTP 429 or 503 (§C.3).
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.
Clients MUST ignore a server
message whose cmd tag they do not recognize: log and
drop it, keep the socket open, and leave in-flight requests
undisturbed. Unsolicited notifications are the protocol's main
evolution path, so an unknown tag is a newer relay speaking, not
an error. A frame that is not valid MessagePack, or a recognized
message that fails to decode, remains a fatal framing error. The
relay applies the mirror rule to unknown client commands
(STORE_FAIL, socket held open).
On connection, the relay issues a challenge:
Relay -> Client:
{ cmd: "CHALLENGE", nonce: bytes32 }
Client -> Relay:
{ cmd: "AUTH", pubkey: bytes32, sig: bytes64, proto?: uint }
where sig = crypto_sign_detached(
"PPP:AUTH:v1\0" || relay_host || nonce || pubkey, // normative
client_ed25519_sk)
or sig = crypto_sign_detached(nonce, client_ed25519_sk) // legacy
Relay -> Client:
{ cmd: "AUTH_OK" }
or
{ cmd: "AUTH_FAIL", reason: string }
After AUTH_OK, all subsequent commands are scoped to the authenticated pubkey.
Handshake rules. The client has 10 seconds
after CHALLENGE to send AUTH, and one
attempt. The first client frame must be a binary MessagePack
AUTH of at most MAX_AUTH_FRAME_BYTES
(512 B). An AUTH_FAIL ends the connection: the relay
drops the socket without a Close frame. The reason strings are
expected AUTH message (timeout, a non-binary first frame,
or a frame over the WebSocket size limit), malformed
message (over 512 B or not MessagePack),
expected AUTH command, pubkey must be 32
bytes, signature must be 64 bytes and
invalid signature — the last also covers a weak
public key, deliberately indistinguishable. An AUTH
sent after authentication answers AUTH_FAIL "already
authenticated" and the socket stays open.
Signature forms. New clients MUST sign the
domain-separated material:
"PPP:AUTH:v1\0" || relay_host || nonce || client_pubkey
(ppp-core auth_signing_material, FFI
ppp_auth_signing_material). relay_host is
the canonical relay hostname: the TLS SNI hostname the client
connected to, ASCII-lowercase, no port, no scheme (e.g.
ws.prelay.net); IDN hostnames MUST be given in A-label
(punycode) form — relay_host is always ASCII.
The tag binds the signature to this
protocol, this relay, and the signing identity; a raw-nonce
signature is context-free by comparison. The first-published form
— a signature over the bare nonce — is legacy:
relays dual-accept it for fielded clients, log the presented form
per connection (auth_form=raw|tagged), and will drop
raw-nonce acceptance once that log shows the fleet has migrated.
Relays reject small-order (weak) public keys at AUTH; with weak
keys out, an Ed25519 signature binds exactly one message, the two
forms cannot cross-verify, and dual-accept form attribution is
unambiguous.
Relays learn their own hostname via configuration
(PPP_RELAY_HOST); a relay without it accepts the
legacy form only. The full byte transcript for both forms is
pinned by test-vectors/vector-10-auth-transcript.json
(§15).
proto announces the protocol major version the client
speaks. It is optional, and absence means v1: the
field postdates the first published protocol, so clients that never
send it are v1 by definition — that equation is permanent. Relays
MUST accept AUTH with or without it and MUST NOT condition v1
behavior on its value; it exists so a future revision can
distinguish client generations in-band (see the transport rule in
§5.1) without changing the handshake. Clients SHOULD send
proto: 1.
STORE — submit an event for delivery:
Client -> Relay:
{ cmd: "STORE", event: bin } // MessagePack-serialized GiftWrap bytes, not a nested map
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.
A duplicate answers STORE_FAIL "duplicate event_id".
A client retrying a STORE whose STORED it
never saw SHOULD treat that reply as
success. Relay deduplication lasts only while the row exists (until
an hour after ACK, or expiry); lasting replay protection
is the recipient's local deduplication. A GiftWrap addressed to a
room's per-room admin key A0 is delivered to that room admin's own
mailbox (§7.8); the ciphertext is unchanged and the admin
unwraps it with the A0 secret. Every command is checked against its
per-pubkey budget before anything else (§C.3); a refusal reads
rate limit exceeded for <CMD>: max N per Ws. A
full mailbox reads quota exceeded: …, and physical
storage pressure relay capacity reached (§7.3).
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.
Reply order. The relay processes a connection's
commands one at a time and answers every binary frame with exactly
one reply, in order; only the notification family
(NOTIFY, ROOM_NOTIFY,
KNOCK_NOTIFY, …) can arrive in between. A client
may rely on that ordering to match replies. A client that pipelines
without tracking order MUST NOT
assume a failure belongs to the oldest or the most recent request;
in_reply_to names the command, which disambiguates
everything except two in-flight commands of the same kind.
One reply carries neither in_reply_to nor a request id:
a frame that is not a decodable client command answers
STORE_FAIL "invalid command" (socket held open).
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. created_at is the
sender's clock (§3.3) and the relay does not check it,
so since is not a delivery watermark: a Note queued
offline, or from a sender whose clock runs behind, can carry a
created_at below a recipient's last fetch and would be
skipped. Clients SHOULD fetch with
since = 0, follow the cursor, and ACK what
they processed — acknowledged events are no longer returned
— which is what the iOS client does. 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.
No FETCH_CARRY. An earlier design had
couriers fetch a carry group with a dedicated command. It was dropped
with the carry-group model; couriers use ordinary FETCH
(§8.2), and the relay has no such command.
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). Ids that match nothing are
ignored, not errors, so count is the number actually
acknowledged. Acknowledged events stop appearing in
FETCH at once and are deleted an hour later.
NOTIFY — relay pushes a notification:
Relay -> Client:
{ cmd: "NOTIFY", count: uint }
Sent when new events arrive for the authenticated pubkey (including
sealed Notes and STOREs redirected to a room admin's mailbox), to every
connection of that pubkey. count is always 1. It is best
effort: a connection whose 256-entry notification queue is full misses
it, so clients MUST also
FETCH on connect rather than rely on
NOTIFY alone.
WS_MAX_MESSAGE_SIZE
(256 KiB), a relay write that stalls past
WS_WRITE_TIMEOUT_SECS, or a socket error drops the
connection with no Close frame.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 the live surface is
the WebSocket transport (§5.1), the two sealed-sender HTTP
endpoints (§6.3, §7), the three authenticated backup
endpoints (§6.7) and GET /v1/health (§C.6). 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 }
Relay -> Client:
{ cmd: "PUSH_REGISTERED" }
or
{ cmd: "STORE_FAIL", reason: string, in_reply_to?: string }
Push payloads MUST NOT contain event content — they serve only as wake signals for the client to FETCH.
The platform set is open. "apns" and
"fcm" are the platforms the relay accepts today; a
registration naming any other platform is refused with
STORE_FAIL. Only APNs is delivered, though:
"fcm" registrations are validated, stored and answered
with PUSH_REGISTERED, but the relay has no FCM sender
yet and skips them when it pushes. An Android client registering
today receives no wake signals and must rely on its own
reconnect/FETCH cadence until FCM delivery ships. New platforms (e.g. a Web Push platform,
whose “token” is a push-subscription endpoint URL) are
added relay-side with no wire change, so clients
MUST NOT treat the set as closed.
Tokens are opaque. Clients
MUST pass push tokens through exactly
as issued by the platform and
MUST NOT pre-validate, normalize, or
otherwise interpret them. The relay-side constraints — the
MAX_PUSH_TOKEN_BYTES cap and the per-platform alphabet
checks below — are provisional relay policy sized to
the platforms live today, not protocol constants: they can be
loosened server-side at any time, and a future platform is already
known to need it (a Web Push endpoint URL contains /
and can approach the current cap). A second implementation
MUST NOT replicate them
client-side.
Relay policy today: the token's alphabet is validated,
not merely its length, per platform — apns
tokens must be hexadecimal, fcm tokens limited to
alphanumerics and - _ : . (FCM tokens are not hex, so
a single alphabet for both would reject every valid one) —
and a failing token is rejected with STORE_FAIL.
The reason a check exists at all 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 construct that URL from
percent-encoded path segments rather than by string interpolation
— that structural control is platform-independent and
load-bearing, and it is what makes the per-platform alphabet
policy safe to loosen.
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 reasons Unregistered,
BadDeviceToken or DeviceTokenNotForTopic;
the FCM equivalents once FCM delivery exists), the relay
MUST delete the affected
(pubkey, token) row so dead devices stop accruing
send attempts.
Pushes fire on the following triggers, when no WebSocket notification reached the target (normally: the recipient or admin has no connection; a connection whose notification queue is full counts as not reached):
ROOM_NOTIFY (§7.5). Other members
being online does not suppress the admin's push; the admin being
connected but not subscribed to the room does not either.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 or filter by location, and the database has no sender column
(an authenticated STORE does reveal the storing connection at the
time, §13).
event_id primary key.(recipient, created_at)
with partial index excluding acknowledged events.acked_at; the event
disappears from FETCH immediately and is deleted one
hour later.expires_at, events acknowledged
more than 3,600 s ago, expired rooms (cascading to messages,
tokens, knocks, membership records and storage counters), member
tokens whose rotation grace has ended, expired knocks, expired
backup tokens, push registrations not refreshed within
PUSH_REGISTRATION_TTL_SECS (90 days) and backups not
re-stored within BACKUP_TTL_SECONDS (180 days).
Groups never expire; DELETE_GROUP removes them.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 per-IP budget
on these endpoints (SEALED_IP_RATE_LIMIT in
§C.2). The same budget, in a separate bucket, also guards the
three /v1/backup/* routes, and WebSocket upgrades have
their own per-IP admission limits (§C.3). The reference relay's
limiters are fixed windows (a window starts with a bucket's
first request and resets when it elapses), not sliding windows. A
refused HTTP request gets 429 with the body
rate limited (retry after Ns); there is no
Retry-After header on these routes. 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 X-Forwarded-For
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 —
the address the trusted proxy itself appended; anything to its
left could be client-supplied. An unparseable entry falls back to
the peer. Forwarded and X-Real-IP are
not read.This is correct for exactly one trusted proxy hop, which is the reference deployment (Caddy directly in front, no CDN). A deployment with more than one proxy hop would attribute every request to the inner proxy; supporting it needs a right-to-left walk that skips trusted entries, which the reference relay does not implement.
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).max delivery tokens per recipient reached (500) —
or relay capacity reached) answers
STORE_FAIL.DEREGISTER_DELIVERY_TOKEN { delivery_token } removes a
token the caller registered and answers
DELIVERY_TOKEN_DEREGISTERED. It succeeds even when the
token is unknown or belongs to someone else (nothing is deleted
then), so it cannot be used to probe other users' tokens.
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 — the
same formula as the authenticated path, so the recipient can
ACK and report the Note by the id it computes from
the fetched bytes.MAX_ACTIVE_ENVELOPES_PER_RECIPIENT) → 507;
relay physical capacity reached → 503.Responses. Success is HTTP 200 with the JSON body
{"ok": true} (the request is MessagePack, the reply is
JSON) and no receipt (§6.8). Errors: body not MessagePack, a
field of the wrong length, empty ciphertext or an already-expired
expires_at → 400; ciphertext over
MAX_CIPHERTEXT_SIZE, or a body over
SEALED_BODY_LIMIT_BYTES → 413; unknown token
→ 401; per-IP or per-token budget → 429; duplicate
event_id → 409 (treat as success when retrying);
recipient mailbox full → 507; relay capacity → 503; any
other storage error → 500.
Shared HTTP limits. The sealed and backup routes
share a cap of MAX_INFLIGHT_HTTP_REQUESTS (32) requests
in flight; excess requests are shed with 503. A request whose body
has not finished arriving within BODY_READ_TIMEOUT_SECS
(330 s) is cut off with 408.
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.
Retention: a backup is kept until
BACKUP_TTL_SECONDS (180 days — constants
table) has passed since its last store; every store refreshes
the clock, so the bound prices abandonment, not content age.
Clients SHOULD re-upload at least every 30 days even when the
payload is unchanged — a client that suppresses no-op
uploads indefinitely (for example via a content digest) would
otherwise age its user out of the relay while the app is still
installed. An explicit delete
(POST /v1/backup/delete) remains immediate.
Exactly one backup command lives on the WebSocket:
MINT_BACKUP_TOKEN (autogen'd into §C.1). It
answers with BACKUP_TOKEN { token, expires_at }
— a single-use random token of
BACKUP_TOKEN_BYTES (32) bytes, redeemable for
BACKUP_TOKEN_TTL_SECS (600 s) — or
BACKUP_FAIL with a stated reason. The backup
payloads themselves travel over authenticated HTTPS: three
POST /v1/backup/* endpoints (autogen'd into
§C.6), each redeeming one minted token, carried in the
MessagePack request body — never the URI or a
header, because the reverse proxy logs both verbatim. Backups
moved off the WebSocket on 2026-07-31: they were the only large
frames the socket ever carried, and moving them is what let
WS_MAX_MESSAGE_SIZE drop to 256 KiB.
POST /v1/backup/store { token, ciphertext }
— upserts the single per-pubkey row, increments the
version counter, returns
{ version, stored_at }. The ciphertext is bounded
by MAX_BACKUP_SIZE (constants table), which sits
64 KiB under BACKUP_HTTP_BODY_LIMIT_BYTES so
an oversize backup is refused by the handler with a stated
reason (413) instead of dying at the body-limit layer with no
explanation.POST /v1/backup/fetch { token } — returns
the latest { ciphertext, version, stored_at } for
the minting pubkey, or 404 if none exists. The 404 is
meaningful and terminal (“you have no backup”),
unlike the 401, which just means “mint again”.POST /v1/backup/delete { token } — removes
the row. Idempotent; 204 either way.Token redemption is atomic and consuming: replaying a token finds nothing, and the 401 deliberately does not distinguish never-existed, expired and already-used — all three mean “mint a new one”. Malformed requests (bad token length, empty or oversize ciphertext) are refused before redemption and do not consume the token; a request refused after redemption (over the per-operation budget) has consumed it. The mint budget is sized so re-minting is never the bottleneck. These routes are deliberately not sealed-sender: a backup is per-identity state, so the relay learning which pubkey acted is inherent (see §C.6).
The backup key is derived client-side from the identity's
32-byte Ed25519 seed — keyed BLAKE2b with the seed as the
key and "ppp-backup-v1" as the message, 32-byte
output — and the blob is
AES-256-GCM(NHB1(payload)) (container format in
docs/specs/protocol/backup-payload.md). Because the
seed is recoverable from the BIP-39 phrase (§2.1), a clean
device with only the phrase can re-derive the key. Restoring is
not anonymous, though: the fetch token is minted over a WebSocket
authenticated as that identity, so the relay learns which pubkey
restored. The per-operation budgets stay tight and are enforced by the HTTP
handlers after token redemption, keyed on the redeeming pubkey
(STORE_BACKUP, FETCH_BACKUP and
DELETE_BACKUP at 5/hour each, see §C.3;
MINT_BACKUP_TOKEN at 30/hour bounds only token
churn), 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.
The relay signs a delivery receipt for every
stored event and room message, and returns it alongside the data
(the trailing receipts array in EVENTS, the
per-message receipt in ROOM_MESSAGES, and the
POST /v1/room/submit response). The store
acknowledgements — STORED,
ROOM_STORED, and the sealed-Note
{ ok: true } — carry none; a sender of a Note
has no receipt, only the recipient does. On the WebSocket a
receipt is the MessagePack triple
{ key_id: uint8, stored_at: uint64, sig: bytes64 };
the sealed room submit returns it as JSON,
{ sequence_number, receipt: { key_id, stored_at, sig } }
with sig hex-encoded. Either way the signature is an
Ed25519 signature over one of two domain-separated materials:
note receipt material:
"PPP:RECEIPT:note:v1\0" || event_id(32) || stored_at_be(8)
room receipt material:
"PPP:RECEIPT:room:v1\0" || room_id(16) || seq_be(8)
|| content_hash(32) || stored_at_be(8)
where content_hash = BLAKE2b-256(nonce || ciphertext)
Receipts exist for abuse-report verifiability: they let
the moderation verifier prove, at triage time, that reported
bytes are exactly what the relay stored — a reporter cannot
forge "the relay delivered X". Clients treat receipts as
opaque. A client MUST store the receipt alongside the
message it came with and include it verbatim in any report; a
client MUST NOT reject or alter messages based on receipt
contents, and does not verify receipt signatures in v1. The
verifying party is the operator's verifier
(ppp-report-verify), which holds the
receipt-key registry — a committed JSON mapping
key_id → pubkey per environment
(prod=1, sandbox=2 by convention;
test vectors use ids ≥ 9). This division is deliberate: a
relay that mints bogus receipts only destroys its own evidence
trail, so client-side pre-verification defends nothing in the
v1 threat model. A future tier in which third parties verify
receipts without trusting the operator would need an
authenticated in-band key advertisement, and is deferred with
that shape noted.
The schema-v2 report bundle is the JSON a
reporting client submits to /v1/report/submit for a
room message: schema: 2, report_id,
surface, mode, reason,
created_at (filing time, unix seconds),
room_id (hex), message_seq,
content_hash (hex), the receipt
triple (sig hex), an evidence object
(nonce and ciphertext base64,
k_msg hex), and reported_sig_pk
(hex). k_msg is the per-message key
BLAKE2b(key: room_key, msg: nonce ||
"ppp-room-msg-key-v1", 32) — revealing it opens exactly
one message, never the room key. The verifier checks, in order:
the receipt signature over the room material above; decryption
of the evidence under k_msg; and the sender's
per-room signature inside the decrypted payload, over
"PPP:ROOM_MSG:v1\0" || room_id(16) || nonce(24) || ts_be(8)
|| handle_len_be(2) || handle || text_len_be(4) || text
yielding verified (all three pass),
failed (some check actively failed — itself
signal), or unverifiable (chain incomplete, e.g. a
legacy or evidence-less report). Every construction on this page
is pinned by vector-09-receipts-report.json,
including a complete deterministic bundle that must produce
verified against the registry embedded in the
vector.
A Note report uses the same envelope with
surface: "note": event_id (hex, the
§4.5 id of the reported GiftWrap), the note
receipt triple, and an evidence object
carrying the original giftwrap bytes (base64) plus the
two layer keys that open exactly that envelope,
wrap_key and seal_key (hex, from
derive_unwrap_keys; the recipient's secret key never
leaves the device). Optional context fields are
details, presence_id and
reported_pubkey. The verifier checks that
event_id is the id of the evidence bytes, unwraps both
layers, checks the Seal signature against
rumor.sender, and checks the receipt over the note
material. Verifying a Note report shows the operator the full Rumor,
region and time window included. Reports in a gated room can
instead use the threshold mode, in which the operator learns the
content only after N = 3 independent members have reported
the same message (§7.3); those reports are verified per bucket,
not one by one.
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 the ciphertext and routes messages
by room_id. What it can read depends on the room kind:
a gated room's key is random and reaches members only
through admission (§7.4), so the relay cannot read its messages;
an open room's key is derived from the public
room_id (below), so anyone who knows the room — the
relay included — can. Senders are hidden from the relay only on
the sealed path (POST /v1/room/submit, authenticated by
an anonymous member token); an authenticated STORE_ROOM
is tied to the posting connection's identity.
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, // key distributed by admission, not derivable
proximity_required: bool, // reserved for future use
ble_enabled: bool, // accepts knock/admit (§7.4)
max_members: uint32, // 1-100 (schema CHECK)
expires_at: uint64, // now < expiry <= now + 30 days
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 and its first
active member, so it counts toward max_members. 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.
gated and the room key. The relay
stores gated and returns it in discovery, but enforces
nothing with it: reading and posting require admin, active
membership or a registered member token regardless (§7.2,
§7.3). What gated decides is where the key comes
from. A gated room's key is 32 random bytes held by the admin and
handed to each admitted member. An open room's key is derived by
every client from the room id:
open_room_key = BLAKE2b(
key: room_id, // 16 bytes
input: "ppp-open-room-key-v1",
output_len: 32
)
It gives no confidentiality against anyone who knows the room id,
which includes the relay; it only makes open-room ciphertext uniform
with gated rooms. Pinned by vector-12. Open rooms are
not enabled in the shipped iOS client (every room it creates is
gated), and the construction freezes when they are; the iOS source
still carries an older HKDF-SHA256 derivation for this path, which
must be replaced by the one above before open rooms ship.
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). The relay
rejects a token of the wrong length or format with
STORE_FAIL (since 2026-07-31), so a wrong level is a
loud error — but a client must still send the right
cell, which no validation can check. The one-token rule is
below. 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.
Discovery works only if every client computes byte-identical
strings, so the token encoding is part of the wire contract, not
a client detail. An s2_cell_id /
s2_coarse_id is the compact S2 token
of the cell: the 64-bit cell id in lowercase
hexadecimal with trailing zero digits stripped — the format
Google's S2 libraries produce as ToToken(). It is
not the face/position debug string
("3/04012"), and it is not uppercase: the relay
compares tokens as bytes, so two spellings of one cell would
partition the same place into halves that cannot see each
other.
Because trailing zeros are stripped and a cell's lowest set bit is fixed by its level, token length determines level: a level-13 token is always exactly 8 characters, a level-16 token exactly 9. The relay enforces both the alphabet and the length, per field:
| Field | Level | Token | Example (Berlin) |
|---|---|---|---|
DISCOVER_ROOMS.s2_cell_id (the query) |
DISCOVERY_S2_LEVEL = 13 | 8 lowercase hex | 47a851dc |
CREATE_ROOM.s2_coarse_id |
DISCOVERY_S2_LEVEL = 13 | 8 lowercase hex | 47a851dc |
CREATE_ROOM.s2_cell_id |
ROOM_S2_LEVEL = 16 | 9 lowercase hex | 47a851dff |
A room's coarse token MUST be the
level-13 ancestor of its own fine cell — not an independently
computed cell — or the room is discoverable from a place it
is not in. Groups carry an empty s2_cell_id and are
excluded from discovery entirely (§7.6); the empty string is
the group sentinel, never a valid query.
Exactly one token per query. Clients MUST NOT fan out to neighbouring cells. This is a privacy rule, not an efficiency one: which neighbours a client would ask for varies with its position inside the cell, so a fan-out hands the relay a finer fix than any single token — partially undoing the 64× coarsening that separates the query level from the room level. A client near a cell boundary sees only its own cell's rooms; that is the accepted cost. (A future client on coarse, cell-tower-grade location has no settled rule here — see the KaiOS review.)
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 count of current tokens (excluding those in a
rotation grace window, below) must be < max_members.
Registration is idempotent: re-registering a token that is already
current answers TOKEN_REGISTERED without counting it
again, even in a full room, so a client may retry after a lost
reply. Re-registering a token that a rotation retired, before the
relay has deleted it, makes it current again.
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 }
Every token that is still valid is marked with
grace_until; revoked_count counts them.
During the grace period tokens remain valid; after, they are
rejected, and the relay deletes them. A rotation never extends a
grace window already running and never touches a token whose grace
has ended, so a revoked token cannot become valid again at a later
rotation. The relay broadcasts a KEY_ROTATION_NOTIFY to
all room subscribers.
Tokens in a grace window do not count toward
max_members: they belong to the key being retired, and
the admin must be able to register every member's replacement token
while the old ones still work. For up to the grace period (at most
600 s) a room can therefore hold more than
max_members valid tokens — only the admin can
cause this, and max_members is the admin's own
setting.
Room and persistent-group history has cumulative byte and row budgets. Defaults are 16 MiB of ciphertext plus nonce bytes and 10,000 messages per room, with relay-wide limits of 512 MiB, 100,000 messages, and 10,000 rooms/groups. Operators may configure lower or higher budgets. Existing history remains readable when full; new messages are refused until deletion or expiry frees capacity. Physical storage pressure can also refuse new writes. Authorization is checked before these limits.
WebSocket refusals use STORE_FAIL with reason
room quota exceeded or relay capacity reached.
Sealed room submission returns HTTP 409 for the former and HTTP 503
for the latter. A refused write creates no receipt or notification.
Clients should preserve the unsent draft and avoid immediate retries.
Physical storage pressure refuses every other growing write the
same way: STORE, KNOCK, ADMIT,
REGISTER_TOKEN, REGISTER_DELIVERY_TOKEN,
REGISTER_PUSH and CREATE_ROOM answer
STORE_FAIL with relay capacity reached
(MINT_BACKUP_TOKEN answers BACKUP_FAIL
with the same reason); sealed Note submission and backup store
answer HTTP 503.
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 the next sequence number, notify subscribers. Sequence numbers are relay-global row ids: increasing within a room, but not contiguous (other rooms' messages take the gaps).
Who may read and post. FETCH_ROOM and
STORE_ROOM require the caller to be the room's admin or
to hold an active membership row (created by CREATE_ROOM
for the creator and by ADMIT). Refusals are deliberately
opaque — fetch denied / store denied,
the same for a nonexistent room — so outsiders cannot probe
which room ids exist. Sealed submission is authorised by the member
token instead.
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 on the legacy path are idempotent (a replay answers
success). On the signed-envelope path they are not: a repeated
envelope fails with replayed admin command, and a
retrying client re-signs with a fresh cmd_ts. 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.
Reporting a room message. A member can report
directly (the §6.8 bundle, which hands the operator the
per-message key) or, in a gated room, by threshold: the
operator learns the content only once N independent members have
reported the same message. Every member holds the room key and the
message bytes, so each derives the same secret-sharing polynomial
(deterministic Shamir over the ristretto255 scalar field) without
coordinating; the operator, lacking the room key, derives nothing. A
threshold report carries one point on that polynomial —
x derived from the reporter's per-room signing key, the
room id and the message sequence, and y = P(x) —
a commitment to the secret, the evidence bundle encrypted under a key
derived from the secret, the reporter's membership certificate
(MEMBER_CERT_GRANT, §4.3) and a signature over the
share ("PPP:REPORT_SHARE:v1\0" material). The operator's
verifier groups reports by (room_id, message_seq,
content_hash), discards shares with a bad certificate,
signature or x, and with N distinct certified members
interpolates the secret and opens the envelope; below N the status
is threshold-pending and nothing is revealed. The iOS
client uses N = 3 (ppp-core allows up to 16). The share
constructions are pinned by vector-08. Membership certificates
issued by delegate admins are not yet accepted.
For rooms created with ble_enabled set, prospective
members request admission without prior knowledge of the room
key:
What ble_enabled means. The flag
gates this knock/admit flow and nothing else: it means “this
room accepts knocks”. The name is historical, kept for wire
compatibility, and does not imply Bluetooth — the
entire flow below rides the relay, and the normative grant path is
in-band (ADMIT → ADMITTED). BLE grant delivery is an optional
proximity accelerator between co-present devices. Clients on
platforms without a BLE API
MUST set
ble_enabled: true on rooms they create when the room
should be joinable by knocking, and
MUST NOT interpret the flag as a
Bluetooth capability requirement. The relay refuses KNOCK against
a room whose flag is false.
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 after the
TTL fails with knock expired, and into a room past its
expires_at with room expired, even before
the relay's periodic purge has removed the rows.
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, // the room's A0 when set, else the creator's key
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. ADMIT refuses
with room is full when the room's active members
(creator included) reach max_members, unless the knocker
is already a member. That is a different counter from
REGISTER_TOKEN's, which counts current member tokens
(§7.2). 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) — the normative path, which every client
supports — or out-of-band via BLE when both devices are
co-present and capable (optional accelerator). 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 |
| KICKED | The removed member | KICK_MEMBER / BAN_MEMBER (compiled out today, §7.7) |
A room subscriber is a WebSocket connection that has made a
successful FETCH_ROOM for that room; it stays subscribed
until it disconnects. Subscription is per connection, not per
identity: with one identity on several devices, only the
connections that fetched the room receive its notifications, and a
connection that fetches repeatedly is still subscribed once. There
is no explicit subscribe command.
Status: the relay implements groups
(CREATE_GROUP / DELETE_GROUP are accepted
from any authenticated client), but they are not enabled in
the shipped iOS client (product decision 2026-08-13: off at
launch, to return in a reshaped form). Treat this section as the
relay's contract, not as a feature users can reach today.
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 253402300800 (10000-01-01T00:00Z) 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, tokens, knocks and the group's storage-usage
row in one transaction. It is the only way a group's capacity
slot (§7.3) is freed. 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
invite alone does not give the recipient relay
access: FETCH_ROOM and STORE_ROOM require
an active membership row, which only CREATE_GROUP and
ADMIT create, and sealed submission requires a member
token the admin has registered — derived from the invitee's
per-room signing key, which the admin does not know. The join path
that works end to end in v1 is therefore the knock/admit handshake
(§7.4) against a group ID shared by another channel; the
Gift-Wrap invite carries the key but still needs that
admission.
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. Groups have one root admin; the relay
also accepts single-hop delegate admins (§7.8), whose
certificates are capped at 30 days for groups.
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's admin — via the signed admin envelope (A0 or a
delegate, §7.8) or the legacy authenticated-connection check,
like every other admin command — 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). (A malformed admin_auth_pubkey at
CREATE_ROOM is silently dropped and the room is created
without A0.) 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. One
exception: a signed DELETE_ROOM_MESSAGE against a room
without A0 is refused outright (room has no admin authority
key) rather than falling back; send it unsigned (legacy form,
§7.3) for such rooms.
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. 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 only after verification
succeeds. A replay fails with STORE_FAIL "replayed admin
command", so a client retrying a command re-signs with a new
cmd_ts. params is fixed per command:
Command (cmd_tag) | params |
|---|---|
ADMIT | request_id (16 bytes) |
REGISTER_TOKEN | member_token (12 bytes) |
REVOKE_TOKENS | empty |
ROTATE_ROOM_KEY | grace_seconds as u32 big-endian, the raw value sent (before the relay's 600 s clamp) |
UPDATE_ROOM | 2 bytes, one per flag in the order gated, proximity_required: 0 = unchanged, 1 = set false, 2 = set true |
DELETE_ROOM_MESSAGE | message_id as i64 big-endian (8 bytes) |
KICK_MEMBER, BAN_MEMBER | member_pubkey (32 bytes) — compiled out today (§7.7) |
DELETE_GROUP | empty; root admin (A0) only |
vector-14-admin-params.json pins every row: params,
full material and signature for a fixed room, admin seed and
cmd_ts. Its test also drives each relay handler with a
freshly signed envelope from an unrelated connection, so the table
is checked against what the relay actually binds.
Deliberately outside the signature. ADMIT's
encrypted_grant and share_history are
excluded from params — and from the
replay-dedup key — by decision rather than accident. ADMIT
is one-shot (a knock is admitted atomically at most once), the
history floor is enforced by the relay itself, and the only actor
positioned to substitute either field is the relay, which already
controls their delivery outright; substituting the grant can at
most leave the knocker behind a wrong key, an outcome the relay
can already produce by refusing service. Binding them would
remove no relay capability. This holds while ADMIT's unbound
parameters stay limited to fields the relay could equivalently
withhold; a future parameter without that property must be bound
into params.
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
(seed = BLAKE2b(key: sk[0..32], msg: room_id ||
"ppp-room-admin-v1", 32), then an Ed25519 keypair from that
seed; pinned by vector-06), 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.
Status: the relay accepts delegate certificates
today. The shipped iOS client never issues them — co-admin is
off for launch (decided 2026-08-01) — so in practice every
admin command is signed by A0 or comes over the legacy
path.
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. In a gated room the relay sees only encrypted blobs; in an open room the key follows from the room's id, so the relay could read along.
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:
STORE_ROOM does
not.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 the relay (or over BLE, when both devices support it). Once you have the key, you can derive your member token and participate.
The 120-second TTL on knocks prevents stale requests from accumulating.
A courier is a contact who fetches a recipient's sealed
Notes from the relay while the recipient is offline, and hands them
over phone-to-phone by Bluetooth LE. The v0.1 design, revised
2026-05-01, is bonded, pairwise, single-hop and
inbound-only: one recipient delegates to one courier, the
courier carries Notes to the recipient, and nothing is
passed courier-to-courier. It needs no relay
feature: carry tokens are ordinary delivery tokens
(§6.2) registered under the courier's own pubkey, and carried
Notes arrive by ordinary FETCH. The iOS client
implements pairing, delegation and BLE delivery. Multi-hop
(§8.4) and the outbound direction are
Deferred.
The earlier “carry group” design — a relay-side
group_id → members mapping with
REGISTER_CARRY_GROUP / FETCH_CARRY
commands — was dropped: registering it would have told the
relay which courier serves which recipient. Its schema survives
unused in the relay database (carry_groups,
carry_group_members). Separately, BLE also appears in
the knock/admit flow (§7.4), which is a different mechanism.
The protocol MUST define a pairwise carry delegation: a recipient-signed authorization granting exactly one courier the right to fetch a specified set of carry tokens on the recipient's behalf for a bounded period.
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.
The recipient chooses an existing contact as courier (their key
is already known, §10) and signs a
CarryDelegationCert. The design calls for an in-person
numeric-comparison ceremony first; the shipped iOS client does not
perform one and relies on the contact's key as already imported.
The cert travels end to end inside a Gift Wrap and is never
registered with the relay, which would otherwise hold a standing
courier↔recipient mapping. Like every event-layer struct it is a
MessagePack positional array (six elements, 0x96):
CarryDelegationCert {
recipient_pk: bytes32
courier_pk: bytes32
valid_from: uint64
valid_until: uint64 // > valid_from, ≤ valid_from + 90 days
delegation_id: bytes16
signature: bytes64
}
delegation_id = BLAKE2b(key: recipient_pk,
msg: courier_pk || valid_from_le64
|| valid_until_le64 || "ppp-carry-deleg-id-v1",
output_len: 16)
signature = Ed25519_sign(recipient_sk,
BLAKE2b(key: recipient_pk,
msg: courier_pk || valid_from_le64 || valid_until_le64
|| delegation_id || "ppp-carry-deleg-sign-v1",
output_len: 32))
For each contact whose Notes the courier should carry, the recipient derives a carry token from the pairwise X25519 secret it shares with that contact:
carry_token = BLAKE2b(key: X25519(recipient_sk, contact_pk),
msg: recipient_pk || courier_pk || "ppp-carry-token-v1",
output_len: 12)
The recipient sends the cert and the tokens (with per-contact
display hints) to the courier in a Gift-Wrapped
CARRY_GROUP_INVITE (0x06). The iOS client sends it over
authenticated STORE, not sealed submission (the courier
may not yet hold a delivery token for the recipient), so at that
moment the relay sees the recipient's session store an event for the
courier; the database keeps no sender column, so the edge is not
retained. The courier answers with a
Gift-Wrapped CARRY_GROUP_ACCEPT (0x07), signed over
BLAKE2b(key: courier_pk, msg: delegation_id ||
accepted_at_le64 || "ppp-carry-accept-v1", output_len: 32),
and registers each carry token with
REGISTER_DELIVERY_TOKEN under its own
session. To the relay these are indistinguishable from the
courier's own delivery tokens; this is why the per-recipient token
cap (MAX_DELIVERY_TOKENS_PER_RECIPIENT) is 500.
A contact sends to the carry token over the sealed-Note endpoint
(§6.3) exactly as to a delivery token.
Expiry and revocation: both sides treat the
delegation as void once valid_until passes, and the
courier DEREGISTER_DELIVERY_TOKENs its carry tokens
(only the courier can: they are registered under its key). There
is no in-band revocation event in v0.1; the recipient revokes by
letting the cert expire or by asking the courier.
Online, the courier authenticates and runs a normal
FETCH. The page mixes its own Notes and carried ones;
anything it cannot unwrap with its own key is carried. It stores
those locally with their expires_at and does
not ACK them, so the relay copy stays until
delivery. After a BLE hand-over (§8.3) the courier ACKs the
delivered event ids to the relay.
Service B5E1A001-6D3C-4B8E-9F2A-1C7D5E3B9A08 (courier advertises)
HANDSHAKE_NONCE B5E1A002-… read 32-byte nonce, fresh per connection
AUTH_RESPONSE B5E1A003-… write blinded_tag(32) || hint(4) || sig(64)
EVENT_STREAM B5E1A004-… notify chunked GiftWrap events
EVENT_ACK B5E1A005-… write event_id(32) || sig(64)
The advertisement carries only the service UUID — nothing that identifies a recipient.
HANDSHAKE_NONCE.AUTH_RESPONSE:
blinded_tag = BLAKE2b(key: delegation_id zero-padded to 32,
msg: recipient_pk || session_nonce || "ppp-carry-ble-tag-v1",
output_len: 32); hint = the first 4 bytes of
delegation_id; sig = Ed25519 by the
recipient over session_nonce || blinded_tag ||
delegation_id. The tag changes every session, so a
bystander cannot link sessions to a recipient key; the hint lets
the courier find the cert without trying each one.recipient_pk, and checks valid_until.EVENT_STREAM.EVENT_ACK: the event id (§4.5) and an Ed25519
signature over event_id || session_nonce. The courier
verifies it before marking the event delivered, so a bystander who
sniffed an id cannot make it drop an undelivered event.Each notification is sequence_le16 || total_le16 ||
data; the payload per chunk is the negotiated MTU minus 3 (ATT)
minus the 4-byte header. The recipient reassembles by sequence
number and aborts past 1,024 chunks.
Couriers limit handshake attempts: at most one
AUTH_RESPONSE evaluation per connection per 10 s,
and at most 10 distinct blinded tags per hour. Failed attempts are
logged locally only.
The protocol SHOULD eventually support multi-hop courier relay, where a courier transfers carried events to another courier for onward delivery.
v0.1 implementations MUST NOT exchange carried events courier-to-courier. Reintroducing it needs a bundle-age field so TTLs survive unsynchronised clocks across hops, a way to detect a middle courier silently dropping events, and a blinded handshake for courier-to-courier hand-off where neither side is the recipient. The outbound direction (offline sender → courier → relay) is deferred as well, chiefly because an offline sender may encrypt to a key the recipient has since rotated.
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 hands the messages to yours. You set this up once by naming that friend, already one of your contacts, as your courier.
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:
Passing envelopes courier-to-courier widens the set of devices that hold them and gives a middle courier an undetectable way to drop or delay mail. Background BLE on iOS also does not reliably support two couriers meeting by chance, which is most of the value. Single-hop, pairwise delivery covers the rural last mile without those costs.
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 (§9.5) |
| Relay-side brute-force | Trivial for operator | PSI migration (§9.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.
Status — what ships today. None of the signed
verification flow in §10.1–10.9 is implemented: there is no
ppp-verify-v1 payload, no /v or
/va URL, no verification levels and no fingerprint
comparison. Contacts are added by an unsigned key link,
https://nowherethen.com/key/<base64 Ed25519 pubkey>?handle=<handle>,
shared over any channel; importing it sends a Gift-Wrapped
KEY_EXCHANGE_RETURN (0x08, §4.3) back through the
relay so the initiator imports the responder's key without a second
link. Because the link is unsigned and carries no fingerprint check,
anyone who can alter it in transit can substitute their own
key; the only defence today is sharing the link over a
channel you already trust, or in person. The design below is the
planned replacement.
| 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.
TR-05 (transport-agnostic event format) is stated in §4.
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 | Status |
|---|---|---|---|---|---|
| 1 | WebSocket | iOS, Android, KaiOS, Web | Yes (NOTIFY) | Queue + reconnect | Live |
| 2 | HTTP poll | J2ME, constrained HTTP | No (polling) | Queue + next poll | Deferred (§5.2) |
| 3 | SMS notify | Any phone with SMS | Notification only | N/A | Deferred (§5.4) |
| 4 | BLE courier | Any BLE device | Local only | Courier carries | Pairwise carry live in iOS (§8); multi-hop Deferred |
Today the relay serves only tier 1 plus the sealed and backup HTTP routes; it does not negotiate or adapt per client (§11.1 is deferred). A client picks its own transports.
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. The plan is for each device to advertise what it can do and for the relay to adapt: a smartphone gets real-time WebSocket delivery; a feature phone might rely on SMS pings and HTTP polling. Today only the WebSocket tier and phone-to-phone BLE carrying exist; the rest is roadmap.
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 delegation 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, sizes, blob | Gift Wrap hides content and, inside the envelope, the sender. Sealed Note submission (§6.3) additionally hides the submitting connection; authenticated STORE — still used as a fallback and for some control events — does not, though the database keeps no sender column. Open-room messages are readable to the relay (§7). Blinded mailboxes (§12.2) are planned, not built. |
| Network observer | Sees TLS-encrypted traffic | Standard TLS. Message sizes and timing are not hidden today: payload padding (§12.1) and batched delivery (§12.3) are planned, not built, and notifications are sent immediately. |
| Malicious courier | Holds carried encrypted envelopes | Cannot decrypt or learn the sender. Knows which recipient it carries for (from the delegation, by design); the envelopes it fetched are addressed to its own key. |
| BLE bystander | In BLE range | Courier delivery advertises only a fixed service UUID and authenticates with a per-session blinded tag (§8.3); handshake attempts are rate-limited. The knock/admit grant uses a per-knock service UUID derived from the knocker's ephemeral key ("ppp-ble-svc-v1"), so it is not linkable across knocks. |
| Malicious recipient | Decrypts received events | By design. No forward secrecy in this version. |
| Key-link interceptor | Can alter a shared key link in transit | Not mitigated today. The shipped key link (§10) is unsigned and has no fingerprint check, so the interceptor can substitute their own key. Share the link over a channel you already trust. The signed verification design in §10 is deferred. |
| Discovery spoofer / hash brute-forcer | Registers fake mappings; enumerates phone numbers | Contact discovery is not built (§9, deferred); nothing is exposed until it is. |
room_id, so the relay (or anyone who learns the id) can
read it. Only gated rooms are confidential against the relay.What the relay retains. Client IP addresses exist only
in in-memory rate-limit and admission buckets and are never written to
the relay's logs or database; the reverse proxy's access log is
scrubbed of client IPs and keeps request path, status and latency for
7 days. The relay journal (14-day retention) records command names
with an abbreviated pubkey for authenticated commands — plus an
abbreviated room id for room commands — and never pairs a
sender with a recipient or event id. The database holds envelopes
(recipient, timestamps, ciphertext) until one hour after
ACK or expiry; rooms with their S2 cells, admin key and
membership rows until the room expires; push registrations until 90
days without refresh; backups until 180 days without a store. Exact
bounds are in §5.5 and §C.2. Room discovery queries reveal
the S2 cell asked about.
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
───────────── ──────── ──────────────────────────────
version uint8 Wire format version (1)
routing_tag bytes32 v1: recipient Ed25519 pubkey
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
Seven fields, encoded as a positional array (fixarray 0x97 —
vector-02's first byte). Fixed-field bytes:
1 + 32 + 32 + 24 + 8 + 8 = 105 + ciphertext (+ MessagePack framing)
version leads the array; 1 means
routing_tag is the recipient's plaintext pubkey
(2 is reserved for VOPRF-blinded mailbox tags,
§12.2).
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
content map(1) {"<VariantName>": {…}} (§4.1, §4.3)
created_at uint64 Sender-asserted timestamp
Three fields, positional array. There is no kind byte on the
wire; §4.2's numeric ids are internal/FFI identifiers.
Field Type Description
───────────── ──────── ──────────────────────────────
region_type string "0" = circle (v0.1 baseline) — a MessagePack string
latitude float64 Center latitude (WGS84)
longitude float64 Center longitude (WGS84)
radius_m uint32 Radius in meters (smallest uint encoding)
Positional array of 4. For r = 500:
1 (0x94) + 2 ("0") + 9 + 9 + 3 = 24 bytes (§4.4.1)
For the canonical vector-02 PRESENCE event (short
message, circle region, Text payload):
Rumor: sender + tagged content + created_at = 197 B
Seal: 1 + (2+32) + (2+24) + (2 + rumor+16) + (2+64) = 342 B
GiftWrap: 1 + 1 + (2+32) + (2+32) + (2+24)
+ (3 + seal+16) + 5 + 5 = 467 B
(each bin field carries its 2- or 3-byte bin8/bin16 header;
the timestamps fit uint32, 5 bytes each)
All three sizes are measured by unwrapping
vector-02's pinned GiftWrap, framing included, and
longer messages grow them linearly. Under
500 bytes for typical events: 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 with
GENERATE_VECTORS=1 cargo test -p ppp-relay --test
wire_vectors — scripts/generate-test-vectors.sh
only regenerates vectors 01–04). Covers
every ClientMessage and
ServerMessage variant — a CI gate
(every_wire_variant_is_vectored) fails when a new
variant is added without one, so this list cannot silently fall
behind the wire. The only exclusions are the compile-time
disabled kick/ban commands (§7.7), named explicitly in the
test. Both the all-absent and all-present forms of the
optional-carrying commands are pinned, because that is where a
hand-written codec miscounts its map header.vector-06-derivations.json). Each of these
fails silently when wrong: a mis-derived member token
reads as "not a member", a mis-derived backup key as a corrupt
blob.EventContent variant from fixed synthetic fields
(vector-07-event-content.json) — including
GroupInvite, the shipped group-join path, and both
PresencePayload tags, since the variant names and
the Mood tag are frozen v1 wire constants. An exhaustive-match
gate fails compilation when a variant is added without an
entry.vector-08-crypto-constructions.json): the
88-byte BLE room grant
(fixed nonce; the test decrypts it with the shipping
decryptor), both delegation-certificate families with their
signing materials, and the deterministic threshold-share suite
— three shares, interpolation, and the opened content
key. Ed25519 is RFC 8032 deterministic, so every signature
here is reproducible from the stated seeds.test-vectors/vector-09-receipts-report.json at
the workspace root; §6.8): both receipt signing
materials, signed wire receipts from a stated seed, and a
complete deterministic report bundle that must verify as
verified against the registry embedded in the
vector — with every intermediate value (per-room signing
key, k_msg, signing material) pinned so a
mismatch names the layer that drifted.test-vectors/vector-10-auth-transcript.json at
the workspace root; §5.1.1): the deterministic
CHALLENGE→AUTH byte transcript for both
signature forms — the legacy raw-nonce form in both
frame lineages (bare AUTH and proto: 1) and the
domain-separated form, which is marked normative for new
clients. This was the stated precondition for starting a
second (Kotlin) implementation. Regenerate with
cargo run -p ppp-relay --example
gen_auth_transcript_vectors.test-vectors/vector-11-s2-tokens.json at the
workspace root; §7.1): lat/lon → L13/L16
compact-lowercase-hex tokens, token-parse ancestor relations
(including the vector-05 Berlin pair, re-asserted by the
generator), and informative RegionCoverer coverings. Pins
app-layer behavior, not ppp-core output: the relay
string-matches tokens without computing cells, so a drifting
client S2 port fails silently — this vector
must gate any new client's discovery code. Generator:
tools/gen-s2-token-vectors.py (s2sphere).test-vectors/vector-12-room-message.json at the
workspace root; §7.1, §7.3): the shared
ppp-core room_message module —
v2 signing material, admin-revoke material, pseudonymous
handle derivation (with modulo-boundary coverage), the
open-room key, and the deterministic NHB1 backup-container
framing. The cross-client contract for room chat; the relay
stores room-message ciphertext opaquely so none of it changes
a wire byte. Generator:
cargo run -p ppp-core --example
gen_room_message_vectors.ppp-core/test-vectors/vector-13-mnemonic.json;
§2.1): entropy → 12-word mnemonic → 64-byte BIP-39
seed (empty passphrase) → Ed25519 seed → public key, for
four entropies. The all-zero case is also checked against the
published BIP-39 reference seed, anchoring the chain outside this
codebase. Generator: cargo run -p ppp-core --example
gen_mnemonic_vectors.test-vectors/vector-14-admin-params.json at the
workspace root; §7.8): params, full signing
material and signature for every admin command. Its test also
drives each relay handler with a fresh signed envelope, so the
table is checked against what the relay binds. Regenerate with
GENERATE_VECTORS=1 cargo test -p ppp-relay --test
admin_params_vectors.Not yet vectored (stated so an implementer knows what has no reference bytes rather than discovering it): the BLE courier handshake (§8.3) and the knock grant's BLE service UUID. OOB verification (§10) is not built, so it has nothing to vector yet.
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, 6–8 and 13 run in
ppp-core/tests/test_vectors.rs; vector 12 in
ppp-core/tests/room_message_vectors.rs; vector 10 in
ppp-relay/tests/auth_transcript_vectors.rs; vector 14
in ppp-relay/tests/admin_params_vectors.rs. Vector 11
has no automated runner in this repository yet
— it is generator output only. Vector 5 runs in
ppp-relay/tests/wire_vectors.rs and is also read
directly by the iOS suite, so a regenerated file is verified
against a shipping client decoder rather than only against its
own generator; vector 9 runs in
ppp-relay/tests/receipt_report_vectors.rs, through
the shipping verifier. Generators: vectors 01–04
ppp-core/examples/generate_test_vectors.rs; 06, 07,
08, 12 and 13 ppp-core/examples/gen_*.rs; 09 and 10
ppp-relay/examples/gen_receipt_report_vectors.rs /
gen_auth_transcript_vectors.rs; 05 and 14 in-test
under GENERATE_VECTORS=1; 11
tools/gen-s2-token-vectors.py. Kotlin binding
verification is pending.
Every requirement, with its status on 2026-09-26. Requirement
boxes appear where each topic is specified; IDs marked * are
defined only in docs/specs/protocol/requirements.md
(the full requirements document, which also carries acceptance
criteria), and † only here. The origin column traces each
back to the NowHere app spec it was extracted from.
| ID | Requirement | Status | NowHere origin |
|---|---|---|---|
| PR-01 | End-to-End Encryption | Met | NFR-02 (Privacy & Security) |
| PR-02 | Sender Anonymity from Relay | Partial: sealed Note and room submission hide the submitter; authenticated STORE / STORE_ROOM do not (§13) | New: relay metadata protection |
| PR-03 | Location Confidentiality | Met for Notes; room S2 cells are relay-visible by design (§7) | NFR-02 |
| PR-04 | Metadata Minimization | Partial (§12, §13) | Product principle: privacy by default |
| PR-05 | PII-Free Contact Discovery | Deferred (§9) | FR-07 (Contact Discovery Refresh) |
| PR-06 | Payload Padding | Deferred (§12.1) | New: relay metadata protection |
| PR-07 | Relay Delivery Graph Protection | Partial: level-1 delivery tokens only (§6.6) | New: relay metadata protection |
| PR-08 | Region Radius Bounds | Met, sender side (§4.4) | NFR-02 |
| IR-01 | Keypair-Based Identity | Met | Open decision: migrate from CloudKit |
| IR-02 | Server-Independent Identity | Met | Open decision: migrate from CloudKit |
| IR-03 | Key Rotation | Deferred (§2.3) | — |
| IR-04 | Relay Hint Distribution | Event defined (RELAY_HINT 0x0a, §4.3) | — |
| TR-01 | WebSocket Primary Transport | Met | FR-04, FR-08 |
| TR-02 | HTTP Polling Fallback | Deferred (§5.2) | FR-08 (Offline) |
| TR-03 | SMS Notification Tier | Deferred (§5.4) | FR-08 |
| TR-04 | BLE Local Delivery | Met: pairwise courier delivery in iOS (§8) | New: courier relay concept |
| TR-05 | Transport-Agnostic Event Format | Met | FR-04, FR-08 |
| TR-06 | In-Band Protocol Semantics | Met (§5.1) | KaiOS review |
| CR-01 | Pairwise Carry Delegation | Met; the shipped pairing skips the in-person comparison (§8.1) | New: courier relay concept |
| CR-02* | Courier Fetch via Token Delegation | Met (§8.2) | New: courier relay concept |
| CR-03 | BLE Handshake Privacy | Met (§8.3) | New: courier relay concept |
| CR-04 | Multi-Hop Relay | Deferred (§8.4) | New: courier relay concept |
| CR-05* | Signed Delivery Confirmation | Met: signed EVENT_ACK (§8.3.2) | New: courier relay concept |
| DR-01 | Hash-Based Contact Matching | Deferred (§9) | FR-07 |
| DR-02* | Discovery Opt-In | Deferred (§9) | FR-07 |
| DR-03 | Discovery Rate Limiting | Deferred (§9) | FR-07 |
| SR-01 | Offline-First Operation | Relay side met; client queueing is client behaviour | FR-08 (Offline-First Operation) |
| SR-02 | Eventual Consistency | Relay side met (ACK + cursor, §5.1.2) | FR-08 |
| SR-03 | Event Expiry | Met (§5.5) | FR-09 (Expiration and Cleanup) |
| SE-01 | Replay Protection | Relay dedup while the row exists; lasting protection is client-side (§5.1.2) | NFR-02, security improvements |
| SE-02 | Relay Authentication | Met (§5.1.1) | NFR-02 |
| SE-03 | Enumeration Resistance | Met for rooms and tokens (opaque denials, §6.2, §7.3) | NFR-02 |
| VR-01..VR-02, VR-03*, VR-04..VR-06 | Out-of-band verification | Deferred; an unsigned key link ships instead (§10) | New: OOB verification |
| RM-04† | Group Non-Goals | Relay implements groups; off in the shipped client (§7.6) | Groups v1 |
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, proto?: uint32 |
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 |
Request admission to a room (knock). |
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. |
MINT_BACKUP_TOKEN |
(no fields) | Mint a single-use token for one authenticated backup HTTP operation. |
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_ROOM_TTL_SECS |
30 * 24 * 60 * 60 |
u64 |
Maximum lifetime for newly created ordinary rooms. Existing expiries and persistent groups are retained; cumulative storage budgets apply to both. |
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. |
WS_MAX_MESSAGE_SIZE |
256 * 1024 |
usize |
Per-frame and per-message ceiling for WebSocket traffic, in bytes. Defense-in-depth over the per-handler caps: rejects giant frames before deserialization or handler dispatch. Backups moved to the authenticated HTTP route on 2026-07-31 (audit C2, option 2), so no legitimate WebSocket frame is large any more: the biggest client frame is a STORE at 64 KiB of ciphertext plus a small envelope, and the biggest predictable batch (a 1024-id ACK) is ~36 KiB. |
MAX_BACKUP_SIZE |
4 * 1024 * 1024 - 64 * 1024 |
usize |
Maximum encrypted backup ciphertext accepted by `POST /v1/backup/store`, in bytes. One backup slot per pubkey (upsert), so this also bounds per-user backup storage on the relay. Equal to `BACKUP_HTTP_BODY_LIMIT_BYTES` minus 64 KiB of envelope headroom, so an oversize backup is refused by the handler with a stated reason rather than dying at the HTTP body-limit layer as an opaque 413. |
BACKUP_HTTP_BODY_LIMIT_BYTES |
4 * 1024 * 1024 |
usize |
Request-body ceiling for the authenticated backup HTTP routes, enforced by axum's `DefaultBodyLimit` before the handler runs. Axum's own default is 2 MB — smaller than `MAX_BACKUP_SIZE` — so without this explicit limit every full-size backup would die as an opaque 413 before any code ran. |
BACKUP_TOKEN_BYTES |
32 |
usize |
Backup HTTP auth-token length, in bytes. Random from the OS CSPRNG. |
BACKUP_TOKEN_TTL_SECS |
600 |
u64 |
How long a minted backup token stays redeemable, in seconds. Single-use, and long enough to outlast a full upload — but not a session credential. The token is redeemed *after* axum has buffered the whole request body (the store body can be `MAX_BACKUP_SIZE`), so the TTL must exceed the client's total upload time, not just its round trip. It must stay **above the iOS `backupSession` resource timeout** (300 s, the hard per-request transfer deadline in `PPPRelayClient`): a token that expires mid-upload turns a slow link into a `401` the client can only answer by re-minting and re-uploading the same megabytes, which expire again — unwinnable, and logged misleadingly as an invalid token rather than a slow link. 600 s clears the 300 s ceiling with margin. Update both together. |
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. Provisional relay policy sized to the live platforms, not a protocol constant (spec §5.3): APNs device tokens are 64 hex characters today, FCM registration tokens run ~140-200, and a future Web Push platform — whose "token" is an endpoint URL — is already known to need this loosened. Clients treat tokens as opaque and never pre-validate. |
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. |
BACKUP_TTL_SECONDS |
180 * 24 * 60 * 60 |
u64 |
Backups whose `stored_at` is older than this are purged (180 days). `backups` was the one table with no retention bound at all (metadata audit §7): pubkey + `stored_at` + `version` formed a per-user activity record that outlived every other purge. The TTL is measured from `stored_at`, which the upsert refreshes on every store — so it prices abandonment, not content age. 180 days sits between the two failure modes. Shorter risks deleting the backup of someone whose lost phone takes months to replace, and restore is the one operation that must still work after everything else has already gone wrong. Longer re-creates the indefinite activity record. The client re-uploads an *unchanged* payload once its last success is 30 days old (its content-hash dirty check would otherwise suppress uploads forever for an idle-but-installed device), so every live install refreshes at least 6x inside this bound. |
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. |
WS_MAX_CONNECTIONS |
256 |
usize |
Maximum concurrent WebSockets, including unauthenticated handshakes. |
WS_MAX_CONNECTIONS_PER_IP |
32 |
usize |
Maximum concurrent WebSockets per client IP (shared NATs share this budget). |
WS_UPGRADES_PER_SECOND |
100 |
u64 |
Global upgrade attempts per second, including failed authentication. |
WS_UPGRADES_PER_IP_PER_MINUTE |
60 |
u64 |
Upgrade attempts per client IP per minute. |
WS_MAX_IP_BUCKETS |
4096 |
usize |
Maximum in-memory WebSocket IP buckets; new addresses are refused when full. |
WS_MESSAGES_PER_SECOND |
64 |
u64 |
Received WebSocket messages per connection per second, before decoding. |
WS_BYTES_PER_SECOND |
1024 * 1024 |
u64 |
Received WebSocket bytes per connection per second, before decoding. |
WS_GLOBAL_MESSAGES_PER_SECOND |
1024 |
u64 |
Aggregate received WebSocket messages per second, before decoding. |
WS_GLOBAL_BYTES_PER_SECOND |
16 * 1024 * 1024 |
u64 |
Aggregate received WebSocket bytes per second, before decoding. |
WS_WRITE_TIMEOUT_SECS |
5 |
u64 |
Deadline for each WebSocket write, including challenge, pong and close. |
WebSocket upgrades have global and per-client-IP concurrent limits and attempt budgets, including failed authentication. Forwarded IPs use the same trusted-proxy rules as sealed HTTP. Buckets are bounded and kept only in memory. Admission failures return HTTP 429 or 503 with Retry-After; clients should reconnect with backoff.
After authentication, every received message (including malformed binary, text, ping and pong) consumes connection and aggregate message/byte budgets before command decoding. Excess traffic closes the connection. These fixed-window budgets supplement command-specific limits. All application WebSocket writes have a five-second deadline; timed-out sessions release their admission slots.
Authenticated WebSocket commands are rate-limited per pubkey using a fixed window (it starts with a pubkey's first command of that kind and resets when it elapses). 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) | |
MINT_BACKUP_TOKEN |
30 | 3600s (1h) | One token per backup HTTP operation. A token has no body, so this only bounds token churn — the storage cost is bounded by the per-op budgets after redemption. 30/h leaves headroom above the summed store+fetch+delete budgets (15/h) so a burned mint on a failed op does not lock a user out of retrying. |
STORE_BACKUP |
5 | 3600s (1h) | Enforced by POST /v1/backup/store after token redemption; backups are infrequent on legitimate clients. |
FETCH_BACKUP |
5 | 3600s (1h) | Enforced by POST /v1/backup/fetch after token redemption; returns up to MAX_BACKUP_SIZE bytes, tight budget prevents egress amplification. |
DELETE_BACKUP |
5 | 3600s (1h) | Enforced by POST /v1/backup/delete after token redemption. Explicit rather than defaulted so the MINT budget note's arithmetic (store+fetch+delete = 15/h) is true. |
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 signs the domain-separated AUTH material over the nonce (legacy: the raw 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_TOKEN |
token: bytes, expires_at: uint64 |
Single-use backup HTTP token, in response to MINT_BACKUP_TOKEN. |
BACKUP_FAIL |
reason: string |
Backup token minting failed (rate limit, storage error). |
GROUP_CREATED |
room_id: bytes |
Group created successfully. |
GROUP_DELETED |
room_id: bytes |
Group deleted successfully. |
A Rumor has no kind field on the wire: its content is
tagged by variant-name string (§4.1). These uint8
discriminants are the internal/FFI identifiers used by ppp-core and
this document. New kinds are appended; deprecated ones stay
reserved. 0x03 and 0x05 have no content variant and cannot be sent
as events.
| 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. |
Authenticated (token-redeeming) HTTP endpoints — not
sealed-sender: a backup is per-identity state, so the relay learning
which pubkey acted is inherent. Auth is a single-use token minted
over the authenticated WebSocket (MINT_BACKUP_TOKEN,
32 random bytes, BACKUP_TOKEN_TTL_SECS = 600 s,
redeemed-once atomically), carried
in the request body because the reverse proxy logs the URI
and header map verbatim. Request bodies are capped at
BACKUP_HTTP_BODY_LIMIT_BYTES; the per-operation budgets
in §C.3 are enforced after redemption, keyed on the redeeming
pubkey. Backups moved here from the WebSocket on 2026-07-31 — the
socket now carries no large frames, which is what allowed
WS_MAX_MESSAGE_SIZE to drop to 256 KiB.
| Method | Path | Body | Description |
|---|---|---|---|
POST |
/v1/backup/store |
BackupStoreRequest |
Store the caller's encrypted backup (upsert, one slot per pubkey). Body: token + ciphertext, msgpack. |
POST |
/v1/backup/fetch |
BackupFetchRequest |
Fetch the caller's latest backup. POST, not GET: the auth token must travel in the body, never the URI. 404 when none exists. |
POST |
/v1/backup/delete |
BackupFetchRequest |
Delete the caller's backup. Idempotent; 204 either way. |
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). |
One further HTTP route shares the relay host but is not
served by the relay binary:
POST /v1/report/submit, the anonymous abuse-report
intake (JSON body; the reverse proxy routes it to a separate
intake service). The schema-2 report bundle it accepts is
specified in §6.8; the full endpoint contract —
request shapes per surface, status codes and their retry
semantics, and the global intake limits — is
docs/specs/protocol/report-intake-http.md.