A cross-platform protocol for place- and time-gated social event delivery with strong privacy guarantees. PPP enables encrypted, location-aware messaging across iOS, Android, KaiOS feature phones, and potentially J2ME devices — without exposing message content, sender identity, or location data to relay servers or network observers.
| 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 |
| 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 |
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
}
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_id = crypto_generichash(
32,
serialize(giftwrap.ephemeral_pk
|| giftwrap.nonce
|| giftwrap.ciphertext)
)
This ensures uniqueness without revealing content or sender.
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 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 — 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] }
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 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.
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 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.expires_at > now.event_id = BLAKE2b(32, ephemeral_pk || nonce || ciphertext).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 |
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 |
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.
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.