How BitChat Implements Private Messages over Nostr: Proprietary Three-Layer Protocol Explained

BitChat implements private messages over Nostr by wrapping plaintext rumors in a proprietary three-layer envelope—rumor, seal, and gift-wrap—using ECDH key exchange, HKDF-SHA256 derivation, and XChaCha20-Poly1305 encryption, but it deliberately diverges from standard NIP-17, NIP-44, and NIP-59 specifications.

The permissionlesstech/bitchat repository builds a custom cryptographic stack on top of Nostr to protect direct message metadata and content. While it reuses familiar Nostr event kinds, its payload format, key derivation, and sender-anonymity mechanics are intentionally proprietary and not backward-compatible with standard NIPs. The core logic lives in bitchat/Nostr/NostrProtocol.swift and explicitly rejects interoperability with official Nostr Improvement Proposals as documented in WHITEPAPER.md and SECURITY.md.

BitChat Three-Layer Private Message Envelope

BitChat constructs private messages as nested Nostr events. Each layer adds a distinct privacy property, culminating in a kind-1059 gift-wrap event that relays can route without learning the sender’s stable identity.

Rumor Layer (Kind 14)

The innermost layer is the rumor. It is a standard NostrEvent containing the sender’s public key, a timestamp, and the plaintext message content. Unlike typical Nostr events, the rumor is intentionally unsigned.

Seal Layer (Kind 13)

The seal encrypts the serialized rumor for the recipient and signs it with the sender’s real Schnorr identity key. In NostrProtocol.swift, the createSeal function derives a symmetric key from an ECDH shared secret over secp256k1, encrypts the rumor payload, and produces a kind-13 event.

Gift-Wrap Layer (Kind 1059)

The outermost gift-wrap hides the sender’s identity using a one-time ephemeral key. The createGiftWrap method generates a fresh P256K.Schnorr.PrivateKey, encrypts the seal payload, and signs the outer event with that ephemeral wrap key. The resulting kind-1059 event carries a single p tag pointing to the recipient’s public key and nothing else.

Cryptographic Primitives in NostrProtocol.swift

The encryption pipeline relies on a small set of well-defined cryptographic operations implemented in Swift.

ECDH Shared Secret and Key Derivation

The deriveSharedSecret function converts a Schnorr private key into a KeyAgreement key, reconstructs a compressed secp256k1 public key from the counterparty (testing both even and odd Y when only the X coordinate is supplied), and executes ECDH.

The derivePrivateEnvelopeKey function then feeds that shared secret into HKDF-SHA256 with an empty salt and the info string "nip44-v2". This produces the symmetric key used for the envelope encryption.

XChaCha20-Poly1305 and the v2: Payload Format

BitChat encrypts every layer with XChaCha20-Poly1305 via XChaCha20Poly1305Compat.seal and open. The resulting ciphertext uses a proprietary v2: prefix:

v2:<base64url(nonce24 || ciphertext || tag)>

The nonce is 24 bytes. The authentication tag is 16 bytes. The entire payload is base64url-encoded and prefixed with v2:, signaling the BitChat-only format.

How BitChat Differs from Standard NIPs

BitChat’s design intentionally breaks compatibility with official Nostr private-message standards. The following differences are explicitly acknowledged in the repository’s documentation.

  • Kind numbers and payload layout: BitChat reuses event kinds 13, 14, and 1059 from the NIP-17/NIP-59 family but embeds a proprietary payload layout. Standard NIP-44 introduces its own kind 44 with a different structure.

  • Payload format versioning: BitChat prefixes ciphertext with v2: using a 24-byte nonce and 16-byte tag. NIP-44 uses a v4: prefix with a different HKDF schedule and derivation logic.

  • Key derivation salt and info strings: BitChat calls HKDF-SHA256 with an empty salt and the info string "nip44-v2". NIP-44 derives its conversation key via HKDF-extract using "nip44" as the salt and a different info parameter.

  • Forward secrecy: BitChat does not provide forward secrecy because the shared secret is derived from the recipient’s long-term Nostr key. NIP-44 can be implemented with ephemeral keys that provide forward secrecy.

  • Sender anonymity and relay metadata: The BitChat gift-wrap is signed by an ephemeral key, and the sender’s real identity signature is sealed inside the encrypted payload. Relays see only the recipient’s p tag and randomized timestamps. NIP-17 and NIP-59 may expose the sender’s public key in the outer event or require explicit sender-identity fields.

  • Timestamp randomization: BitChat randomizes the outer event timestamp by ±15 minutes through randomizedTimestamp to prevent timing correlation. The true timestamp is recovered from the decrypted rumor. NIP-44 does not prescribe timestamp randomization.

Sending and Receiving Private Messages in BitChat

The NostrProtocol.swift file exposes high-level methods for creating and consuming private-message envelopes.

Sending a Private Message

let sender = try NostrIdentity(pubkeyHex: "...", privateKeyHex: "...")
let recipientPubkey = "abcdef…"

let event = try NostrProtocol.createPrivateMessage(
    content: "Hello, secret world!",
    recipientPubkey: recipientPubkey,
    senderIdentity: sender
)

Calling createPrivateMessage internally invokes createSeal and then createGiftWrap according to the implementation in NostrProtocol.swift lines 45-94. The returned NostrEvent is a kind-1059 gift-wrap ready for relay publication.

Decrypting a Received Gift-Wrap

let recipient = try NostrIdentity(pubkeyHex: "...", privateKeyHex: "...")
let receivedEvent: NostrEvent = /* fetched from relay */

let (text, senderPubkey, timestamp) = try NostrProtocol.decryptPrivateMessage(
    giftWrap: receivedEvent,
    recipientIdentity: recipient
)

print("From: \(senderPubkey) at \(timestamp): \(text)")

The decryptPrivateMessage function validates the outer envelope, unwraps the gift-wrap, authenticates the seal, opens the seal, and returns the plaintext rumor along with the original sender identity and true timestamp.

Testing Malformed Envelopes

For unit-test verification, the repository includes a debug-only helper that produces an intentionally invalid seal signature:

let brokenEvent = try NostrProtocol.createPrivateMessageWithInvalidSealSignatureForTesting(
    content: "Bad DM",
    recipientPubkey: recipientPubkey,
    senderIdentity: sender
)

This helper is defined in NostrProtocol.swift lines 84-104 under #if DEBUG and is exercised in bitchatTests/NostrProtocolTests.swift.

Summary

  • BitChat implements private messages over Nostr via a nested three-layer envelope: rumor, seal, and gift-wrap.
  • The protocol uses ECDH over secp256k1, HKDF-SHA256 with the info string "nip44-v2", and XChaCha20-Poly1305 for authenticated encryption.
  • The proprietary v2: payload format and kind reuse make the implementation incompatible with NIP-17, NIP-44, and NIP-59.
  • Sender anonymity is achieved by signing the outer gift-wrap with an ephemeral key while hiding the real identity inside the encrypted seal.
  • Outer timestamps are randomized by ±15 minutes to frustrate timing analysis, a property not required by standard NIPs.

Frequently Asked Questions

Is BitChat compatible with NIP-44 private messages?

No. According to WHITEPAPER.md lines 85-87 and SECURITY.md lines 25-27, BitChat’s proprietary private-envelope protocol is explicitly not NIP-17/NIP-44/NIP-59 compatible. The v2: payload prefix, empty-salt HKDF derivation, and proprietary event nesting prevent interoperability with standard Nostr clients.

How does BitChat hide the sender's identity from Nostr relays?

BitChat hides the sender’s stable identity by signing the outer kind-1059 gift-wrap event with a one-time ephemeral Schnorr key. The sender’s real public key appears only inside the encrypted seal payload, which relays cannot read. The outer event exposes only a single p tag containing the recipient’s public key.

What encryption algorithm does BitChat use for direct messages?

BitChat uses XChaCha20-Poly1305 for authenticated encryption with associated data. The symmetric key is derived via HKDF-SHA256 from an ECDH shared secret, and all operations are performed in XChaCha20Poly1305Compat.swift through the seal and open methods.

Does BitChat provide forward secrecy for private messages?

No. BitChat does not implement forward secrecy for private messages over Nostr because the shared secret is derived from the recipient’s long-term secp256k1 key. If the recipient’s private key is compromised, all past messages encrypted to that key can be decrypted. Standard NIP-44 implementations may use ephemeral keys to achieve forward secrecy, but BitChat does not.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →