Protocol
Wire formats and key derivation
Naming X3DH and the Double Ratchet proves nothing on its own - a protocol is the composition, not the ingredient list. These are the actual formats and labels from the shipping source, including the two places where the composition is weaker than the primitives.
Everything below is read off the code in the public repository, client and relay alike. Where something is unproven or missing, it is marked rather than omitted.
Wire formats
Session-init envelope
[0x01] [IK_A: 32] [EK_A: 32] [EncryptedMessage]
First message to a contact. Carries the sender identity and ephemeral public keys so a stranger can be decrypted with no prior contact record. Base64-encoded into the Solana memo.
Regular envelope
[0x00] [EncryptedMessage]
Every subsequent message. The session is already established, so no key material rides along.
Prekey bundle
[flags: 1] [IK: 32] [SPK: 32] [SPK signature: 64] (+[OPK: 32] if bit0)
Published to the on-chain registry. Bit 0 signals a one-time prekey. The shipping client always publishes it unset - see the gap below.
Padding buckets
32 · 64 · 128 · 256 · 512 bytes
Plaintext is padded to the next bucket before encryption. The 512 ceiling exists because a larger memo exceeds Solana’s 1232-byte transaction limit.
Key derivation
| Identity derivation | HKDF-SHA256(IKM = phrase, salt = "PrivaMesh-msg-identity-v1", info = label) for labels dhIdentityKey, signingKey, signedPrekey and pqPrekey. |
|---|---|
| Root key ratchet | KDF_RK = HKDF-SHA256(salt = root key, IKM = DH output, info = "PrivaMesh-DR-RK"). |
| Stealth address | address(root, label, index) = HKDF-SHA256(IKM = root, salt = label, info = index) → Ed25519 public key. label fixes the direction, index is a per-direction counter. |
| Payload encryption | AES-256-GCM under the per-message key produced by the symmetric ratchet. |
Relay endpoints
| Endpoint | Body | What it does |
|---|---|---|
| GET /pubkey | — | The blind-signature issuer public key (N, E). The private exponent stays a Worker secret. |
| POST /issue | { jws, blinded } | Verifies an Apple receipt and returns blind signatures. This is where payment is proven - once. |
| POST /send | { tx, token } | Anonymous path. Verifies the token is valid and unspent, co-signs the fee-payer slot, submits. No account is sent. |
| POST /send | { tx, jws, account } | Legacy path, and the only one accepted for publishing a public discovery nickname. |
| POST /credit | { jws, account } | Adds a consumable message pack to an account balance. |
Abuse controls sit in the same Worker: a per-caller sliding-window rate limit defaulting to 20 sends a minute, a global daily cap on sponsored transactions, and atomic single-spend tracking so one token cannot fund two sends. A treasury guard refuses to co-sign any transaction that would debit the treasury rather than only pay its fee.
Where the composition is weaker than the primitives
One-time prekeys are never published. The bundle format reserves a flag and a slot for them, and the client always sends the slot empty. Combined with identity, signed prekey and PQ prekey all being HKDF-derived from the recovery phrase, this means a phrase holder can recompute the X3DH root for the session-opening envelope of every conversation - and those envelopes are on the chain permanently. This is documented as a limitation rather than argued away. Fixing it needs consumed one-time prekeys or a rotating non-deterministic signed prekey.
There is no user-facing key verification. Prekey bundles are signed, so the registry cannot hand out a key that does not belong to the publishing account. But nothing lets you confirm that the account belongs to the person you meant to add. There is no safety number, no fingerprint comparison and no out-of-band check in the shipping app. Against an attacker who can get you to add the wrong account, the signature does not help.
What would actually constitute proof
None of the above is proof, and it is not offered as such. What would be: published test vectors another implementation can verify itself against; a formal or symbolic analysis of this composition rather than of its parts; and an independent audit of the implementation. None of the three exists yet. Until they do, this page is a description you can check against the source, which is a weaker claim than a proof and an honest one.
Frequently asked questions
Are there test vectors?
Not published yet. Wire formats and KDF labels are documented here and readable in the source, but there is no vector file another implementation could check itself against. That is the next thing this page needs.
Has the protocol been formally analysed?
No. The primitives - X3DH, the Double Ratchet, AES-256-GCM, ML-KEM-768 - carry their own analysis. This particular composition of them has had no formal treatment and no independent audit.
Why is naming X3DH and the Double Ratchet not enough?
Because a protocol is the composition, not the ingredient list. Where key material comes from, whether one-time prekeys are consumed, how the transport interacts with retries - those decide whether the properties hold. Two of ours are documented below as open.
Where is the fee worker source?
In the same public repository as the client, under relay/. It is a Cloudflare Worker: blind-token issuance and verification, single-spend tracking, quota and rate limits, and transaction co-signing.
Primary sources
- X3DH key agreement protocolThe specification for the Extended Triple Diffie-Hellman handshake, by Marlinspike and Perrin.
- The Double Ratchet algorithmThe specification for the per-message ratchet that provides forward secrecy and post-compromise security.
- NIST SP 800-38D: Galois/Counter ModeThe NIST recommendation defining AES-GCM, the authenticated encryption PrivaMesh uses to seal payloads.
- Curve25519Bernstein’s elliptic curve, the basis for the key exchange underneath X3DH.
- RFC 5869: HKDFThe key derivation function the ratchet uses to advance chain keys.
- Solana Memo ProgramThe on-chain program whose field carries PrivaMesh ciphertext.