How Private Message Encryption Differs Between BitChat's Mesh and Nostr Transports

BitChat uses XChaCha20-Poly1305 for private messages on both transport layers, but derives encryption keys through different key-agreement mechanisms: Curve25519 DH with HKDF for mesh, and raw X25519 DH for Nostr.

BitChat is a permissionless messaging application that supports dual transport layers—local peer-to-peer mesh networking and relay-based Nostr protocol communication. Understanding how private message encryption differs between these transports requires examining the source code in permissionlesstech/bitchat, particularly the transport implementations and key-derivation protocols.

Mesh Transport: Local Key Agreement with HKDF

The mesh transport handles offline, peer-to-peer communication where devices connect directly over Bluetooth or local network interfaces. In bitchat/Protocols/MeshMessageIdentity.swift, BitChat defines a protocol that manages key material for this environment.

Key Material Structure

Each peer maintains a dedicated mesh key pair separate from Nostr identities:

protocol MeshMessageIdentity {
    var meshPublicKey: Data { get }
    var meshPrivateKey: Data { get }
    func deriveSharedSecret(with peerPublicKey: Data) throws -> SymmetricKey
}

These keys are generated locally and never leave the device, unlike Nostr keys which may be backed up or shared across relay connections.

Shared Secret Derivation with HKDF

The mesh transport performs a two-step key derivation. First, it executes a Curve25519 Diffie-Hellman exchange, then strengthens the output with HKDF-SHA256:

extension MeshMessageIdentity {
    func deriveSharedSecret(with peerPublicKey: Data) throws -> SymmetricKey {
        let privateKey = try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: meshPrivateKey)
        let peerPublic = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: peerPublicKey)
        let shared = try privateKey.sharedSecretFromKeyAgreement(with: peerPublic)
        
        return SymmetricKey(data: shared.hkdfDerivedSymmetricKey(
            using: SHA256.self,
            salt: Data(),
            info: Data("bitchat-mesh".utf8),
            outputByteCount: 32
        ))
    }
}

The domain separation string "bitchat-mesh" ensures that mesh encryption keys remain cryptographically isolated from Nostr-derived keys, even if the same underlying Curve25519 private key were somehow reused.

Nostr Transport: Direct X25519 Key Agreement

The Nostr transport in bitchat/Services/NostrTransport.swift provides relay-based messaging compatible with the Nostr protocol ecosystem. It reuses the Nostr identity system managed by NostrIdentityBridge.

Nostr Identity Management

Per bitchat/Services/NostrIdentityBridge.swift, Nostr identities use bech32-encoded keys:

struct NostrIdentity: Codable {
    let npub: String // public key in bech32
    let nsec: String // private key in bech32
    
    var privateKey: Data {
        return try! bech32Decode(nsec) // raw 32 bytes
    }
    var publicKey: Data {
        return try! bech32Decode(npub) // raw 32 bytes
    }
}

Direct X25519 Derivation Without HKDF

The Nostr transport performs a streamlined key agreement suitable for NIP-24 encrypted direct messages and other Nostr-compatible encryption:

func sendMessage(_ message: BitchatMessage, to recipientPubKey: String) async throws {
    // 1. Derive shared secret using sender's private key and recipient's public key
    let senderIdentity = try deps.currentIdentity()
    guard let senderPriv = senderIdentity?.privateKey else { 
        throw TransportError.missingIdentity 
    }
    
    let recipientPubKeyData = try Data(hexString: recipientPubKey)
    let sharedSecret = try X25519.KeyAgreement
        .privateKey(from: senderPriv)
        .sharedSecretFromKeyAgreement(withPeerPublicKey: recipientPubKeyData)
    
    // 2. Encrypt the message payload
    let payload = try message.toBinaryPayload()
    let sealedBox = try XChaCha20Poly1305.seal(payload, using: sharedSecret)
    
    // 3. Create and publish Nostr event
    let event = try NostrEvent(encryptedPayload: sealedBox.combined, 
                               from: senderIdentity!.npub)
    deps.sendEvent(event)
}

Notice the absence of HKDF—the raw X25519 shared secret feeds directly into the encryption algorithm. This design choice aligns with common Nostr encryption patterns and reduces computational overhead for relay communication.

Shared Encryption Layer: BitchatMessage Utilities

Both transports converge on a unified encryption implementation in localPackages/BitFoundation/Sources/BitFoundation/BitchatMessage.swift:

public struct BitchatMessage {
    // MARK: - Encryption helpers (used by transports)
    public static func encrypt(_ payload: Data, sharedSecret: SymmetricKey) throws -> Data {
        let sealedBox = try XChaCha20Poly1305.seal(payload, using: sharedSecret)
        return sealedBox.combined
    }
    
    public static func decrypt(_ sealedData: Data, sharedSecret: SymmetricKey) throws -> Data {
        let sealedBox = try XChaCha20Poly1305.SealedBox(combined: sealedData)
        return try XChaCha20Poly1305.open(sealedBox, using: sharedSecret)
    }
}

The BitchatMessage struct handles binary serialization and delegates to Apple's CryptoKit framework for authenticated encryption. This abstraction allows both transports to share audit-tested cryptographic primitives while maintaining transport-specific key derivation.

Critical Differences at a Glance

Characteristic Mesh Transport Nostr Transport
Key pair type meshPublicKey / meshPrivateKey npub / nsec (bech32 encoded)
Key storage Secure Enclave / local keychain Keychain via NostrIdentityBridge
DH algorithm Curve25519 X25519
Key derivation HKDF-SHA256 with "bitchat-mesh" info Direct (no KDF)
Transport envelope Raw binary over mesh channel Nostr event with encryptedPayload
Relay dependency None (direct P2P) Requires Nostr relay infrastructure

Practical Implementation Example

Encrypting via Mesh Transport

// Assume localIdentity conforms to MeshMessageIdentity
let peerPublicKey: Data = // ... obtain from mesh peer discovery
let sharedKey = try localIdentity.deriveSharedSecret(with: peerPublicKey)

let message = BitchatMessage(
    id: UUID(),
    senderID: myPeerID,
    content: "Offline secure message",
    isPrivate: true,
    recipientID: remotePeerID,
    timestamp: Date()
)

let payload = try message.toBinaryPayload()
let encrypted = try BitchatMessage.encrypt(payload, sharedSecret: sharedKey)

// Transmit encrypted data over local mesh channel
meshChannel.send(encrypted)

Encrypting via Nostr Transport

let transport = NostrTransport(dependencies: deps)

let message = BitchatMessage(
    id: UUID(),
    senderID: myPeerID,
    content: "Relayed secure message",
    isPrivate: true,
    recipientID: remotePeerID,
    timestamp: Date()
)

// Asynchronous relay publication
try await transport.sendMessage(message, to: recipientNpubHex)

Summary

  • Both transports use XChaCha20-Poly1305 from CryptoKit for authenticated encryption, implemented in BitchatMessage.swift.
  • Mesh transport applies HKDF-SHA256 with a protocol-specific info string to derive 32-byte symmetric keys from Curve25519 DH outputs.
  • Nostr transport uses raw X25519 shared secrets without additional key derivation, matching Nostr ecosystem conventions.
  • Key isolation is enforced through separate identity systems: MeshMessageIdentity for local mesh keys and NostrIdentityBridge for relay-compatible Nostr keys.
  • Code reuse is maximized via the BitchatMessage encryption helpers, while transport-specific requirements dictate key derivation differences.

Frequently Asked Questions

Can the same private key be used for both mesh and Nostr transports?

No. BitChat maintains cryptographically separated key hierarchies. The mesh transport uses keys conforming to MeshMessageIdentity while Nostr uses NostrIdentity with bech32 encoding. Even if both internally use Curve25519, the key derivation paths differ—mesh applies HKDF with domain separation, preventing cross-protocol key collisions.

Why does the mesh transport use HKDF when Nostr does not?

The mesh transport's HKDF step with the "bitchat-mesh" info string provides explicit domain separation, ensuring that a compromised mesh session cannot decrypt Nostr messages or vice versa. Nostr omits HKDF for compatibility with existing Nostr client encryption patterns and to minimize relay latency, accepting the trade-off of reduced domain separation.

How does BitChat handle key rotation for private messages?

Per the source analysis, NostrIdentityBridge provides storage and retrieval for Nostr identities, suggesting key rotation would involve generating new NostrIdentity instances. For mesh keys, MeshMessageIdentity is a protocol that implementations can satisfy with rotating key material, though the specific rotation policy depends on the concrete identity provider not shown in the examined files.

Are nonces managed automatically in BitChat's encryption?

Yes. The XChaCha20Poly1305.seal operation in CryptoKit automatically generates cryptographically random nonces. The resulting SealedBox.combined property concatenates nonce, ciphertext, and authentication tag, ensuring safe nonce handling without caller intervention in both NostrTransport.swift and BitchatMessage.swift.

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 →