Presence-Privacy-Protocol

Draft Version 0.2 March 2026

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.

The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.

Contents

1. Terminology

TermDefinition
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.

In Plain English

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.

Cross-References

External References

2. Identity Model

IR-01 Keypair-Based Identity

User identity MUST be based on Ed25519 public/private key pairs. The public key serves as the user's protocol-level identifier.

  • A new user generates an Ed25519 key pair locally on first launch.
  • The public key (32 bytes) is the canonical user identifier in all protocol operations.
  • No server-assigned user IDs exist in the protocol.
IR-02 Server-Independent Identity

User identity MUST NOT be bound to any specific relay or server. A user MUST be able to switch relays without changing identity.

IR-03 Key Rotation

The protocol SHOULD support key rotation, allowing a user to migrate to a new key pair while maintaining continuity with existing contacts.

2.1 Key Generation

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).

2.2 Key Conversion

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.

2.3 Key Rotation

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.

Why Ed25519?

Ed25519 was chosen for three reasons:

  • Compact — 32-byte public keys and 64-byte signatures are small enough for BLE and SMS transport.
  • Deterministic — signing is deterministic (no random nonce), eliminating a class of implementation bugs.
  • Convertible — Ed25519 keys can be mathematically converted to X25519 for encryption, so users only need one key pair.

Ed25519 paper (Bernstein et al.) · RFC 8032

In Plain English

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.

External References

Cross-References

3. Crypto Layer

PR-01 End-to-End Encryption

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).

3.1 Symmetric Encryption

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

3.2 Key Exchange

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
)

3.3 Gift Wrap Pattern

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:

PartyVisible 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

Gift Wrap Encryption Layers

Layer 3: GiftWrap recipient, ephemeral_pk, nonce, ciphertext created_at, expires_at Layer 2: Seal ephemeral_pk, nonce, ciphertext, sender_sig Layer 1: Rumor sender (real pubkey) kind (event type) content (message, location, time) created_at Only the recipient sees this Relay sees Encrypted Encrypted

Why Three Layers?

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 vs AES-GCM

XChaCha20-Poly1305 was chosen over AES-GCM because:

  • 24-byte nonces are safe to generate randomly (negligible collision probability), while AES-GCM's 12-byte nonces require careful counter management.
  • No hardware dependency — performs well on devices without AES-NI (feature phones, KaiOS).
  • libsodium default — available across all target platforms.

RFC 8439: ChaCha20-Poly1305 · libsodium: XChaCha20-Poly1305

Cross-References

External References

4. Event Format

TR-05 Transport-Agnostic Event Format

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.

4.1 Serialization

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:

KeyField
1recipient
2ephemeral_pk
3nonce
4ciphertext
5created_at
6expires_at
7sender
8kind
9content
10sender_sig
11group_id
12hop_count
13max_hops
14region_type

4.2 Event Kinds

KindValueDescription
PRESENCE0x01Place- and time-gated event (primary use case)
REVOKE0x02Sender revokes a previously sent event
ACK0x03Recipient acknowledges receipt
KEY_ROTATION0x04Sender announces a new public key
CONTACT_DISCOVERY0x05Contact hash publication for discovery
CARRY_GROUP_INVITE0x06Invitation to join a carry group
CARRY_GROUP_ACCEPT0x07Acceptance of carry group invitation

4.3 Event Content by Kind

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
}

4.4 Region Model

PR-03 Location Confidentiality

Geographic coordinates and region data MUST NOT appear in plaintext in any protocol message visible to relays, couriers, or network observers.

PR-08 Region Radius Bounds

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.

4.4.1 Region Type 0: Circle (v0.1 baseline)

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.

BoundValueRationale
Minimum10 mPrevents point-location deanonymisation. Sub-10 m radii effectively reveal exact GPS coordinates.
Maximum50 kmKeeps 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.

4.4.2 Future Region Types (reserved)

region_typeRepresentationUse Case
0Circle (lat/lon/radius)v0.1 baseline
1Geohash cell setArbitrary drawn areas
2-127ReservedFuture 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.

4.5 Event ID Derivation

event_id = crypto_generichash(
    32,
    serialize(giftwrap.ephemeral_pk
              || giftwrap.nonce
              || giftwrap.ciphertext)
)

This ensures uniqueness without revealing content or sender.

In Plain English

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.

Why Integer Keys?

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.

Region Privacy

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.

Cross-References

External References

5. Relay Protocol

TR-01 WebSocket Primary Transport

The protocol MUST define a WebSocket-based transport as the primary real-time channel between clients and relays.

SE-02 Relay Authentication

Clients MUST authenticate to the relay to prevent unauthorized access to stored events. Authentication uses Ed25519 challenge-response.

5.1 WebSocket Transport (Tier 1)

Connection endpoint: wss://<relay-host>/v1/ws

All WebSocket frames are MessagePack-encoded objects with a cmd field.

5.1.1 Authentication

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.

5.1.2 Commands

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.

5.1.3 Connection Lifecycle

  • Clients SHOULD send a WebSocket ping every 30 seconds.
  • Relays SHOULD close idle connections after 5 minutes.
  • On disconnect, clients SHOULD reconnect with exponential backoff (1s, 2s, 4s, ... capped at 60s).

5.2 HTTP Polling Transport (Tier 2)

TR-02 HTTP Polling Fallback

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)
EndpointMethodWS Equivalent
/v1/eventsPOSTSTORE
/v1/events?since={ts}GETFETCH
/v1/events/carry?group={id}&since={ts}GETFETCH_CARRY
/v1/events/ackPOSTACK

Clients SHOULD poll at a default interval of 30 seconds. The relay MAY include a Retry-After header.

5.3 Push Notification Bridge

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.

5.4 SMS Notification (Tier 3)

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.

5.5 Storage Backend

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.

  • STORE: Insert envelope, reject duplicates via event_id primary key.
  • FETCH: Query by (recipient, created_at) with partial index excluding acknowledged events.
  • ACK: Set acked_at timestamp; purge after grace period.
  • Expiry purge: Background task deletes events past expires_at.

Relay Message Flow

Client A Relay Client B AUTH AUTH_OK STORE (GiftWrap) STORED NOTIFY FETCH EVENTS ACK ACKED Expiry purge

In Plain English

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.

Why SQLite?

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.

Cross-References

External References

6. Sealed Sender

PR-02 Sender Anonymity from Relay

The relay MUST NOT be able to determine the sender of any event. Sender identity MUST be included only inside the encrypted payload.

PR-07 Relay Delivery Graph Protection

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.

6.1 Delivery Token Derivation

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:

  • Symmetric: both contacts derive the same token for a given recipient.
  • Domain-separated: the context string ensures tokens are distinct from room member tokens.
  • 12 bytes (96 bits): sufficient anti-collision space for ~50-100 tokens per user.
  • Self-authenticating: derived from a shared secret only the two contacts can compute.

6.2 Token Registration

Client -> Relay:
{ cmd: "REGISTER_DELIVERY_TOKEN",
  delivery_token: bytes12 }

Relay -> Client:
{ cmd: "DELIVERY_TOKEN_REGISTERED" }
  • Maximum 100 tokens per recipient.
  • Registration is idempotent.
  • Rate-limited per standard per-pubkey limiter.

6.3 Anonymous Note Submission

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:

  1. Validate field lengths.
  2. Look up delivery_tokenrecipient_pk. Unknown token → 401.
  3. Per-token rate limit: 2/sec. Exceeded → 429.
  4. Validate expires_at > now.
  5. Derive event_id = BLAKE2b(32, ephemeral_pk || nonce || ciphertext).
  6. Store event. Duplicate → 409.
  7. Notify recipient (WebSocket or push).

6.4 Graceful Fallback

Sealed sender is an optimization. Clients MUST support fallback to authenticated STORE when:

  • The recipient hasn't registered the sender's delivery token yet.
  • Token submission fails (unknown token, rate limit, error).
  • The sender is already on an authenticated WebSocket.

Fallback degrades privacy but never blocks delivery. Clients SHOULD retry sealed submission before falling back.

6.5 Anonymity Set Considerations

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.

6.6 Sealed Sender Levels Roadmap

LevelHidesMechanismStatus
1Sender identityDelivery tokens + anonymous HTTP POSTImplemented
2Recipient identityBlinded mailbox IDs + epoch rotationDeferred
3BothLevel 1 + Level 2 combinedDeferred

Authenticated vs Sealed Sender

Authenticated STORE 1. AUTH (pubkey) 2. STORE (event) Relay learns: sender + recipient Sealed Sender 1. POST /v1/note/submit (no auth handshake) Relay learns: recipient only Privacy: partial Privacy: stronger Always available Requires token pre-registration

In Plain English

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.

Why 12 Bytes?

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.

Cross-References

External References

7. Room Protocol

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:

  • Notes use circles (S4.4) inside the encrypted payload. The relay never sees the location — only the recipient decrypts and performs a haversine proximity check. Circles are trivially implementable on all target platforms, including J2ME (~15 lines of basic trigonometry).
  • Rooms use S2 cell IDs as relay-visible metadata for spatial indexing. The relay must answer “what rooms are near this location?” for discovery, which requires an indexable spatial representation. S2 cells provide hierarchical precision and map efficiently to SQLite indexes.

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.

7.1 Room Lifecycle

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.

7.2 Member Tokens

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.

7.3 Room Messages

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.

7.4 BLE Knock/Admit

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.

7.5 Room Notifications

NotificationRecipientsTrigger
NEW_ROOM_MESSAGESAll room subscribersSTORE_ROOM or sealed submit
KNOCK_RECEIVEDRoom adminKNOCK command
ADMITTEDKnockerADMIT command
KEY_ROTATIONAll room subscribersROTATE_ROOM_KEY

In Plain English

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.

Rooms vs Point-to-Point

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:

  • Point-to-point: Only the recipient can decrypt. Relay sees recipient pubkey.
  • Rooms: All members can decrypt. Relay sees only room_id and opaque ciphertext. Sender anonymity is stronger (sealed submission hides which member sent it).

Circles vs S2 Cells

This is a deliberate asymmetry, not an inconsistency:

NotesRooms
Relay sees location?NoYes (S2 cell)
PurposeProximity gateDiscovery index
Device constraintJ2ME compatibleSQLite indexable
RepresentationCircle (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.

Why S2 Geometry?

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.

BLE Knock/Admit Flow

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.

Cross-References

External References

8. Courier & BLE

CR-01 Carry Group Membership

The protocol MUST define a carry group concept: a set of users who consent to having their encrypted events carried by designated couriers.

TR-04 BLE Local Delivery

The protocol MUST define a Bluetooth Low Energy transport for courier-to-recipient event delivery without internet connectivity.

CR-03 BLE Handshake Privacy

The BLE delivery handshake MUST NOT reveal the courier's carried recipient list to bystanders or unauthenticated devices.

8.1 Carry Group Lifecycle

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.

8.2 Courier Fetch Phase

When a courier has internet connectivity:

  1. Authenticate with the relay.
  2. Issue FETCH_CARRY for each carry group.
  3. Store received events locally with metadata:
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
}

8.3 BLE Delivery Protocol

8.3.1 GATT Service Definition

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)

8.3.2 Handshake Sequence

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 ---------|
  |                                    |

8.3.3 Event Chunking

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
}

8.3.4 Rate Limiting

  • Maximum 1 AUTH_RESPONSE per connection per 10 seconds.
  • Maximum 10 unique pubkey attempts per hour.
  • Failed attempts are logged locally (not transmitted).

8.4 Multi-Hop Rules

CR-04 Multi-Hop Relay

The protocol SHOULD support multi-hop courier relay, where a courier transfers carried events to another courier for onward delivery.

  1. Both couriers perform the handshake (each acts as courier and recipient).
  2. Events exchanged only if receiving courier's carry group includes the recipient pubkey.
  3. hop_count incremented on transfer.
  4. Events where hop_count >= max_hops are NOT relayed further.
  5. Deduplication by event_id — duplicates silently dropped.

In Plain English

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.

Why BLE?

Bluetooth Low Energy is available on virtually every modern smartphone and many feature phones. Unlike Wi-Fi Direct or NFC:

  • Background operation — BLE can scan and advertise while the app is backgrounded (iOS and Android both support this).
  • Low power — negligible battery impact for passive scanning.
  • Range — 10-30m typical, enough for passing someone on the street or being in the same building.

Multi-Hop Design

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.

Cross-References

External References

9. Contact Discovery

PR-05 PII-Free Contact Discovery

Contact discovery MUST NOT transmit plaintext phone numbers, email addresses, or other personally identifiable information to the relay.

DR-01 Hash-Based Contact Matching

Contact discovery MUST use cryptographic hashes of contact identifiers to match users without exposing raw contact data.

DR-03 Discovery Rate Limiting

The relay MUST rate-limit discovery queries to prevent enumeration attacks.

9.1 Hash Construction

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.

9.2 Discovery Flow

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 }] }

9.3 Privacy Properties

  • The relay sees hashes, not raw identifiers.
  • Pepper rotation invalidates old hashes, requiring republication.
  • Rate limiting bounds enumeration attempts.
  • Query padding with dummy hashes mitigates address book size leakage.

9.4 Rainbow Table & Brute-Force Analysis

Phone numbers have low entropy (~10 billion values per country code). This makes fast hashes vulnerable even with a pepper:

AttackFeasible?Mitigation
Pre-computed rainbow tableNo (with pepper)Relay pepper invalidates pre-computed tables
Online enumerationBoundedRate limiting (DR-03)
Offline brute-force (with pepper)Yes — BLAKE2b is fastSlow-hash upgrade (S8.5)
Relay-side brute-forceTrivial for operatorPSI migration (S8.5)

9.5 Hardening Roadmap

  1. Slow hashing (near-term): Replace BLAKE2b with Argon2id. Raises brute-force cost from seconds to hours/days.
  2. Server-side HMAC (medium-term): Relay applies HMAC with secret key. Prevents client-side brute-force but requires trusting relay with plaintext during query.
  3. Private Set Intersection (long-term): OPRF-based PSI allows matching without either party learning the other's full set.

In Plain English

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.

Known Limitation

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.

Cross-References

External References

10. Out-of-Band Verification

VR-01 Channel-Agnostic Verification

The protocol MUST define an out-of-band verification mechanism independent of any specific messaging channel.

VR-02 Signed Verification Payloads

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.

10.1 Verification Levels

LevelNameMethodTrust Basis
0DiscoveredRelay hash lookupTOFU — relay honesty
1OOB VerifiedMessage via authenticated channelChannel's identity binding
2In-PersonQR code scan, NFC tap, verbal comparisonPhysical co-presence

10.2 Verification Payloads

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.

10.3 OOB URL Format

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)>

10.4 Verification Flow

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 ====|

10.5 Key Pinning

VR-04 Key Pinning After Verification

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.

10.6 Key Change Detection

VR-05 Key Change Alerts

Clients MUST alert users when a verified contact's pubkey changes, with alert prominence proportional to the verification level.

LevelBehavior 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.

10.7 Channel Trust Ranking

ChannelConfidentialityIdentity Binding
iMessageE2EEApple ID / phone
WhatsAppE2EEPhone number
SignalE2EEPhone number
Email (TLS)Transit onlyEmail address
SMSNonePhone (weak — SIM swap risk)
QR codeN/A (physical)Co-presence (strongest)

10.8 Invite Expiry & Anti-Spam

VR-06 Invite Expiry and Replay Prevention

Verification invites MUST expire and MUST NOT be reusable.

  • Invites SHOULD expire after 48 hours.
  • Used tokens MUST be rejected on re-use.
  • Clients SHOULD limit outgoing invites (e.g., max 20/hour).

10.9 In-Person Verification

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.

In Plain English

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.

Forwarding Attack Prevention

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.

Cross-References

External References

11. Transport Tiers

TR-03 SMS Notification Tier

The protocol SHOULD define an SMS notification mechanism for devices without data connectivity.

TR-05 Transport-Agnostic Event Format

The event format MUST be independent of transport mechanism.

11.1 Client Capability Advertisement

Client -> Relay:
{ cmd: "CAPABILITIES",
  transports: ["ws", "http", "ble"],
  push_platform: "apns" | "fcm" | null,
  push_token: string | null }

11.2 Tier Summary

TierTransportDevicesReal-timeOffline Send
1WebSocketiOS, Android, KaiOS, WebYes (NOTIFY)Queue + reconnect
2HTTP pollJ2ME, constrained HTTPNo (polling)Queue + next poll
3SMS notifyAny phone with SMSNotification onlyN/A
4BLE courierAny BLE deviceLocal onlyCourier carries

11.3 Multi-Transport Delivery

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.

In Plain English

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.

Why Four Tiers?

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.

Cross-References

12. Metadata Protection

PR-04 Metadata Minimization

The protocol MUST minimize metadata visible to relays and intermediaries.

PR-06 Payload Padding

Event payloads MUST be padded to fixed bucket sizes before outer encryption to prevent size-based correlation.

12.1 Payload Padding

BucketTypical Content
256 BACKs, revocations, key rotations
1024 BShort presence events (text only)
4096 BLonger messages, carry group invites
16384 BReserved 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

12.2 Blinded Mailbox Identifiers (Level 2 — Deferred)

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.

12.3 Batched Delivery

The relay SHOULD support configurable batch windows (default: 30s) to break timing correlation between event storage and notification.

  • Relays SHOULD inject dummy notifications to maintain minimum batch size.
  • Clients MAY request immediate delivery for latency-sensitive use.

12.4 Techniques Evaluated But Deferred

TechniqueWhy Deferred
Cover trafficHigh bandwidth/battery cost, incompatible with BLE/offline devices
Mixnet relay chainingRequires relay federation, premature for v0.1
Private Information RetrievalO(N) server work per query, infeasible on feature phones

In Plain English

Even with perfect encryption, the relay can learn things from patterns: who messages whom, how often, and message sizes. Metadata protection adds countermeasures:

  • Padding makes all messages look the same size
  • Blinded mailboxes hide who's checking for messages
  • Batching breaks the timing link between send and receive

Pragmatic Trade-offs

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.

Cross-References

External References

13. Threat Model

13.1 Adversaries

AdversaryCapabilitiesMitigations
Relay operatorSees envelopes: recipient, ephemeral key, timestamps, blobGift Wrap hides sender/content. Sealed sender hides submitter. Blinded mailboxes reduce linkage.
Network observerSees TLS-encrypted trafficStandard TLS. Padding prevents size fingerprinting. Batching weakens timing analysis.
Malicious courierHas carried encrypted blobsCannot decrypt. Cannot determine sender. Can see recipient pubkeys.
BLE bystanderIn BLE rangeGeneric service UUID. Handshake requires valid keypair. Rate limiting prevents enumeration.
Malicious recipientDecrypts received eventsBy design. No forward secrecy in this version.
Discovery spooferRegisters fake hash-to-pubkey mappingOOB verification detects. Level 0 contacts marked in UI.
OOB interceptorIntercepts verification URLsPayloads contain only public data. Modification detected by signatures.
Hash brute-forcerObtains pepper, enumerates phone numbersPartial only in v0.1. See hardening roadmap (S8.5).

13.2 What Is NOT Protected

  • Relay delivery graph (recipient side): The relay knows which pubkeys receive events. Sealed sender hides the submitter but the relay still resolves delivery tokens to recipients.
  • Courier recipient set: Couriers know which pubkeys are in their carry group (by design).
  • Traffic analysis: Message timing patterns may leak usage patterns. Partially mitigated by padding and batching.
  • Key compromise: No forward secrecy (no Double Ratchet). Past events are readable if the private key is compromised.
  • Device compromise: Locally stored keys and decrypted events are accessible.

13.3 Trust Assumptions

  • Users trust their device's secure storage (Keychain, Keystore).
  • Users trust the relay for availability but NOT confidentiality.
  • Carry group members trust their courier for liveness but NOT content access.
  • OOB verification trusts the chosen channel to deliver to the intended recipient.

In Plain English

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.

No Forward Secrecy

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.

Cross-References

14. Wire Format Summary

14.1 Outer Envelope (GiftWrap)

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

14.2 Seal

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

14.3 Rumor

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

14.4 Region

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

14.5 Total Overhead Estimate

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.

Size In Context

At ~500 bytes, a PPP event is:

  • Smaller than a single TCP packet (typically 1460 bytes)
  • Transferable in 2-3 BLE notifications (at 185-byte MTU)
  • ~10x smaller than a typical HTTPS request with headers

This compactness is critical for BLE courier delivery and bandwidth-constrained feature phones.

Cross-References

15. Test Vectors

Reference implementations MUST pass these vectors to confirm cross-platform interoperability.

Planned vectors:

  1. Key generation from a known seed.
  2. Gift Wrap encryption of a known rumor, verifiable by a known recipient key.
  3. Event ID derivation from a known GiftWrap.
  4. CHALLENGE-AUTH handshake with known nonce and key pair.
  5. BLE handshake with known nonce and key pair.
  6. Haversine distance check: known lat/lon pair → known distance → inside/outside result for a circle region.
  7. OOB verification: invite generation, accept signature verification, forwarding attack rejection.

Vectors are published as JSON in the test-vectors/ directory with deterministic seeds and nonces for reproducibility.

Why Test Vectors?

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.

Implementation Status

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.

16. Traceability

These requirements derive from the NowHere app spec and brainstorming sessions on cross-platform protocol design.

PPP RequirementNowHere Origin
PR-01, PR-03NFR-02 (Privacy & Security)
PR-04Product Principle: Privacy by default
PR-06, PR-07New: relay metadata protection
IR-01, IR-02Open Decision: migrate from CloudKit
TR-01, TR-05FR-04 (Save + Share Flow), FR-08 (Offline)
CR-01..CR-05New: courier relay concept
DR-01..DR-03FR-07 (Contact Discovery Refresh)
SR-01, SR-02FR-08 (Offline-First Operation)
SR-03FR-09 (Expiration and Cleanup)
SE-01..SE-03NFR-02, security improvements
VR-01..VR-06New: OOB verification

Context

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.

A. Sync & Reliability

SR-01 Offline-First Operation

Clients MUST support creating and queuing events while offline, with automatic delivery when connectivity is restored.

SR-02 Eventual Consistency

The protocol MUST guarantee eventual delivery of events to recipients, provided the relay is reachable within the event's TTL.

  • Relay persists events until acknowledged or expired.
  • Clients re-fetch on reconnection using since timestamp.
  • Duplicate delivery tolerated; clients deduplicate by event ID.
SR-03 Event Expiry

Events MUST carry an expiry timestamp. Relays and couriers MUST discard expired events.

  • expires_at is REQUIRED on all events.
  • Relays purge expired events on periodic schedule.
  • Couriers drop expired events from carry storage.

In Plain English

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.

Cross-References

B. Security Requirements

SE-01 Replay Protection

The protocol MUST prevent replay attacks where a previously valid event is resubmitted.

  • Each event has a unique ID derived from its content hash.
  • Relays reject STORE for already-present event IDs.
  • Clients reject events with already-processed IDs.
SE-03 Enumeration Resistance

The protocol MUST resist attempts to enumerate registered users or stored events.

  • FETCH returns events only for the authenticated pubkey.
  • The relay does not expose a user directory or event count.
  • BLE couriers rate-limit handshake attempts.
  • Discovery queries are rate-limited per DR-03.

In Plain English

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.

Cross-References