Bitchat’s private message format over Nostr is proprietary and app-specific. It was designed to protect message content and sender identity from Nostr relay operators, while remaining simple enough to implement consistently across iOS and Android clients. The design draws structural inspiration from NIP-59 (gift wrapping) but diverges substantially in its encryption construction.Documentation Index
Fetch the complete documentation index at: https://mintlify.com/permissionlesstech/bitchat/llms.txt
Use this file to discover all available pages before exploring further.
Compatibility Warning
Three-Layer Wrapping
Private messages travel in a three-layer nested structure. Each layer adds a cryptographic property, and the layers together ensure that relays cannot learn the stable sender identity or the message content.Layer 1 — Inner Message (kind 14): the rumor
Layer 1 — Inner Message (kind 14): the rumor
The innermost event is an unsigned kind-14 event containing the plaintext message. It is called a “rumor” because it carries no signature of its own — sender authentication comes from the seal that wraps it, not from the rumor itself.The rumor contains:
pubkey: the sender’s real Nostr public keycreated_at: the actual message timestampcontent: the plaintext message contenttags: empty (iOS) or a single["p", recipientPubkey]tag (Android — both shapes are accepted by current parsers)
Layer 2 — Seal (kind 13): sender authentication
Layer 2 — Seal (kind 13): sender authentication
The seal is a signed kind-13 event. It encrypts the serialized rumor JSON and is signed with the sender’s real, stable Nostr private key.This is the critical authentication layer. The seal signature is what proves which key actually sent the message. Without verifying the seal signature, a received DM would be forgeable by anyone who knows the recipient’s public key.The seal:
- Is signed by the sender’s real identity key
- Carries no
tags(an empty tag array is required for validation) - Has a randomized timestamp (±15 minutes from the actual send time)
- Contains the encrypted rumor as its
content
Layer 3 — Gift Wrap (kind 1059): relay anonymization
Layer 3 — Gift Wrap (kind 1059): relay anonymization
The outermost event is a signed kind-1059 event. It encrypts the serialized seal JSON and is signed by a freshly generated one-time ephemeral key that is discarded after use.Because the gift wrap is signed by a throwaway key with no prior history:
- Relays cannot link the gift wrap’s signer to the sender’s stable identity
- The recipient’s public key appears in a
["p", recipientPubkey]tag in plaintext (relays need this to route the event) - The gift wrap also carries a randomized timestamp (±15 minutes)
Encryption Construction
Every encryptedcontent field — both the seal’s encryption of the rumor, and the gift wrap’s encryption of the seal — uses the same construction.
Content format:
v2: prefix is a fixed framing marker. The remainder is a base64url-encoded blob with no padding, containing:
- A 24-byte random nonce
- The XChaCha20-Poly1305 ciphertext (same length as the plaintext)
- A 16-byte Poly1305 authentication tag
info parameter is the string "nip44-v2". This label is retained for wire compatibility with deployed clients but does not indicate NIP-44 compliance. NIP-44 uses this string as a salt in an HKDF-extract step and uses ChaCha20 (not XChaCha20) with per-message key expansion. The BitChat schedule is different from the ground up.
XChaCha20-Poly1305 implementation:
Bitchat implements XChaCha20-Poly1305 via a compatibility wrapper (XChaCha20Poly1305Compat) built on top of Apple’s CryptoKit ChaChaPoly:
- Apply HChaCha20 to the first 16 bytes of the 24-byte nonce and the key to derive a 32-byte subkey
- Construct a 12-byte ChaCha20 nonce as
[0x00, 0x00, 0x00, 0x00] || last_8_bytes_of_nonce24 - Encrypt with CryptoKit
ChaChaPolyusing the subkey and 12-byte nonce
content field is encrypted independently with a fresh 24-byte random nonce. The two encryptions (seal and gift wrap) use different ECDH key pairs and different nonces.
x-only public key handling: Nostr public keys are 32-byte x-only secp256k1 coordinates. The ECDH implementation tries even-Y parity (0x02 prefix) first; if decryption fails, it retries with odd-Y (0x03 prefix).
What Relays See
| Field | Visibility to relays |
|---|---|
Outer p tag (recipient pubkey) | Visible — relays need this to route the event |
| Gift wrap signer pubkey | Visible, but this is a one-time throwaway key |
| Gift wrap timestamp | Visible, but randomized ±15 minutes |
| Seal signer pubkey (real sender) | Not visible — inside authenticated ciphertext |
| Seal timestamp | Visible, but randomized ±15 minutes |
| Actual message timestamp | Not visible — encrypted inside the rumor |
| Message content | Not visible — doubly encrypted |
Security Properties
Authentication: The seal is signed by the sender’s real Nostr private key. A received DM whose seal has an invalid or missing signature is rejected before any content is processed. The rumor’spubkey field must also match the seal signer — mismatched sender claims are explicitly rejected.
Integrity: XChaCha20-Poly1305 is an authenticated encryption scheme. Any tampering with the ciphertext causes decryption to fail.
Relay anonymization: Relays learn the recipient’s public key and approximate send time, but not the sender’s stable identity or message content.
No forward secrecy: The key for each envelope is derived from the recipient’s static Nostr private key via ECDH. If that key is ever compromised, all stored envelopes addressed to it can be decrypted retroactively. There is no per-session ephemeral key agreement on the Nostr path.
The lack of forward secrecy means that any private messages stored on Nostr relays become readable if the recipient’s Nostr private key is ever compromised. This is a documented limitation. A prekey-based scheme providing forward secrecy for the Nostr path is future work. For the full encryption overview including the BLE mesh path (which does provide forward secrecy via Noise XX), see Noise Protocol.
Protocol Scope
The three-layer private envelope format is used only for private direct messages between mutual favorites over the Nostr transport path. It is not used for:- Public location channel messages — these use standard Nostr kind 20000 events signed directly by the per-cell ephemeral identity, with no encryption
- Mesh bridge traffic — public bridge events are signed kind 20000 events with an
rrendezvous tag - Courier drops — these are kind 1401 events carrying an opaque Noise-sealed envelope (a different encryption scheme entirely), published under a throwaway key