Skip to main content

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.

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.

Compatibility Warning

This format is not NIP-17, NIP-44, or NIP-59 compatible.BitChat private envelopes reuse the NIP-17/NIP-59 kind numbers (kind 14, kind 13, kind 1059) for historical reasons, and the encrypted content fields carry a v2: prefix that superficially resembles NIP-44. However, the XChaCha20-Poly1305 cipher, key derivation schedule, nonce layout, and payload structure are all BitChat-specific.A standard NIP-17 or NIP-44 client will fail to decrypt these envelopes. Interoperability exists only between BitChat clients (iOS and Android).

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.
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 key
  • created_at: the actual message timestamp
  • content: the plaintext message content
  • tags: empty (iOS) or a single ["p", recipientPubkey] tag (Android — both shapes are accepted by current parsers)
The rumor is serialized to JSON and encrypted into the seal.
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
The seal itself is then encrypted into the outer gift wrap.
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)
This is the event that is published to Nostr relays.
The decryption path reverses these layers: unwrap the gift wrap (using the recipient’s key), verify the seal signature (authenticating the sender), and open the seal to recover the rumor.

Encryption Construction

Every encrypted content 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:<base64url(24-byte nonce || XChaCha20-Poly1305 ciphertext || 16-byte Poly1305 tag)>
The v2: prefix is a fixed framing marker. The remainder is a base64url-encoded blob with no padding, containing:
  1. A 24-byte random nonce
  2. The XChaCha20-Poly1305 ciphertext (same length as the plaintext)
  3. A 16-byte Poly1305 authentication tag
Key derivation:
shared_secret = secp256k1 ECDH(sender_private_key, recipient_public_key)
symmetric_key = HKDF-SHA256(IKM=shared_secret, salt=<empty>, info="nip44-v2", length=32)
The HKDF 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:
  1. Apply HChaCha20 to the first 16 bytes of the 24-byte nonce and the key to derive a 32-byte subkey
  2. Construct a 12-byte ChaCha20 nonce as [0x00, 0x00, 0x00, 0x00] || last_8_bytes_of_nonce24
  3. Encrypt with CryptoKit ChaChaPoly using the subkey and 12-byte nonce
Per-field encryption: Each 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

FieldVisibility to relays
Outer p tag (recipient pubkey)Visible — relays need this to route the event
Gift wrap signer pubkeyVisible, but this is a one-time throwaway key
Gift wrap timestampVisible, but randomized ±15 minutes
Seal signer pubkey (real sender)Not visible — inside authenticated ciphertext
Seal timestampVisible, but randomized ±15 minutes
Actual message timestampNot visible — encrypted inside the rumor
Message contentNot visible — doubly encrypted
A relay operator can observe: the recipient’s stable Nostr public key, event sizes, the approximate time range when messages were sent, and the IP address of the publishing client (unless Tor is in use).

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’s pubkey 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.
Ciphertext size bound: The receiver enforces a 64 KiB limit on outer envelope ciphertext before any decryption work. This prevents a relay from driving unbounded allocation by publishing a large addressed event.

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 r rendezvous tag
  • Courier drops — these are kind 1401 events carrying an opaque Noise-sealed envelope (a different encryption scheme entirely), published under a throwaway key
Private messages delivered over the BLE mesh use the Noise XX session, not this envelope format. The private envelope is specifically the Nostr-path fallback for mutual favorites when both devices have internet access but are not on the same BLE mesh.

Build docs developers (and LLMs) love