0penw0rld — E2E Encrypted Messaging over BCH OP_RETURN (CCSH Protocol)

What is 0penw0rld?

0penw0rld is an open-source, self-custody BCH toolkit — a set of standalone HTML apps that run entirely in the browser with zero backend. No accounts, no servers, no tracking.

Live: https://0penw0rld.com

The suite includes:

  • 00 Wallet — Send/receive BCH, Ledger + WalletConnect v2 support
  • 00 Chat — E2E encrypted messaging over OP_RETURN (the CCSH protocol described below)
  • 00 Mesh — Nostr-based group chat with BCH identity
  • 00 ID — On-chain identity registration via Nostr
  • 00 Loan — CashToken lending protocol

All apps connect to user-configurable Fulcrum nodes via WebSocket. Users can plug their own endpoints (Fulcrum, Nostr relays, indexers) through a settings panel — everything is stored in localStorage.


The CCSH Protocol — E2E Encrypted Messaging on BCH

CCSH (Chat Cash) is a binary protocol for embedding end-to-end encrypted messages directly into BCH transactions using OP_RETURN outputs.

Design Goals

  • True E2E encryption — only the recipient can decrypt
  • Forward secrecy — ephemeral X25519 keys per message
  • On-chain transport — messages are embedded in OP_RETURN (max 223 bytes)
  • Multi-chunk support — large messages are split across multiple transactions
  • No servers — the blockchain IS the transport layer

Cryptographic Stack

  1. X25519 ECDH — Key exchange (Curve25519)
  2. SHA-256 KDF — Derive AES key from shared secret
  3. AES-256-GCM — Authenticated symmetric encryption
  4. Ephemeral keys — Fresh X25519 keypair per message for forward secrecy

How It Works

  1. Alice generates a random X25519 keypair (separate from her BCH secp256k1 keys)
  2. Alice publishes her X25519 public key (32 bytes) as her “chat identity”
  3. To send a message to Bob:
    • Generate an ephemeral X25519 keypair
    • ECDH with Bob’s X25519 public key → shared secret
    • SHA-256(shared_secret) → AES-256 key
    • Encrypt plaintext with AES-256-GCM (random 12-byte nonce)
    • Output: ephemeral_pub(32) || nonce(12) || ciphertext+tag
  4. The encrypted blob is chunked into ≤162-byte pieces
  5. Each chunk is wrapped in a CCSH v1 packet (61-byte header)
  6. Each packet is embedded in an OP_RETURN output of a BCH transaction sent to Bob’s BCH address
  7. Bob’s scanner watches his address, detects CCSH magic bytes, reassembles and decrypts

Packet Format (CCSH v1)

Offset  Size  Field
─────────────────────────────────
0       4     MAGIC ("CCSH")
4       1     VERSION (0x01)
5       1     MSG_TYPE
6       1     FLAGS
7       16    MSG_ID (UUID4)
23      32    SENDER_PUB (X25519)
55      2     CHUNK_INDEX (uint16 BE)
57      2     CHUNK_TOTAL (uint16 BE)
59      2     CIPH_LEN (uint16 BE)
61      var   CIPHERTEXT_CHUNK
─────────────────────────────────
Total header: 61 bytes
Max payload per TX: 223 - 61 = 162 bytes

Message Types

Type Value Description
ENCRYPTED_CHUNK 0x01 Standard DM (X25519 encrypted)
ADDR_CHANGE 0x02 BCH address rotation notification
GROUP_MSG 0x03 Group message (AES-256-GCM with shared group key)
GROUP_INVITE 0x04 Group invitation (X25519 encrypted JSON)
GROUP_ADDR_CHANGE 0x05 Group key rotation

Code — Packet Encoding/Decoding

# core/packet_v1.py
from __future__ import annotations
import uuid
from dataclasses import dataclass

MAGIC = b"CCSH"
VERSION = 0x01

MSG_TYPE_ENCRYPTED_CHUNK  = 0x01
MSG_TYPE_ADDR_CHANGE      = 0x02
MSG_TYPE_GROUP_MSG        = 0x03
MSG_TYPE_GROUP_INVITE     = 0x04
MSG_TYPE_GROUP_ADDR_CHANGE = 0x05

SENDER_PUB_LEN = 32     # X25519 raw pubkey
MSG_ID_LEN = 16          # UUID4 bytes
FIXED_HEADER_LEN = 4 + 1 + 1 + 1 + MSG_ID_LEN + SENDER_PUB_LEN + 2 + 2 + 2  # 61 bytes


@dataclass(frozen=True)
class PacketV1:
    msg_id: bytes
    sender_pub: bytes
    chunk_index: int
    chunk_total: int
    ciphertext_chunk: bytes
    msg_type: int = MSG_TYPE_ENCRYPTED_CHUNK
    flags: int = 0


def pack_packet(pkt: PacketV1) -> bytes:
    out = bytearray()
    out += MAGIC
    out += bytes([VERSION])
    out += bytes([pkt.msg_type])
    out += bytes([pkt.flags])
    out += pkt.msg_id
    out += pkt.sender_pub
    out += int(pkt.chunk_index).to_bytes(2, "big")
    out += int(pkt.chunk_total).to_bytes(2, "big")
    out += int(len(pkt.ciphertext_chunk)).to_bytes(2, "big")
    out += pkt.ciphertext_chunk
    return bytes(out)


def unpack_packet(raw: bytes) -> PacketV1:
    if len(raw) < FIXED_HEADER_LEN:
        raise ValueError(f"raw too short: {len(raw)} < {FIXED_HEADER_LEN}")
    if raw[0:4] != MAGIC:
        raise ValueError("bad magic")
    ver = raw[4]
    if ver != VERSION:
        raise ValueError(f"unsupported version: {ver}")

    msg_type = raw[5]
    flags = raw[6]
    pos = 7
    msg_id = raw[pos:pos + MSG_ID_LEN]; pos += MSG_ID_LEN
    sender_pub = raw[pos:pos + SENDER_PUB_LEN]; pos += SENDER_PUB_LEN
    chunk_index = int.from_bytes(raw[pos:pos + 2], "big"); pos += 2
    chunk_total = int.from_bytes(raw[pos:pos + 2], "big"); pos += 2
    clen = int.from_bytes(raw[pos:pos + 2], "big"); pos += 2
    ciphertext_chunk = raw[pos:pos + clen]

    return PacketV1(
        msg_id=bytes(msg_id), sender_pub=bytes(sender_pub),
        chunk_index=chunk_index, chunk_total=chunk_total,
        ciphertext_chunk=bytes(ciphertext_chunk),
        msg_type=msg_type, flags=flags,
    )

Code — X25519 + AES-256-GCM Encryption

# core/crypto.py
import os, hashlib
from cryptography.hazmat.primitives.asymmetric import x25519
from cryptography.hazmat.primitives.ciphers.aead import AESGCM


def derive_key(shared_secret: bytes) -> bytes:
    """SHA-256 KDF: shared_secret -> 32-byte AES key"""
    return hashlib.sha256(bytes(shared_secret)).digest()


def encrypt_for_pubkey(plaintext: bytes, recipient_pub: bytes) -> bytes:
    """
    Encrypt for recipient X25519 pubkey with forward secrecy.
    Output: eph_pub(32) || nonce(12) || ciphertext+tag
    """
    recipient_pubkey = x25519.X25519PublicKey.from_public_bytes(recipient_pub)
    eph_priv = x25519.X25519PrivateKey.generate()
    eph_pub = eph_priv.public_key().public_bytes_raw()
    shared = eph_priv.exchange(recipient_pubkey)
    key = derive_key(shared)
    aes = AESGCM(key)
    nonce = os.urandom(12)
    ciphertext = aes.encrypt(nonce, bytes(plaintext), None)
    return eph_pub + nonce + ciphertext


def decrypt_with_privkey(data: bytes, recipient_priv) -> bytes:
    """Decrypt data produced by encrypt_for_pubkey()."""
    eph_pub = data[:32]
    nonce = data[32:44]
    ciphertext = data[44:]
    eph_pubkey = x25519.X25519PublicKey.from_public_bytes(eph_pub)
    shared = recipient_priv.exchange(eph_pubkey)
    key = derive_key(shared)
    aes = AESGCM(key)
    return aes.decrypt(nonce, ciphertext, None)

Code — OP_RETURN Builder

# core/opreturn.py
def build_op_return(payload: bytes) -> bytes:
    """Build OP_RETURN script (max 223 bytes payload)."""
    if len(payload) > 223:
        raise ValueError("OP_RETURN payload too large (>223)")
    l = len(payload)
    if l <= 75:
        return b"\x6a" + bytes([l]) + payload
    else:
        return b"\x6a" + b"\x4c" + bytes([l]) + payload


def parse_op_return(script: bytes) -> bytes:
    """Parse OP_RETURN script back to payload."""
    if len(script) < 2 or script[0] != 0x6a:
        raise ValueError("Not OP_RETURN")
    op = script[1]
    if op <= 75:
        ln, start = op, 2
    elif op == 0x4c:
        ln, start = script[2], 3
    else:
        raise ValueError(f"Unsupported push opcode: {op:#x}")
    return script[start:start+ln]

Code — High-Level Protocol API

# core/protocol.py
from .packet_v1 import PacketV1, pack_packet, unpack_packet, new_msg_id
from . import chunks, crypto


def encode_message(plaintext, sender_priv_hex, sender_pub_hex,
                   recipient_pub_hex, max_chunk_size=162):
    """plaintext -> encrypt -> chunk -> CCSH v1 packets"""
    recipient_pub = bytes.fromhex(recipient_pub_hex)
    ct_bundle = crypto.encrypt_for_pubkey(plaintext.encode("utf-8"), recipient_pub)
    blob_chunks = chunks.chunk_bytes(ct_bundle, max_chunk_size=max_chunk_size)

    packets = []
    msg_id = new_msg_id()
    sender_pub = bytes.fromhex(sender_pub_hex)
    total = len(blob_chunks)
    for idx, part in enumerate(blob_chunks):
        pkt = PacketV1(msg_id=msg_id, sender_pub=sender_pub,
                       chunk_index=idx, chunk_total=total,
                       ciphertext_chunk=part)
        packets.append(pack_packet(pkt))
    return packets


def decode_packets(packets, recipient_priv_hex):
    """CCSH v1 packets -> reassemble -> decrypt -> plaintext"""
    from cryptography.hazmat.primitives.asymmetric import x25519
    recipient_priv = x25519.X25519PrivateKey.from_private_bytes(
        bytes.fromhex(recipient_priv_hex))

    grouped = {}
    for raw in packets:
        p = unpack_packet(raw)
        if p.msg_type != 0x01: continue
        grouped.setdefault(p.msg_id, []).append(
            (p.chunk_index, p.chunk_total, p.sender_pub, p.ciphertext_chunk))

    decoded = []
    for msg_id, parts in grouped.items():
        parts_sorted = sorted(parts, key=lambda x: x[0])
        sender_pub = parts_sorted[0][2]
        blob = chunks.rebuild_bytes([c for (_, _, _, c) in parts_sorted])
        plaintext_bytes = crypto.decrypt_with_privkey(blob, recipient_priv)
        decoded.append({
            "msg_id": msg_id.hex(),
            "sender_pub_hex": sender_pub.hex(),
            "plaintext": plaintext_bytes.decode("utf-8"),
        })
    return decoded

Transport Layer

Messages are transported as standard BCH transactions:

  • Sender builds a TX with an OP_RETURN output containing the CCSH packet
  • TX is sent to the recipient’s BCH address (dust amount ~546 sats)
  • Recipient’s scanner monitors their address via Fulcrum (ElectrumX) WebSocket
  • On new TX, scanner checks for CCSH magic bytes in OP_RETURN outputs
  • Valid packets are buffered by msg_id until all chunks arrive, then decrypted

The scanner connects to Fulcrum nodes via WebSocket and subscribes to address notifications using the ElectrumX protocol (blockchain.address.subscribe).

WalletConnect v2 Support

Both 00 Wallet and 00 Chat support WalletConnect v2 using the wc2-bch-bcr standard:

  • Namespace: bch:bitcoincash
  • Methods: bch_getAddresses, bch_signTransaction, bch_signMessage
  • Compatible wallets: Paytaca, Cashonize, Zapit

For 00 Chat, WalletConnect handles BCH transaction signing while X25519 encryption keys are generated and stored locally — the external wallet never touches the encryption layer.

Ledger Hardware Wallet Support

00 Wallet also supports Ledger hardware wallets via WebHID. The integration speaks raw APDU commands to the Bitcoin Cash app on the Ledger device — no Ledger Live or intermediary needed. This includes:

  • INS 0x40 — Get public key (with on-device confirmation)
  • INS 0x44/0x4A/0x48 — Hash TX inputs, finalize outputs, sign (BIP143 sighash)

Full Ledger integration code is in the repo at landing/ledger.js.


Try It

Feedback, ideas, and contributions are welcome — the source code will be published soon.

Published : https://github.com/openworldcash/0penw0rld

Tags: #bch-native #e2e-encryption #op-return #walletconnect #open-source

2 Likes

I have some thoughts/feedback about your protocol that I think are worthwhile for you to consider.

Encryption is a constant “cat and mouse” game. What was strong encryption yesterday is weak/broken encryption today. What is strong encryption today is weak/broken encryption tomorrow.
X25519 and AES-256-GCM, while sufficient for encrypting messages today, are both not considered PQC secure algorithms and will likely be broken by advancements in quantum computing in the future. Even algorithms recommended for PQC today by NIST are likely to be broken in the future by future advancements in quantum computing.

When you store messages in OP_RETURN on the blockchain they are stored forever. They will always be a part of the chain. The messages users send today might be private but because they are in the blockchain they will be kept forever and will not be private in the future. Switching to PQC algorithms might seem like a solution to this problem but because they are also likely to be broken in the future.

My suggestion is that you need another medium of storage that does not store the messages forever, otherwise reading someones chat is a simple case of “store now, decrypt later”.

I hope this helps in some way.

2 Likes

It probably is quantum-safe for practical purposes. Reducing it to 2^64 requires interaction and exotic setup, so it’s safe against the “store now, decrypt later”: https://crypto.stackexchange.com/questions/54482/is-gcm-mode-of-authenticated-encryption-quantum-secure

This is elliptic curve cryptography, and we expect it to be the first victim of QCs. However, I think he does not store the shared secrets on chain, so people would be exposed only if the attacker could intercept the ECDH or learn the pubkeys.

@0pen how is the shared secret communicated and where is it stored?

1 Like

hey! so the shared secret is never stored anywhere and never transmitted directly.

each message uses a fresh ephemeral X25519 keypair, the sender generates a random private key, computes the shared secret locally via ECDH with the recipient’s public key, derives the AES-256-GCM key from it (SHA-256 of the shared secret), encrypts the message, and then throws away the ephemeral private key. only the ephemeral PUBLIC key is stored on-chain (in the OP_RETURN header, 32 bytes) so the recipient can recompute the same shared secret on their side.

so what’s on the blockchain is: eph_pubkey(32) || nonce(12) || ciphertext || tag(16) . no shared secret, no recipient pubkey, no private keys. an attacker seing the chain would need to either break X25519 ECDH or know one of the two private keys to derive the shared secret.

and since each message uses a NEW ephemeral key, even if one message’s key is somehow compromised, it doesnt affect any other message. Thats forward secrecy.

for the “store now decrypt later” concern with quantum: yeah if someone records the on-chain data today and breaks X25519 in 20 years, they could theoreticaly decrypt old messages. thats exactly why we designed v2 (split-knowledge mode) : https://github.com/openworldcash/0penw0rld/commit/83932bfbdf92dd6d51abd067620b05009c9bbe80) the message is XOR-split into two pieces, one on-chain and one on an ephemeral nostr relay. even if you break AES+ECDH on the chain piece, you get random garbage because the other half is gone. its information-theoretic security, not computational.

but for v1 specifically: the shared secret lives only in RAM during encrypt/decrypt and is never persisted anywhere.

1 Like