A protocol for place- and time-gated social event delivery with strong privacy guarantees. PPP enables encrypted, location-aware messaging without exposing message content, sender identity, or location data to relay servers or network observers. The current production stack is iOS plus a Rust relay; the protocol is designed for portability to Android, KaiOS, and J2ME devices, but those clients are not yet implemented — references to them throughout this document describe future targets, not shipped code.
| Term | Definition |
|---|---|
| Event | A place- and time-gated message from a sender to one or more recipients. |
| Presence Event | An event that recipients discover when physically near the specified location within the specified time window. |
| Relay | A server that stores and forwards encrypted events between clients via WebSocket or HTTP. |
| Courier | A client that fetches encrypted events on behalf of others and delivers them locally via BLE when internet is unavailable. |
| Carry Group | A set of users who opt in to having a designated courier fetch and deliver events for them. |
| Gift Wrap | A three-layer encryption pattern that hides both content and sender identity from intermediaries. |
| Envelope | The outer, relay-visible structure of an event (recipient pubkey, ephemeral key, encrypted blob, timestamps). |
| Seal | The middle encryption layer binding sender identity to encrypted content. |
| Rumor | The inner, unencrypted event content (only visible after full decryption by the intended recipient). |
| Delivery Token | A 12-byte token derived from a pairwise X25519 shared secret between two contacts, used to route anonymous Note submissions without revealing sender identity to the relay. |
| Sealed Sender | A submission mode where the sender POSTs an encrypted event via anonymous HTTP, identified only by a delivery token, so the relay cannot determine which pubkey submitted it. |
Think of PPP like sending sealed letters through a post office. The Relay is the post office — it knows where to deliver the letter but can never open it. A Courier is like a friend who picks up your mail and hand-delivers it when the post office can't reach you.
The three encryption layers (Rumor, Seal, Gift Wrap) are like putting a letter in an envelope, sealing it in a second envelope with your signature, then placing that inside a third, anonymous mailer. Even the post office doesn't know who sent it.
User identity MUST be based on Ed25519 public/private key pairs. The public key serves as the user's protocol-level identifier.
User identity MUST NOT be bound to any specific relay or server. A user MUST be able to switch relays without changing identity.
The protocol SHOULD support key rotation, allowing a user to migrate to a new key pair while maintaining continuity with existing contacts.
Each user generates an Ed25519 key pair on first launch:
seed = randombytes(32)
(pk, sk) = crypto_sign_ed25519_seed_keypair(seed)
pk (32 bytes): the user's public identity, used as their
protocol address.sk (64 bytes): stored in platform-secure storage (Keychain
on iOS, Keystore on Android).seed (32 bytes): the backup/recovery secret. Users
SHOULD be prompted to back up their seed
phrase (BIP-39 mnemonic or raw hex).Ed25519 keys are converted to X25519 for Diffie-Hellman key exchange:
x25519_pk = crypto_sign_ed25519_pk_to_curve25519(ed25519_pk)
x25519_sk = crypto_sign_ed25519_sk_to_curve25519(ed25519_sk)
This allows a single key pair to serve both signing (Ed25519) and encryption (X25519) purposes.
A key rotation event is signed by the old key and contains the new public key:
KeyRotation {
old_pubkey: bytes32 // signing key (== author)
new_pubkey: bytes32 // replacement key
timestamp: uint64
signature: bytes64 // sign(new_pubkey || timestamp, old_sk)
}
Recipients who receive and verify this event update their local contact mapping. The relay MAY store key rotation events for a configurable retention period to allow offline recipients to catch up.
Ed25519 was chosen for three reasons:
Your identity in PPP is a cryptographic key pair generated on your device — not a username, phone number, or account on a server. This means no one can impersonate you without access to your device, and you can switch between different relay servers without losing your identity.
The seed is like a master password that can regenerate your keys. If you lose your device, the seed is the only way to recover your identity.
All event content MUST be end-to-end encrypted between sender and recipient(s). No intermediary (relay, courier, network observer) SHALL be able to read event payloads.
All cryptographic operations use libsodium primitives. Implementations on platforms without libsodium MUST use compatible algorithms (X25519, XChaCha20-Poly1305, Ed25519) from equivalent libraries (e.g., TweetNaCl).
Algorithm: XChaCha20-Poly1305 (AEAD)
Nonce: 24 bytes, randomly generated per encryption
Key: 32 bytes
Auth tag: 16 bytes (appended to ciphertext by libsodium)
encrypt(plaintext, nonce, key) -> ciphertext || tag
decrypt(ciphertext || tag, nonce, key) -> plaintext | error
libsodium function: crypto_aead_xchacha20poly1305_ietf_encrypt/decrypt
Algorithm: X25519 Diffie-Hellman
Output: 32-byte shared secret
shared_secret = crypto_scalarmult(my_x25519_sk, their_x25519_pk)
The shared secret is passed through a KDF before use as an encryption key:
encryption_key = crypto_generichash(
32, // output length
shared_secret, // input
"ppp-event-key-v1" // context/salt
)
The Gift Wrap pattern uses three layers to hide both content and sender identity from the relay. This is the REQUIRED encryption method for all events.
Layer 1 — Rumor (cleartext content):
The rumor is the actual event payload. It is NOT signed, providing sender deniability at the content layer.
Rumor {
sender: bytes32 // author's real Ed25519 pubkey
kind: uint8 // event type (see S4)
content: EventContent // type-specific payload
created_at: uint64 // unix timestamp
}
Layer 2 — Seal (encrypted + signed by sender):
The seal encrypts the rumor with a shared secret derived from an ephemeral key and the recipient's key. It proves authorship to the recipient.
ephemeral_seal = crypto_sign_ed25519_seed_keypair(randombytes(32))
seal_shared = crypto_scalarmult(
ephemeral_seal.x25519_sk,
recipient.x25519_pk)
seal_key = kdf(seal_shared, "ppp-seal-v1")
seal_nonce = randombytes(24)
sealed_rumor = encrypt(serialize(rumor), seal_nonce, seal_key)
Seal {
ephemeral_pk: bytes32 // ephemeral_seal.ed25519_pk
nonce: bytes24
ciphertext: bytes // sealed_rumor
sender_sig: bytes64 // sign(sealed_rumor, sender.ed25519_sk)
}
The signature binds the sender's identity to the sealed content. The
recipient verifies sender_sig using rumor.sender
(available after decrypting the seal).
Layer 3 — Gift Wrap (encrypted with ephemeral key):
The wrap encrypts the seal with a SECOND ephemeral key pair. The relay sees only this outer layer.
ephemeral_wrap = crypto_sign_ed25519_seed_keypair(randombytes(32))
wrap_shared = crypto_scalarmult(
ephemeral_wrap.x25519_sk,
recipient.x25519_pk)
wrap_key = kdf(wrap_shared, "ppp-wrap-v1")
wrap_nonce = randombytes(24)
wrapped_seal = encrypt(serialize(seal), wrap_nonce, wrap_key)
GiftWrap {
recipient: bytes32 // recipient's Ed25519 pubkey
ephemeral_pk: bytes32 // ephemeral_wrap.ed25519_pk
nonce: bytes24
ciphertext: bytes // wrapped_seal
created_at: uint64 // relay-facing timestamp
expires_at: uint64 // event TTL
}
Decryption (recipient side):
1. wrap_shared = crypto_scalarmult(my_x25519_sk, giftwrap.ephemeral_pk)
2. wrap_key = kdf(wrap_shared, "ppp-wrap-v1")
3. seal = decrypt(giftwrap.ciphertext, giftwrap.nonce, wrap_key)
4. seal_shared = crypto_scalarmult(my_x25519_sk, seal.ephemeral_pk)
5. seal_key = kdf(seal_shared, "ppp-seal-v1")
6. rumor = decrypt(seal.ciphertext, seal.nonce, seal_key)
7. verify = crypto_sign_verify(seal.sender_sig,
seal.ciphertext, rumor.sender)
What each party sees:
| Party | Visible Data |
|---|---|
| Relay (auth'd STORE) | Sender pubkey (from auth session), recipient pubkey, ephemeral wrap key, opaque blob, timestamps |
| Relay (sealed sender) | Delivery token → recipient pubkey, ephemeral wrap key, opaque blob, timestamps. Sender pubkey NOT visible. |
| Courier | Recipient pubkey, ephemeral wrap key, opaque blob, timestamps |
| Network observer | Encrypted WebSocket/TLS traffic |
| Recipient | Full rumor: sender, content, location, time |
Two layers would suffice for confidentiality, but the third (Gift Wrap) layer provides sender anonymity. The Seal proves authorship to the recipient, while the GiftWrap hides the Seal's contents — including the sender signature — from the relay.
This pattern is adapted from NIP-59 (Nostr's Gift Wrap), extended with per-event ephemeral keys at both layers.
XChaCha20-Poly1305 was chosen over AES-GCM because:
The event format MUST be independent of transport mechanism. The same serialized event MUST be valid whether delivered via WebSocket, HTTP, BLE, or courier relay.
All protocol messages are serialized using MessagePack. Implementations for constrained devices MAY use CBOR as an alternative, provided field ordering and type mapping are preserved.
Field names are serialized as short integer keys to minimize payload size:
| Key | Field |
|---|---|
| 1 | recipient |
| 2 | ephemeral_pk |
| 3 | nonce |
| 4 | ciphertext |
| 5 | created_at |
| 6 | expires_at |
| 7 | sender |
| 8 | kind |
| 9 | content |
| 10 | sender_sig |
| 11 | group_id |
| 12 | hop_count |
| 13 | max_hops |
| 14 | region_type |
This summary lives inline for narrative flow; the canonical
enumeration is autogenerated in §C.5 from the
EventKind enum and is what you should treat as
authoritative when implementing.
| Kind | Value | Description |
|---|---|---|
| PRESENCE | 0x01 | Place- and time-gated event (primary use case) |
| REVOKE | 0x02 | Sender revokes a previously sent event |
| ACK | 0x03 | Recipient acknowledges receipt |
| KEY_ROTATION | 0x04 | Sender announces a new public key |
| CONTACT_DISCOVERY | 0x05 | Contact hash publication for discovery |
| CARRY_GROUP_INVITE | 0x06 | Invitation to join a carry group |
| CARRY_GROUP_ACCEPT | 0x07 | Acceptance of carry group invitation |
| KEY_EXCHANGE_RETURN | 0x08 | Responder returns their pubkey after a deep-link key exchange (TOFU completion) |
| GROUP_INVITE | 0x09 | Invitation to join a persistent encrypted group (carries room_id and room_key) |
PRESENCE (0x01):
PresenceContent {
event_id: bytes16 // unique event identifier (UUID)
message: string // user-visible message text
region: Region // geographic area (see S4.4)
time_window: {
starts_at: uint64 // unix timestamp
ends_at: uint64 // unix timestamp
timezone: string // IANA timezone identifier
}
recipients: [bytes32] // list of recipient pubkeys
}
REVOKE (0x02):
RevokeContent {
event_id: bytes16 // ID of the event to revoke
}
KEY_ROTATION (0x04):
KeyRotationContent {
new_pubkey: bytes32 // the replacement public key
migration_sig: bytes64 // sign(old_pk || new_pk, old_sk)
}
CARRY_GROUP_INVITE (0x06):
CarryGroupInviteContent {
group_id: bytes16 // carry group identifier
group_name: string // human-readable name
members: [bytes32] // current member pubkeys
couriers: [bytes32] // designated courier pubkeys
}
KEY_EXCHANGE_RETURN (0x08): Sent by the
responder back to the initiator after the responder imports the
initiator's public key from a deep link. Carries the responder's
public key so the initiator can auto-import it, completing the
bidirectional handshake without a second out-of-band exchange.
The initiator_pubkey field binds the response to a
specific exchange — an attacker who replays a captured
KEY_EXCHANGE_RETURN at a different initiator cannot make the
import succeed.
KeyExchangeReturnContent {
initiator_pubkey: bytes32 // initiator's Ed25519 pubkey (binds the response)
responder_pubkey: bytes32 // responder's Ed25519 pubkey (the key being returned)
}
GROUP_INVITE (0x09): Invitation to join a
persistent encrypted group (§7.6). Delivered via Gift Wrap
to an existing contact; carries the symmetric
room_key the recipient needs to decrypt subsequent
group messages. Any contact who can be reached via Gift Wrap can
be invited.
GroupInviteContent {
room_id: bytes16 // group room identifier
room_key: bytes32 // symmetric XChaCha20-Poly1305 key
group_name: string // human-readable name
}
Geographic coordinates and region data MUST NOT appear in plaintext in any protocol message visible to relays, couriers, or network observers.
The protocol MUST enforce minimum and maximum radius bounds on circle regions to prevent location deanonymisation and keep relay scope bounded.
The region defines the geographic area where an event is discoverable. The
protocol uses a circle as the baseline representation, with a
version-extensible region_type field for future area representations.
Region {
region_type: uint8 = 0 // 0 = circle
latitude: float64 // center latitude (WGS84)
longitude: float64 // center longitude (WGS84)
radius_m: uint32 // radius in meters [10 .. 50,000]
}
Wire overhead: 1 + 8 + 8 + 4 = 21 bytes.
| Bound | Value | Rationale |
|---|---|---|
| Minimum | 10 m | Prevents point-location deanonymisation. Sub-10 m radii effectively reveal exact GPS coordinates. |
| Maximum | 50 km | Keeps relay scope bounded. Broader coverage should use broadcast channels. |
Latitude MUST be in [-90, 90] and longitude MUST be in [-180, 180] (WGS84 decimal degrees).
Receiver-side presence check (haversine):
d = haversine(my_lat, my_lon, region.latitude, region.longitude)
is_inside = (d <= region.radius_m)
The haversine formula requires only basic trigonometry and is implementable in ~15 lines in any language, including J2ME CLDC 1.1.
| region_type | Representation | Use Case |
|---|---|---|
| 0 | Circle (lat/lon/radius) | v0.1 baseline |
| 1 | Geohash cell set | Arbitrary drawn areas |
| 2-127 | Reserved | Future protocol versions |
Compatibility rule: Clients that encounter an unknown
region_type MUST fall back to the
bounding circle if present, or discard the event if no bounding circle is
provided.
Event IDs are 32-byte BLAKE2b hashes of the GiftWrap envelope.
Two derivation forms exist, depending on whether the envelope arrives
via the authenticated WebSocket (STORE) or the
unauthenticated sealed-Note HTTP endpoint
(POST /v1/note/submit). Both are deterministic from
relay-visible fields, so the relay can deduplicate without
decrypting anything.
Authenticated path (STORE / FETCH / room messages):
event_id = BLAKE2b(32,
giftwrap.ephemeral_pk || giftwrap.nonce || giftwrap.ciphertext
)
Sealed-Note path (POST /v1/note/submit):
event_id = BLAKE2b(32,
"ppp-note-event-id-v1"
|| ephemeral_pk || nonce || ciphertext
|| delivery_token || created_at_be64
)
Both forms MUST produce a 32-byte
output. The relay MUST reject a
STORE for an event_id already present in storage.
Events are the core unit of the protocol — a message tied to a place and time. When you drop a note on the map, that's a PRESENCE event. The location data is always inside the encrypted layers, so the relay never knows where the note is placed.
Using integers (1, 2, 3...) instead of string field names ("recipient", "ephemeral_pk"...) saves 5-15 bytes per field in MessagePack encoding. For a ~500 byte event, this is a meaningful reduction — especially over BLE where every byte counts.
The authenticated path identifies the recipient by the
recipient field of the GiftWrap, so the
ephemeral_pk || nonce || ciphertext triple is already
unique within that recipient's mailbox. The sealed-Note path
never sees a recipient pubkey on the wire (that's the whole point
of sealed sender), so the same triple could collide if the same
ciphertext is replayed against two different delivery tokens.
Adding the delivery_token binds the ID to the
(sender, recipient) pair, and created_at_be64
disambiguates legitimate re-sends. The
"ppp-note-event-id-v1" domain tag prevents an
attacker from pre-computing IDs that would collide with the
authenticated path.
The relay stores only encrypted blobs. It cannot filter events by location or draw a map of where events are placed. All proximity checks happen on the recipient's device after decryption — the client computes whether it is inside the circle.
The 10m minimum radius prevents pinpointing someone's exact location. A 10m circle could be anywhere within a building; a 1m circle is a specific desk.
The protocol MUST define a WebSocket-based transport as the primary real-time channel between clients and relays.
Clients MUST authenticate to the relay to prevent unauthorized access to stored events. Authentication uses Ed25519 challenge-response.
Connection endpoint: wss://<relay-host>/v1/ws
All WebSocket frames are MessagePack-encoded objects with a cmd field.
On connection, the relay issues a challenge:
Relay -> Client:
{ cmd: "CHALLENGE", nonce: bytes32 }
Client -> Relay:
{ cmd: "AUTH", pubkey: bytes32, sig: bytes64 }
where sig = crypto_sign_detached(nonce, client_ed25519_sk)
Relay -> Client:
{ cmd: "AUTH_OK" }
or
{ cmd: "AUTH_FAIL", reason: string }
After AUTH_OK, all subsequent commands are scoped to the authenticated pubkey.
STORE — submit an event for delivery:
Client -> Relay:
{ cmd: "STORE", event: GiftWrap }
Relay -> Client:
{ cmd: "STORED", event_id: bytes32 }
or
{ cmd: "STORE_FAIL", reason: string }
The relay validates: recipient pubkey is well-formed, event is not expired, event_id is not already stored. The relay does NOT need to decrypt the event.
FETCH — retrieve events for the authenticated pubkey:
Client -> Relay:
{ cmd: "FETCH", since: uint64 }
Relay -> Client:
{ cmd: "EVENTS", events: [GiftWrap] }
since is a unix timestamp; the relay returns events with
created_at > since. The relay MAY
paginate using a cursor field.
FETCH_CARRY Deferred — courier fetches for a carry group:
Client -> Relay:
{ cmd: "FETCH_CARRY", group_id: bytes16,
since: uint64, sig: bytes64 }
Relay -> Client:
{ cmd: "CARRY_EVENTS", events: [GiftWrap] }
The carry-group dispatch is part of the courier roadmap (see §8) and is not implemented in the current relay; FETCH_CARRY is unrouted today. Clients MUST NOT assume it works against a production relay.
ACK — confirm receipt of events:
Client -> Relay:
{ cmd: "ACK", event_ids: [bytes32] }
Relay -> Client:
{ cmd: "ACKED", count: uint }
NOTIFY — relay pushes a notification:
Relay -> Client:
{ cmd: "NOTIFY", count: uint }
Sent when new events arrive for the authenticated pubkey. The client then issues a FETCH.
The HTTP polling transport is specified here so that future feature-phone and constrained-network clients have a target to implement against, but the current relay does not route the endpoints below. Today only the WebSocket transport (§5.1) and the two sealed-sender HTTP endpoints (§6.3, §7) are live. Adding HTTP-polling parity is tracked as part of Phase 4.
The protocol MUST define an HTTP polling API that provides equivalent functionality for devices without persistent connections.
Authentication via Authorization header:
Authorization: PPP-Ed25519 <pubkey_hex>:<sig_hex>
where sig = crypto_sign_detached(
request_body || timestamp, sk)
| Endpoint | Method | WS Equivalent |
|---|---|---|
/v1/events | POST | STORE |
/v1/events?since={ts} | GET | FETCH |
/v1/events/carry?group={id}&since={ts} | GET | FETCH_CARRY |
/v1/events/ack | POST | ACK |
Clients SHOULD poll at a default interval of
30 seconds. The relay MAY include a
Retry-After header.
Client -> Relay:
{ cmd: "REGISTER_PUSH",
platform: "apns" | "fcm",
token: string }
Push payloads MUST NOT contain event content — they serve only as wake signals for the client to FETCH.
The relay MUST support
multiple registered tokens per pubkey: the
push_registrations table is keyed on
(pubkey, token), not on pubkey alone.
One identity can be active on a phone, a tablet, and a watch
simultaneously, and a wake signal fans out to every registered
token. When the push provider responds with a permanent failure
(APNs 410 Unregistered; FCM equivalent), the relay
MUST delete the affected
(pubkey, token) row so dead devices stop accruing
send attempts.
Pushes fire on the following triggers, and only when the target recipient or admin has no active WebSocket attached to the relay:
SMS bridging is in the design but is not implemented:
REGISTER_SMS is not in the wire surface (§C.1)
and the relay has no SMS provider integration. Specified here so
that a future feature-phone client has a target.
Client -> Relay:
{ cmd: "REGISTER_SMS", phone_hash: bytes32 }
SMS content is limited to a generic notification and MUST NOT contain event content, sender identity, or location data.
A single-instance relay uses SQLite (WAL mode) for event persistence. The relay stores only opaque GiftWrap envelopes — it cannot decrypt content, identify senders, or filter by location.
event_id primary key.(recipient, created_at)
with partial index excluding acknowledged events.acked_at timestamp; purge after
grace period.expires_at.The two unauthenticated sealed-sender HTTP endpoints
(POST /v1/room/submit and
POST /v1/note/submit) accept requests without an AUTH
handshake, so per-pubkey rate limits do not apply. The relay
MUST impose a sliding per-IP budget
on these endpoints (SEALED_IP_RATE_LIMIT in
§C.2). The per-token limit
(SEALED_TOKEN_RATE_LIMIT) remains the second line of
defense for callers behind shared NAT or proxies.
Because the relay is intended to sit behind a TLS-terminating
reverse proxy (Caddy in the reference deployment), the IP
attributed to a request is derived from the Forwarded
/ X-Forwarded-For chain rather than the connection
peer. The rules:
PPP_TRUSTED_PROXIES; defaults to loopback), the
peer's address is the client IP and any forwarding header is
ignored.X-Forwarded-For entries from rightmost to leftmost
and skip while the entry is itself in the trust set. The first
non-trusted address encountered is the client IP.Operators putting the relay behind a non-loopback proxy
MUST configure
PPP_TRUSTED_PROXIES — otherwise every sealed
request is attributed to the proxy and the IP budget is shared
across all clients.
For every authenticated WebSocket command the relay generates a
random 6-byte (12-hex-character) request_id and
includes it in structured server-side logs.
STORE_FAIL responses surface the request ID in the
reason string so that an operator presented with a
client-side error can grep server logs without exposing user
identifiers. 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" }
POST /v1/note/submit
Content-Type: application/msgpack
{
delivery_token: bytes12,
ephemeral_pk: bytes32,
nonce: bytes24,
ciphertext: bytes,
created_at: uint64,
expires_at: uint64
}
Processing flow:
delivery_token → recipient_pk.
Unknown token → 401.SEALED_TOKEN_RATE_LIMIT
in §C.2). Exceeded → 429.expires_at > now; clamp
expires_at to at most
now + MAX_EVENT_TTL_SECS (§C.2).event_id per §4.5
— this includes the delivery_token and
created_at, and is not the same formula as
the authenticated path.Sealed sender is an optimization. Clients MUST support fallback to authenticated STORE when:
Fallback degrades privacy but never blocks delivery. Clients SHOULD retry sealed submission before falling back.
The anonymity set is bounded by the number of contacts with registered tokens for the recipient. If Bob has only 3 contacts, the relay knows the sender is one of 3 people.
| Level | Hides | Mechanism | Status |
|---|---|---|---|
| 1 | Sender identity | Delivery tokens + anonymous HTTP POST | Implemented |
| 2 | Recipient identity | Blinded mailbox IDs + epoch rotation | Deferred |
| 3 | Both | Level 1 + Level 2 combined | Deferred |
The relay also carries one additional class of opaque encrypted
payload on behalf of the user: a single per-pubkey
backup blob, used so that a client can restore
its event history on a new device without re-fetching every
recipient mailbox. The relay sees only ciphertext, a version
counter, and a server-stamped stored_at; everything
else — the structure of the backed-up data, the symmetric
key, the device-side recovery flow — is client-side and
opaque to the relay.
Three commands form the surface (autogen'd into §C.1):
STORE_BACKUP { ciphertext } — upserts the
single per-pubkey row, increments the version counter. The
ciphertext is bounded by the larger backup-only body limit
(5 MiB) since a backup is a one-shot upload, not a fan-out
event stream.FETCH_BACKUP — returns the latest
{ ciphertext, version, stored_at } for the
authenticated pubkey, or BACKUP_EMPTY if none.DELETE_BACKUP — removes the row.The backup key itself is derived client-side from the user's
BIP-39 mnemonic via BLAKE2b with the
"ppp-backup-v1" domain tag, so a clean device with
only the recovery phrase can decrypt the blob without ever
presenting the long-term identity key to the relay. The relay
rate-limits both endpoints tightly
(STORE_BACKUP and FETCH_BACKUP at
5/hour per pubkey, see §C.3), since legitimate clients
back up infrequently and the larger payload size makes
amplification more attractive than for normal events.
A client can keep its own backups locally or in any cloud storage of its choosing — the relay is not a privileged backup destination. We host the blob path because the relay is already the user's trust-minimized integration point: the same pubkey that authenticates STORE / FETCH authenticates backup, so onboarding a new device after losing the old one does not require provisioning a separate backup credential. The relay learns nothing it didn't already know from the user's session presence.
Normally when you send a message, you first prove your identity to the relay (like showing your ID at the post office). The relay then knows both who sent it and who receives it.
Sealed sender is like using an anonymous drop box — you place the envelope in a slot labeled with a code that only you and the recipient know. The post office can deliver it but has no idea who dropped it off.
96 bits gives negligible collision probability for the expected scale (~100 tokens per user). It matches room member token size for consistency, and keeps the HTTP POST payload compact for bandwidth-constrained clients.
Rooms are location-anchored group messaging spaces managed by an admin.
Unlike point-to-point events (which use Gift Wrap encryption addressed to a
single recipient), room messages are encrypted with a symmetric room key
shared among members. The relay stores opaque ciphertext and routes messages
by room_id — it cannot read content or identify senders.
Location representation — circles vs S2 cells: Notes and rooms use different spatial representations because they serve different purposes with different privacy properties:
This means DISCOVER_ROOMS queries reveal to the relay which S2 cell the client is interested in (approximate location), while note regions remain fully confidential. This asymmetry is inherent: rooms trade some location privacy for discoverability by nearby strangers, while notes are exchanged only between known contacts.
Creation:
Client -> Relay:
{ cmd: "CREATE_ROOM",
room_id: bytes16,
s2_cell_id: string, // S2 fine cell for discovery
s2_coarse_id: string, // S2 coarse parent cell
gated: bool, // require membership token
proximity_required: bool, // reserved for future use
ble_enabled: bool, // BLE knock/admit support
max_members: uint32, // 1-100
expires_at: uint64 } // must be > now
Relay -> Client:
{ cmd: "ROOM_CREATED", room_id: bytes16 }
The authenticated caller becomes the room admin. The relay indexes the room by S2 cell for geospatial discovery.
Discovery:
Client -> Relay:
{ cmd: "DISCOVER_ROOMS", s2_cell_id: string }
Relay -> Client:
{ cmd: "ROOMS", rooms: [RoomInfo] }
RoomInfo contains: room_id, s2_cell_id,
gated, proximity_required, ble_enabled,
member_count, created_at, expires_at.
Only non-expired rooms matching the queried S2 cell are returned.
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, and knocks.
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 }
Relay -> Client:
{ cmd: "TOKEN_REGISTERED" }
The relay verifies: (1) caller is admin, (2) signature is valid for
member_token || room_id under admin pubkey, (3) token count
< max_members. Registration is idempotent.
Revocation (admin only):
Client -> Relay:
{ cmd: "REVOKE_TOKENS", room_id: bytes16 }
Relay -> Client:
{ cmd: "TOKENS_REVOKED", count: uint32 }
Key Rotation with Grace Period (admin only):
Client -> Relay:
{ cmd: "ROTATE_ROOM_KEY",
room_id: bytes16,
grace_seconds: uint32 } // clamped to [0, 600]
Relay -> Client:
{ cmd: "KEY_ROTATED",
room_id: bytes16,
revoked_count: uint32,
grace_until: uint64 }
All existing tokens are marked with grace_until. During the
grace period tokens remain valid; after, they are rejected. The relay
broadcasts a KEY_ROTATION notification to all room subscribers.
Authenticated submission (WebSocket):
Client -> Relay:
{ cmd: "STORE_ROOM",
room_id: bytes16,
nonce: bytes24,
ciphertext: bytes }
Relay -> Client:
{ cmd: "ROOM_STORED",
room_id: bytes16,
sequence_number: int64 }
Sealed submission (anonymous HTTP POST):
POST /v1/room/submit
Content-Type: application/msgpack
{
room_id: bytes16,
member_token: bytes12,
nonce: bytes24,
ciphertext: bytes
}
Processing: validate lengths, verify token for room_id (respecting grace period), per-token rate limit (2/sec), verify room not expired, store with auto-increment sequence number, notify subscribers.
Fetch:
Client -> Relay:
{ cmd: "FETCH_ROOM",
room_id: bytes16,
since_id: int64 }
Relay -> Client:
{ cmd: "ROOM_MESSAGES",
room_id: bytes16,
messages: [RoomMessage] }
RoomMessage: id (sequence number), nonce,
ciphertext, stored_at. Returns up to 100 messages
with id > since_id.
For BLE-enabled rooms, prospective members request admission without prior knowledge of the room key:
Knock:
Client -> Relay:
{ cmd: "KNOCK",
room_id: bytes16,
knocker_eph_x25519_pk: bytes32 }
Relay -> Client:
{ cmd: "KNOCK_OK", request_id: bytes16 }
The knock has a 120-second TTL. The admin is notified via WebSocket or push fallback.
Admit (admin only):
Client -> Relay:
{ cmd: "ADMIT", request_id: bytes16 }
Relay -> Client:
{ cmd: "ADMIT_OK" }
After admission, 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 knocker decrypts to obtain room_id and room_key,
then derives their member token.
| Notification | Recipients | Trigger |
|---|---|---|
| NEW_ROOM_MESSAGES | All room subscribers | STORE_ROOM or sealed submit |
| KNOCK_RECEIVED | Room admin | KNOCK command |
| ADMITTED | Knocker | ADMIT command |
| KEY_ROTATION | All room subscribers | ROTATE_ROOM_KEY |
A group is a persistent variant of a room. The wire
surface and storage layout are shared with rooms — the same
rooms table holds both, distinguished by a
room_type column — but groups differ on three
axes:
expires_at in the year 9999 and are excluded from
the room expiry purge job. They live until explicitly deleted.s2_cell_id and MUST NOT
be returned by DISCOVER_ROOMS. Joining requires an
explicit invite (or a knock against a known group ID), not a
map lookup.DELETE_GROUP is admin-only and cascade-deletes
messages, members, and tokens in one transaction. There is no
equivalent for time-bounded rooms; rooms expire on their own.Group invites are delivered out-of-band via Gift Wrap (event kind
0x09 GroupInvite, see §C.5) carrying the
room_id and the symmetric room_key. The
recipient stores the invite locally and can then participate in
the group via the same room-message commands as a discovered
room. Two join paths exist in v1: a Gift-Wrap invite to an
existing contact, or the BLE knock/admit handshake (§7.4)
against a group ID shared by another channel.
Implementations MUST NOT assume
forward secrecy: room_key is a long-lived symmetric
key with no automatic ratchet, and a leaked key compromises all
past and future group messages until the admin deletes and
recreates the group. Invite links (a recipient-bound token a
sender can share without prior contact) are
MAY in v1 but are
not defined here; the v1 surface only supports
(a) Gift-Wrap invite to a known contact and (b) knock/admit
against a known group ID. Multi-admin operation is also out of
scope; v1 has a single admin per group.
The wire commands KICK_MEMBER and
BAN_MEMBER exist in the protocol surface (§C.1)
and the relay implements both, but the feature is gated behind a
compile-time flag that is off by default in
shipped builds. The current product position is that rooms are
short-lived enough that mid-session moderation is rarely needed,
and the privacy cost of letting an admin link a member's pubkey
to in-room behavior is not worth the convenience for v1.
Specified here because the wire surface is publicly observable
and because we want clients to be able to introspect whether the
relay they are talking to has the feature enabled. A relay that
ships with the feature on MUST
accept KICK_MEMBER / BAN_MEMBER from
the room admin's authenticated WebSocket and
MUST reject them from anyone else.
A relay that ships with the feature off
MUST respond
STORE_FAIL with a stable
"feature disabled" reason so clients can detect
unavailability without inferring it from generic errors.
Rooms are like location-based group chats. An admin creates a room pinned to a spot on the map (using S2 geometry cells). Anyone nearby can discover it. Members share a secret key so they can all read each other's messages, but the relay sees only encrypted blobs.
The admin controls who joins by issuing member tokens — cryptographic tickets that prove membership without revealing identity. The relay checks the ticket is valid but can't tell which member presented it.
Point-to-point events use Gift Wrap (3-layer encryption per recipient). Rooms use symmetric-key encryption with a shared room key — more efficient for groups but with different trust properties:
This is a deliberate asymmetry, not an inconsistency:
| Notes | Rooms | |
|---|---|---|
| Relay sees location? | No | Yes (S2 cell) |
| Purpose | Proximity gate | Discovery index |
| Device constraint | J2ME compatible | SQLite indexable |
| Representation | Circle (haversine) | S2 cell ID (string) |
Notes keep location fully encrypted because they're exchanged between known contacts — no discovery needed. Rooms must be discoverable by strangers nearby, so the relay needs a spatial index. The privacy cost is that DISCOVER_ROOMS reveals the queried S2 cell to the relay.
S2 cells provide a hierarchical spatial index — the relay can efficiently answer "what rooms are near this location?" without storing or processing actual GPS coordinates. The coarse cell enables broader discovery searches, while the fine cell gives precise placement.
Imagine arriving at a venue with a PPP room. You don't know the room key yet, so you "knock" — sending an ephemeral key to the relay. The admin sees your request and "admits" you. The room key is then encrypted specifically for your ephemeral key and delivered over BLE or the relay. Once you have the key, you can derive your member token and participate.
The 120-second TTL on knocks prevents stale requests from accumulating.
This section describes the planned offline-courier and BLE-delivery design. The relay does not route carry-group registration, courier fetch, or any of the BLE-delivery flows below; the only BLE behavior in scope today is the room-level KNOCK / ADMIT handshake (§7.4), which uses BLE proximity for membership signaling but delivers messages over the relay's WebSocket transport. The full courier/BLE roadmap is Phase 7.
The protocol MUST define a carry group concept: a set of users who consent to having their encrypted events carried by designated couriers.
The protocol MUST define a Bluetooth Low Energy transport for courier-to-recipient event delivery without internet connectivity.
The BLE delivery handshake MUST NOT reveal the courier's carried recipient list to bystanders or unauthenticated devices.
Creation: A user generates a random group_id
and distributes CARRY_GROUP_INVITE events (Gift Wrapped) to members.
Joining: A recipient sends CARRY_GROUP_ACCEPT back to the inviter with their pubkey consent.
Registration with relay:
Client -> Relay:
{ cmd: "REGISTER_CARRY_GROUP",
group_id: bytes16,
members: [bytes32],
couriers: [bytes32],
sig: bytes64 }
Leaving: A member sends a signed leave message. The relay MUST stop including departed members' events in subsequent FETCH_CARRY responses.
When a courier has internet connectivity:
CarriedEvent {
event: GiftWrap
fetched_at: uint64
hop_count: uint8 // starts at 0
max_hops: uint8 // default: 3
delivered: bool // until recipient ACKs
recipient: bytes32 // for local lookup
}
Service UUID: 0xPPP1 (short) or 128-bit TBD
Characteristics:
HANDSHAKE_NONCE (read): bytes32, random per-session
AUTH_RESPONSE (write): bytes32 (pk) || bytes64 (sig)
EVENT_STREAM (notify): chunked event data
EVENT_ACK (write): bytes32 (event_id)
Courier Recipient
| |
|<-- BLE scan, discover service -----|
| |
|--- read HANDSHAKE_NONCE ---------->|
| |
|<-- write AUTH_RESPONSE ------------|
| pubkey || sign(nonce, sk) |
| |
| verify sig, check carry group |
| |
|--- EVENT_STREAM notifications ---->|
| (chunked GiftWrap events) |
| |
|<-- EVENT_ACK per event_id ---------|
| |
BLE MTU is negotiable (20-512 bytes). Events larger than (MTU - 3) are chunked:
Chunk {
sequence: uint16 // chunk index (0-based)
total: uint16 // total chunk count
data: bytes // chunk payload
}
The protocol SHOULD support multi-hop courier relay, where a courier transfers carried events to another courier for onward delivery.
hop_count incremented on transfer.hop_count >= max_hops are NOT relayed
further.Couriers solve the “offline last mile” problem. Imagine you're in a remote area with no internet. A friend who was recently in town has your encrypted messages on their phone. When you're within Bluetooth range, their phone automatically hands the messages to yours — without either of you doing anything.
The courier never sees the message contents. They're carrying sealed envelopes they can't open.
Bluetooth Low Energy is available on virtually every modern smartphone and many feature phones. Unlike Wi-Fi Direct or NFC:
The max_hops limit (default 3) prevents events from
circulating indefinitely. Each hop increases latency and widens the
set of devices that see the encrypted envelope. Three hops balances
reach with privacy.
Contact discovery MUST NOT transmit plaintext phone numbers, email addresses, or other personally identifiable information to the relay.
Contact discovery MUST use cryptographic hashes of contact identifiers to match users without exposing raw contact data.
The relay MUST rate-limit discovery queries to prevent enumeration attacks.
normalized = lowercase(strip_whitespace(identifier))
// phone: E.164 format
// email: lowercase, remove dots in local part
hash = crypto_generichash(
32,
normalized,
relay_pepper // relay-provided, rotated
)
The relay pepper prevents offline rainbow table attacks. It is fetched from the relay on each discovery cycle.
1. Client -> Relay:
{ cmd: "DISCOVERY_PUBLISH",
hashes: [bytes32],
pubkey: bytes32 }
2. Client -> Relay:
{ cmd: "DISCOVERY_QUERY",
hashes: [bytes32] }
3. Relay -> Client:
{ cmd: "DISCOVERY_MATCHES",
matches: [{ hash: bytes32,
pubkey: bytes32 }] }
Phone numbers have low entropy (~10 billion values per country code). This makes fast hashes vulnerable even with a pepper:
| Attack | Feasible? | Mitigation |
|---|---|---|
| Pre-computed rainbow table | No (with pepper) | Relay pepper invalidates pre-computed tables |
| Online enumeration | Bounded | Rate limiting (DR-03) |
| Offline brute-force (with pepper) | Yes — BLAKE2b is fast | Slow-hash upgrade (S8.5) |
| Relay-side brute-force | Trivial for operator | PSI migration (S8.5) |
Contact discovery answers: “which of my phone contacts are also on PPP?” Instead of sending your contacts' phone numbers to the server, you send scrambled (hashed) versions. The server compares scrambled values without ever seeing the real numbers.
The relay adds a secret “pepper” to the hash so that even if someone steals the hash database, they can't reverse-engineer the phone numbers without also having the pepper.
The v0.1 pepper model does NOT protect against a malicious relay operator — they have the pepper and can enumerate all ~10 billion phone numbers in minutes with BLAKE2b. This is a documented trade-off. The hardening roadmap addresses this progressively.
OOB verification (S9) provides defense-in-depth: even if an attacker maps a hash to a phone number, they cannot forge the verified identity binding.
The protocol MUST define an out-of-band verification mechanism independent of any specific messaging channel.
Both invite and accept payloads MUST be cryptographically signed to prove key ownership and prevent impersonation.
Contact discovery finds candidates; verification confirms them. The protocol defines the payload format and crypto handshake but does NOT prescribe which channel to use.
| Level | Name | Method | Trust Basis |
|---|---|---|---|
| 0 | Discovered | Relay hash lookup | TOFU — relay honesty |
| 1 | OOB Verified | Message via authenticated channel | Channel's identity binding |
| 2 | In-Person | QR code scan, NFC tap, verbal comparison | Physical co-presence |
VerificationInvite (sent by initiator via OOB channel):
VerificationInvite {
protocol: "ppp-verify-v1"
sender_pk: bytes32
invite_token: bytes16 // random, single-use
relay_url: string
sender_sig: bytes64 // sign(invite_token, sk)
timestamp: uint64
}
VerificationAccept (sent by responder):
VerificationAccept {
protocol: "ppp-verify-v1"
responder_pk: bytes32
invite_token: bytes16 // echoed from invite
responder_sig: bytes64 // sign(sender_pk
// || invite_token, sk)
}
The responder_sig signs both sender_pk AND
invite_token, preventing forwarding attacks.
Invite:
https://<domain>/v?p=<b64(sender_pk)>
&t=<b64(invite_token)>
&r=<b64(relay_url)>
&s=<b64(sender_sig)>
&ts=<timestamp>
Accept:
https://<domain>/va?p=<b64(responder_pk)>
&t=<b64(invite_token)>
&s=<b64(responder_sig)>
Alice (initiator) Bob (responder)
| |
| 1. Generate invite_token |
| Sign with alice_sk |
| |
|-- 2. Send invite via OOB -------->|
| |
| 3. Verify sender_sig |
| 4. Sign acceptance |
| |
|<- 6. Send accept via OOB ---------|
| |
| 7. Verify responder_sig |
| against invite_token |
| |
| 8. Both pin keys at level 1 |
| |
|== 9. PPP communication begins ====|
After successful verification, the client MUST pin the contact's pubkey locally and track the verification level and method.
PinnedContact {
contact_id: string // local only
pubkey: bytes32
verification_level: uint8 // 0, 1, or 2
verified_at: uint64
verified_via: string // channel name
relay_url: string
}
This record is stored locally and is NEVER transmitted to the relay.
Clients MUST alert users when a verified contact's pubkey changes, with alert prominence proportional to the verification level.
| Level | Behavior on Key Change |
|---|---|
| 0 (Discovered) | MAY silently accept (TOFU). SHOULD show subtle indicator. |
| 1 (OOB Verified) | MUST show prominent warning. User must approve or re-verify. |
| 2 (In-Person) | MUST show prominent warning. SHOULD require in-person re-verification. |
| Channel | Confidentiality | Identity Binding |
|---|---|---|
| iMessage | E2EE | Apple ID / phone |
| E2EE | Phone number | |
| Signal | E2EE | Phone number |
| Email (TLS) | Transit only | Email address |
| SMS | None | Phone (weak — SIM swap risk) |
| QR code | N/A (physical) | Co-presence (strongest) |
Verification invites MUST expire and MUST NOT be reusable.
For level 2, both parties scan QR codes or compare a verification fingerprint:
fingerprint = base64(
BLAKE2b(sort(alice_pk, bob_pk), 16))
Displayed as: aBc1 dEf2 gHi3 jKl4
Equivalent to Signal's safety number comparison.
Discovery tells you “someone with this phone number is on PPP.” But how do you know it's really your friend and not an impersonator? Verification answers this by exchanging signed tokens through a channel you already trust (like iMessage or WhatsApp).
The strongest verification is in-person: you meet face-to-face and scan each other's QR codes, or compare a short code displayed on both phones.
The responder signs both the invite token AND the initiator's pubkey. This means if Alice forwards Bob's acceptance to Carol, Carol will see that Bob's signature explicitly names Alice — it can't be repurposed to verify with Carol.
The protocol SHOULD define an SMS notification mechanism for devices without data connectivity.
The event format MUST be independent of transport mechanism.
Client -> Relay:
{ cmd: "CAPABILITIES",
transports: ["ws", "http", "ble"],
push_platform: "apns" | "fcm" | null,
push_token: string | null }
| Tier | Transport | Devices | Real-time | Offline Send |
|---|---|---|---|---|
| 1 | WebSocket | iOS, Android, KaiOS, Web | Yes (NOTIFY) | Queue + reconnect |
| 2 | HTTP poll | J2ME, constrained HTTP | No (polling) | Queue + next poll |
| 3 | SMS notify | Any phone with SMS | Notification only | N/A |
| 4 | BLE courier | Any BLE device | Local only | Courier carries |
A single event may be delivered via multiple transports simultaneously. The recipient deduplicates by event_id regardless of delivery path. The first successful ACK is authoritative.
PPP is designed to work everywhere — from a modern iPhone to a basic feature phone. Each device advertises what it can do, and the relay adapts its delivery strategy accordingly. A smartphone gets real-time WebSocket delivery; a feature phone might rely on SMS pings and HTTP polling.
The protocol targets extreme device diversity. WebSocket covers most modern devices, HTTP polling handles legacy browsers and constrained environments, SMS reaches dumb phones, and BLE courier handles the fully offline case. No single transport reaches everyone.
The protocol MUST minimize metadata visible to relays and intermediaries.
Event payloads MUST be padded to fixed bucket sizes before outer encryption to prevent size-based correlation.
| Bucket | Typical Content |
|---|---|
| 256 B | ACKs, revocations, key rotations |
| 1024 B | Short presence events (text only) |
| 4096 B | Longer messages, carry group invites |
| 16384 B | Reserved for future media-bearing events |
padded_length = next_bucket(plaintext_length)
padding = randombytes(
padded_length - plaintext_length - 2)
padded_input = plaintext || 0x01 || padding || 0x00
^^^^ ^^^^
delimiter terminator
Instead of fetching by long-term pubkey, clients use rotating mailbox identifiers:
epoch = floor(unix_time / 86400) // daily
mailbox_seed = X25519(user_sk, relay_pk)
mailbox_id = BLAKE2b(32,
mailbox_seed || epoch)
The relay cannot link Monday's mailbox_id to Tuesday's without
knowing the user's private key.
The relay SHOULD support configurable batch windows (default: 30s) to break timing correlation between event storage and notification.
| Technique | Why Deferred |
|---|---|
| Cover traffic | High bandwidth/battery cost, incompatible with BLE/offline devices |
| Mixnet relay chaining | Requires relay federation, premature for v0.1 |
| Private Information Retrieval | O(N) server work per query, infeasible on feature phones |
Even with perfect encryption, the relay can learn things from patterns: who messages whom, how often, and message sizes. Metadata protection adds countermeasures:
Cover traffic and mixnets are the gold standard but require always-on connectivity and multiple relay operators. PPP targets feature phones and offline scenarios where these are impractical. The deferred techniques may be offered as an opt-in “high privacy mode” for WiFi-connected clients.
| Adversary | Capabilities | Mitigations |
|---|---|---|
| Relay operator | Sees envelopes: recipient, ephemeral key, timestamps, blob | Gift Wrap hides sender/content. Sealed sender hides submitter. Blinded mailboxes reduce linkage. |
| Network observer | Sees TLS-encrypted traffic | Standard TLS. Padding prevents size fingerprinting. Batching weakens timing analysis. |
| Malicious courier | Has carried encrypted blobs | Cannot decrypt. Cannot determine sender. Can see recipient pubkeys. |
| BLE bystander | In BLE range | Generic service UUID. Handshake requires valid keypair. Rate limiting prevents enumeration. |
| Malicious recipient | Decrypts received events | By design. No forward secrecy in this version. |
| Discovery spoofer | Registers fake hash-to-pubkey mapping | OOB verification detects. Level 0 contacts marked in UI. |
| OOB interceptor | Intercepts verification URLs | Payloads contain only public data. Modification detected by signatures. |
| Hash brute-forcer | Obtains pepper, enumerates phone numbers | Partial only in v0.1. See hardening roadmap (S8.5). |
No system is perfectly secure. This section is honest about what PPP protects against and what it doesn't. The key insight: the relay is treated as an honest but curious adversary — it will follow the protocol but might try to learn what it can from the metadata it handles.
PPP does not implement a Double Ratchet (like Signal). This is a deliberate trade-off: the protocol prioritizes simplicity and cross-platform portability (including J2ME feature phones) over ratchet-based forward secrecy. Adding a ratchet would significantly increase state management complexity and is deferred to a future version.
Field Type Description
───────────── ──────── ──────────────────────────────
recipient bytes32 Recipient pubkey or mailbox ID
ephemeral_pk bytes32 Ephemeral wrap public key
nonce bytes24 XChaCha20 nonce
ciphertext bytes Encrypted Seal
created_at uint64 Unix timestamp
expires_at uint64 Event expiry
Overhead: 32 + 32 + 24 + 8 + 8 = 104 bytes + ciphertext
Field Type Description
───────────── ──────── ──────────────────────────────
ephemeral_pk bytes32 Ephemeral seal public key
nonce bytes24 XChaCha20 nonce
ciphertext bytes Encrypted Rumor
sender_sig bytes64 Sender signature over ciphertext
Overhead: 32 + 24 + 64 = 120 bytes + ciphertext
Field Type Description
───────────── ──────── ──────────────────────────────
sender bytes32 Sender Ed25519 public key
kind uint8 Event kind (S4.2)
content varies Kind-specific payload (S4.3)
created_at uint64 Sender-asserted timestamp
Field Type Description
───────────── ──────── ──────────────────────────────
region_type uint8 0 = circle (v0.1 baseline)
latitude float64 Center latitude (WGS84)
longitude float64 Center longitude (WGS84)
radius_m uint32 Radius in meters
Overhead: 1 + 8 + 8 + 4 = 21 bytes
For a typical PRESENCE event with 140-byte message and circle region:
Rumor: 32 + 1 + ~200 (16 id + 140 msg
+ 21 region + 24 time) + 8 = ~241 B
Seal: 32 + 24 + (241 + 16 tag) + 64 = ~377 B
GiftWrap: 32 + 32 + 24 + (377 + 16) + 16 = ~497 B
Under 500 bytes per event — well within BLE MTU negotiation range and efficient for constrained networks.
At ~500 bytes, a PPP event is:
This compactness is critical for BLE courier delivery and bandwidth-constrained feature phones.
Reference implementations MUST pass these vectors to confirm cross-platform interoperability.
Planned vectors:
Vectors are published as JSON in the test-vectors/ directory
with deterministic seeds and nonces for reproducibility.
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 and 6 are implemented in the Rust reference
(ppp-core) and published in test-vectors/.
Swift and Kotlin binding verification is pending.
These requirements derive from the NowHere app spec and brainstorming sessions on cross-platform protocol design.
| PPP Requirement | NowHere Origin |
|---|---|
| PR-01, PR-03 | NFR-02 (Privacy & Security) |
| PR-04 | Product Principle: Privacy by default |
| PR-06, PR-07 | New: relay metadata protection |
| IR-01, IR-02 | Open Decision: migrate from CloudKit |
| TR-01, TR-05 | FR-04 (Save + Share Flow), FR-08 (Offline) |
| CR-01..CR-05 | New: courier relay concept |
| DR-01..DR-03 | FR-07 (Contact Discovery Refresh) |
| SR-01, SR-02 | FR-08 (Offline-First Operation) |
| SR-03 | FR-09 (Expiration and Cleanup) |
| SE-01..SE-03 | NFR-02, security improvements |
| VR-01..VR-06 | New: OOB verification |
PPP was extracted from NowHere, an iOS app for place-gated social notes. The protocol generalizes the app's features into a platform-independent specification while adding new capabilities (courier delivery, contact discovery, OOB verification) that the original CloudKit-based implementation couldn't support.
Clients MUST support creating and queuing events while offline, with automatic delivery when connectivity is restored.
The protocol MUST guarantee eventual delivery of events to recipients, provided the relay is reachable within the event's TTL.
since timestamp.Events MUST carry an expiry timestamp. Relays and couriers MUST discard expired events.
expires_at is REQUIRED on
all events.PPP is designed for unreliable networks. You can create a message on an airplane, and it will be delivered when you land and get signal. Messages that aren't picked up before their expiry time are automatically cleaned up everywhere — on the relay, on couriers, and on your device.
The protocol MUST prevent replay attacks where a previously valid event is resubmitted.
The protocol MUST resist attempts to enumerate registered users or stored events.
Replay protection ensures that capturing an encrypted event and re-sending it doesn't work — the relay recognizes the duplicate and rejects it. Enumeration resistance means an attacker can't probe the relay to discover who uses the system.
This appendix is generated from the relay source by
cargo run -p spec-gen -- generate. CI rejects pull
requests that change the wire surface without regenerating it, so
the tables below are authoritative — when prose elsewhere in the
spec disagrees with this appendix, the appendix is correct.
| Command | Body | Description |
|---|---|---|
AUTH |
pubkey: bytes, sig: bytes |
Authentication response (after receiving Challenge). |
STORE |
event: bytes |
Submit an encrypted event for delivery. |
FETCH |
since: uint64, cursor?: FetchCursor |
Fetch events for the authenticated pubkey. |
ACK |
event_ids: [bytes] |
Acknowledge receipt of events. |
CREATE_ROOM |
room_id: bytes, s2_cell_id: string, s2_coarse_id: string, gated: bool, proximity_required: bool, ble_enabled: bool, max_members: uint32, expires_at: uint64, name?: string, description?: string, description_public: bool |
Create a new room. |
FETCH_ROOM |
room_id: bytes, since_id: int64 |
Fetch messages from a room. |
DISCOVER_ROOMS |
s2_cell_id: string |
Discover rooms in an S2 cell. |
KNOCK |
room_id: bytes, knocker_eph_x25519_pk: bytes, request_id: bytes, handle?: string |
BLE knock request. |
ADMIT |
room_id: bytes, request_id: bytes, encrypted_grant?: bytes |
Admit a knock request (admin only). |
REGISTER_TOKEN |
room_id: bytes, member_token: bytes, admin_signature: bytes |
Register an admin-signed member token. |
REVOKE_TOKENS |
room_id: bytes, reason?: string |
Revoke all member tokens for a room (admin only). |
ROTATE_ROOM_KEY |
room_id: bytes, grace_seconds: uint32 |
Revoke all tokens and notify members of key rotation (admin only). |
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 |
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 |
Kick a member from a room (admin only). Temporary — can re-knock. |
BAN_MEMBER |
room_id: bytes, member_pubkey: bytes, reason?: string |
Ban a member from a room (admin only). Permanent — cannot re-knock. |
STORE_BACKUP |
ciphertext: bytes |
Upload an encrypted backup (upserts: replaces any existing backup). |
FETCH_BACKUP |
(no fields) | Download the latest backup for the authenticated pubkey. |
DELETE_BACKUP |
(no fields) | Delete the stored backup for the authenticated pubkey. |
CREATE_GROUP |
room_id: bytes, gated: bool, max_members: uint32, name?: string, description?: string, description_public: bool |
Create a persistent group (room_type='group', no expiry, no S2 discovery). |
DELETE_GROUP |
room_id: 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 |
100 |
u32 |
Maximum number of registered Note delivery tokens per recipient pubkey. Each token is one symmetric per-contact derivation; this caps memory for a relay storing tokens for highly-connected users. |
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_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. |
Authenticated WebSocket commands are rate-limited per pubkey using a sliding window. Commands not listed below use the default budget (30 requests per 60 seconds). Per-token (sealed HTTP) and per-IP limits live in §C.2 above.
| Command | Max | Window | Note |
|---|---|---|---|
STORE |
60 | 60s (1m) | |
FETCH |
30 | 60s (1m) | |
ACK |
60 | 60s (1m) | |
STORE_BACKUP |
5 | 3600s (1h) | Backups are infrequent on legitimate clients; tight budget bounds storage churn. |
FETCH_BACKUP |
5 | 3600s (1h) | FETCH_BACKUP returns up to 5 MiB; tight budget prevents egress amplification via repeated fetches. |
DISCOVER_ROOMS |
120 | 60s (1m) | Bounded by the iOS map view (~9 cells x 12 batches/min worst case); 120/60s leaves ~10% headroom. |
| Message | Body | Description |
|---|---|---|
CHALLENGE |
nonce: bytes |
Authentication challenge — client must sign the nonce. |
AUTH_OK |
(no fields) | Authentication succeeded. |
AUTH_FAIL |
reason: string |
Authentication failed. |
STORED |
event_id: bytes |
Event stored successfully. |
STORE_FAIL |
reason: string |
Event storage failed. |
EVENTS |
events: [bytes], cursor?: FetchCursor |
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 |
Knock notification to room admin. |
ADMIT_OK |
request_id: bytes, encrypted_grant?: bytes |
Knock admitted successfully. |
ADMITTED |
room_id: bytes, request_id: bytes, admin_pubkey: bytes, encrypted_grant?: bytes |
Notification to knocker that they've been admitted. |
TOKEN_REGISTERED |
(no fields) | Member token registered successfully. |
TOKENS_REVOKED |
count: uint32 |
Room tokens revoked. |
KEY_ROTATED |
room_id: bytes, revoked_count: uint32, grace_until: uint64 |
Room key rotation completed. |
KEY_ROTATION_NOTIFY |
room_id: bytes, grace_until: uint64 |
Notification to members that room key was rotated (re-register tokens). |
ROOM_STORED |
room_id: bytes, sequence_number: int64 |
Room message stored successfully via authenticated channel. |
ROOM_UPDATED |
room_id: bytes |
Room flags updated. |
PUSH_REGISTERED |
(no fields) | Push token registered. |
ROOM_NOTIFY |
room_id: bytes, count: uint32 |
New room messages available. |
DELIVERY_TOKEN_REGISTERED |
(no fields) | Delivery token registered successfully. |
DELIVERY_TOKEN_DEREGISTERED |
(no fields) | Delivery token deregistered successfully. |
MEMBER_KICKED |
room_id: bytes, member_pubkey: bytes |
Member kicked from room (admin confirmation). |
MEMBER_BANNED |
room_id: bytes, member_pubkey: bytes |
Member banned from room (admin confirmation). |
KICKED |
room_id: bytes |
You were kicked from a room (sent to kicked member). |
BACKUP_STORED |
version: uint32, stored_at: uint64 |
Backup stored successfully. |
BACKUP |
ciphertext: bytes, version: uint32, stored_at: uint64 |
Backup returned in response to FETCH_BACKUP. |
BACKUP_EMPTY |
(no fields) | No backup exists for this pubkey. |
BACKUP_DELETED |
(no fields) | Backup deleted successfully. |
BACKUP_FAIL |
reason: string |
Backup operation failed. |
GROUP_CREATED |
room_id: bytes |
Group created successfully. |
GROUP_DELETED |
room_id: bytes |
Group deleted successfully. |
The kind field of a Rumor (§4.3) takes one of
these uint8 discriminants. New kinds are appended;
deprecated ones stay reserved.
| Kind | Discriminant | Description |
|---|---|---|
Presence |
0x01 |
Place- and time-gated message from sender to recipients. |
Revoke |
0x02 |
Sender revokes a previously sent event. |
Ack |
0x03 |
Application-level acknowledgement of a received event. |
KeyRotation |
0x04 |
Sender announces migration to a new public key (proves ownership of old key). |
ContactDiscovery |
0x05 |
Discovery handshake message (deferred — see Phase 6). |
CarryGroupInvite |
0x06 |
Invitation to join a carry group for BLE courier delivery. |
CarryGroupAccept |
0x07 |
Acceptance of a carry group invitation. |
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). |
Sealed-sender HTTP endpoints; see §6 for the design and §C.2 for the per-token and per-IP rate limits that gate them.
| Method | Path | Body | Description |
|---|---|---|---|
POST |
/v1/room/submit |
SealedRoomSubmit |
Anonymous sealed room message submission. Verified by admin-signed 12-byte member token; no auth handshake. |
POST |
/v1/note/submit |
SealedNoteSubmit |
Anonymous sealed Note submission. Verified by per-contact 12-byte delivery token derived from X25519 shared secret; no auth handshake. |
SealedRoomSubmit (POST /v1/room/submit body)| Field | Type | Description |
|---|---|---|
room_id |
bytes |
Room identifier (16 bytes). |
member_token |
bytes |
Admin-signed member delivery token (12 bytes). |
nonce |
bytes |
XChaCha20-Poly1305 nonce (24 bytes). |
ciphertext |
bytes |
Encrypted message ciphertext. |
SealedNoteSubmit (POST /v1/note/submit body)| Field | Type | Description |
|---|---|---|
delivery_token |
bytes |
Per-contact delivery token (12 bytes). |
ephemeral_pk |
bytes |
GiftWrap ephemeral public key (32 bytes). |
nonce |
bytes |
XChaCha20-Poly1305 nonce (24 bytes). |
ciphertext |
bytes |
GiftWrap ciphertext. |
created_at |
uint64 |
Event creation timestamp (unix seconds). |
expires_at |
uint64 |
Event expiration timestamp (unix seconds). |