Spaces:
Running on Zero
A newer version of the Gradio SDK is available: 6.19.0
M23 β End-to-End Encryption
Spec version: v1.0 (Phase 2)
Depends on: M01 (identity, key derivation), M07 (blobs, for prekey bundles), X02 (events, for prekey/session events), X04 (config), pynacl
Depended on by: M10 chat (extended), M25 group chat, optionally M07 file encryption
1. Responsibility
Provide end-to-end encryption between community members for:
- 1:1 chat (via M10 extension): every chat message encrypted with a per-sender ratchet
- Group chat (M25): per-thread sender keys
- File envelopes (M07 extension): chunks optionally wrapped in a per-recipient envelope
The cryptographic design borrows from Signal but stays simpler:
- X3DH for initial key agreement (one identity key + one signed prekey + one one-time prekey)
- Double Ratchet for per-session forward-secrecy
- Sender keys (Signal-style) for group threads
- Per-blob envelope for file encryption
This module owns the crypto primitives and session state. M10 and M25 own the message protocol that calls into M23.
2. File layout
hearthnet/crypto/
βββ __init__.py
βββ kem.py # X25519 key agreement (X3DH)
βββ ratchet.py # Double Ratchet, per-session
βββ sender_keys.py # Group sender keys (M25 helper)
βββ envelope.py # File envelope encryption (chunks)
βββ prekeys.py # Prekey bundle storage and publication
The hearthnet/crypto/ directory is a NEW top-level package in Phase 2.
3. Public API
3.1 kem.py β X3DH
# hearthnet/crypto/kem.py
from dataclasses import dataclass
@dataclass(frozen=True)
class X25519KeyPair:
private: bytes # 32 bytes
public: bytes # 32 bytes "x25519:<base64>"
def x25519_generate() -> X25519KeyPair: ...
def x25519_dh(our_priv: bytes, their_pub: bytes) -> bytes:
"""Computes the shared secret. 32 bytes."""
@dataclass(frozen=True)
class PrekeyBundle:
"""What a recipient publishes so senders can establish a session without them being online."""
node_id_full: str
identity_pub: bytes # long-lived; derived from Ed25519 identity via SignedConversion
signed_prekey_pub: bytes
signed_prekey_sig: bytes # Ed25519 signature over signed_prekey_pub by identity Ed25519 key
one_time_prekeys_pub: list[bytes] # depleted on use
published_at: int # unix seconds
def derive_identity_x25519_from_ed25519(ed_kp: KeyPair) -> X25519KeyPair:
"""Use the standard nacl conversion. Single x25519 identity key per device."""
def build_prekey_bundle(
ed_kp: KeyPair,
*,
num_one_time: int = E2E_PREKEY_BUNDLE_SIZE,
) -> tuple[PrekeyBundle, X25519KeyPair, list[X25519KeyPair]]:
"""Returns (bundle, signed_prekey_full, one_time_prekeys_full).
Caller persists the private halves; publishes only the bundle."""
def x3dh_initiator(
our_identity_x: X25519KeyPair,
our_ephemeral: X25519KeyPair,
their_bundle: PrekeyBundle,
) -> tuple[bytes, dict]:
"""Returns (shared_secret, session_init_message).
session_init_message includes our identity_pub + ephemeral_pub + used_otp_index."""
def x3dh_responder(
our_identity_x: X25519KeyPair,
our_signed_prekey: X25519KeyPair,
our_one_time_prekey: X25519KeyPair | None,
their_identity_pub: bytes,
their_ephemeral_pub: bytes,
) -> bytes:
"""Returns shared_secret."""
3.2 ratchet.py β Double Ratchet
# hearthnet/crypto/ratchet.py
@dataclass
class RatchetState:
"""One session, one direction. There are two per session (send + receive)."""
root_key: bytes
chain_key: bytes
counter: int
epoch: int
skipped_messages: dict[tuple[int, int], bytes] # (epoch, counter) β message_key
dh_keypair: X25519KeyPair | None
remote_dh_pub: bytes | None
@dataclass
class RatchetSession:
"""A bidirectional encrypted session between two NodeIDs."""
peer_node_id_full: str
send_state: RatchetState
recv_state: RatchetState
def is_established(self) -> bool: ...
def init_session_initiator(shared_secret: bytes, peer_dh_pub: bytes) -> RatchetSession: ...
def init_session_responder(shared_secret: bytes, our_dh_kp: X25519KeyPair) -> RatchetSession: ...
@dataclass(frozen=True)
class RatchetMessageHeader:
dh_pub: bytes # current sender's DH pub
epoch: int
counter: int
def encrypt_message(
session: RatchetSession,
plaintext: bytes,
*,
aad: bytes = b"",
) -> tuple[RatchetMessageHeader, bytes]:
"""Returns (header, ciphertext). Mutates session.send_state."""
def decrypt_message(
session: RatchetSession,
header: RatchetMessageHeader,
ciphertext: bytes,
*,
aad: bytes = b"",
) -> bytes:
"""Verifies + decrypts. Mutates session.recv_state.
Tolerates up to E2E_RATCHET_MAX_OUT_OF_ORDER out-of-order messages via skipped_messages."""
class RatchetError(Exception):
"""code in {
'session_not_established','decrypt_failed','out_of_order_too_far',
'message_too_old','aad_mismatch'}"""
code: str
# Persistence: sessions are serialised via dataclasses_json into a SQLite table.
3.3 sender_keys.py β Group ratchet
# hearthnet/crypto/sender_keys.py
@dataclass
class SenderKeyState:
"""Per (group, sender) sender-key chain. Each sender broadcasts a chain key
to all group members at thread create / join."""
thread_id: str
sender_node_id: str
chain_key: bytes
counter: int
signature_keypair: tuple[bytes, bytes] | None # ed25519, for message signing
@dataclass
class GroupSession:
"""One per thread; holds all members' SenderKeyStates."""
thread_id: str
sender_keys: dict[str, SenderKeyState] # sender_node_id β state
def init_sender_key(thread_id: str, sender_node_id: str) -> SenderKeyState: ...
def encrypt_for_group(session: GroupSession, sender_node_id: str, plaintext: bytes) -> tuple[dict, bytes]: ...
def decrypt_for_group(session: GroupSession, header: dict, ciphertext: bytes) -> bytes: ...
def serialise_sender_key_distribution(state: SenderKeyState) -> bytes:
"""Serialise a sender key for sending to other group members.
MUST be sent inside a pairwise Double Ratchet session, not in cleartext."""
def consume_sender_key_distribution(bytes_blob: bytes, session: GroupSession) -> None: ...
3.4 envelope.py β File envelope
# hearthnet/crypto/envelope.py
@dataclass(frozen=True)
class FileEnvelopeHeader:
recipient_node_ids: list[str]
wrapped_keys: dict[str, bytes] # node_id_short β wrapped symmetric key
nonce: bytes # 12 bytes
chunk_size_bytes: int
def encrypt_blob_for(
recipients: list[str], # NodeIDs full
plaintext: bytes,
sender_kp: KeyPair,
sessions_provider: Callable[[str], RatchetSession | None],
) -> tuple[FileEnvelopeHeader, bytes]:
"""1. Generate random 32-byte symmetric key
2. For each recipient: encrypt key with their ratchet session (one-shot use of next message key)
3. ChaCha20-Poly1305-encrypt plaintext with the symmetric key + nonce
4. Return (header, ciphertext)"""
def decrypt_blob_for_self(
header: FileEnvelopeHeader,
ciphertext: bytes,
our_node_id_full: str,
sessions_provider: Callable[[str], RatchetSession | None],
) -> bytes:
"""1. Find our wrapped key in header.wrapped_keys
2. Decrypt symmetric key via sender's ratchet session
3. ChaCha20-Poly1305-decrypt"""
3.5 prekeys.py β Publication and consumption
# hearthnet/crypto/prekeys.py
class PrekeyStore:
"""Persists this node's private prekey material and the bundles we've consumed for others."""
def __init__(self, db_path: Path):
...
def publish_self(
self,
ed_kp: KeyPair,
event_log: EventLog,
*,
num_one_time: int = E2E_PREKEY_BUNDLE_SIZE,
) -> None:
"""1. Build PrekeyBundle (kem.build_prekey_bundle)
2. Persist private halves locally
3. Emit e2e.prekeys.published event with public halves"""
def get_peer_bundle(self, peer_node_id_full: str) -> PrekeyBundle | None:
"""Look in local cache; if absent, fetch from peer via bus.
Returns one bundle including one consumable one-time prekey if available."""
def consume_one_time_prekey(self, our_otp_index: int) -> X25519KeyPair | None:
"""Server-side: when someone uses one of our one-time prekeys, return + remove it."""
def refill_one_time_prekeys_if_low(self, ed_kp: KeyPair, event_log: EventLog) -> int:
"""If fewer than E2E_PREKEY_BUNDLE_SIZE / 4 remain, publish a new bundle.
Returns count added."""
4. Behaviour
4.1 Session establishment lifecycle (1:1)
Alice wants to send Bob an encrypted message; no session exists.
β
PrekeyStore.get_peer_bundle("ed25519:bob") β fetch from Bob's most recent e2e.prekeys.published event
β
x3dh_initiator(alice_identity_x, alice_ephemeral, bob_bundle) β (shared_secret, init_msg)
β
init_session_initiator(shared_secret, bob_signed_prekey_pub) β RatchetSession
β
encrypt_message(session, plaintext) β (header, ciphertext)
β
Alice sends: chat.message.sent event with data.body = {
"e2e": true,
"header": { x3dh_init: init_msg, ratchet_header: header },
"ciphertext": "<base64>"
}
β
Bob receives event.
x3dh_responder(...) β shared_secret
init_session_responder(...) β RatchetSession
decrypt_message(...) β plaintext
Emit e2e.session.established event so Alice can clean up retries
β
Subsequent messages: just header + ciphertext (no x3dh_init).
4.2 Group session establishment
Thread creator emits chat.thread.created with members and an ed25519:thread_signing_root.
β
Each member generates a SenderKeyState for themselves in this thread.
β
Each member, in a pairwise loop, sends their sender key distribution to each other member
inside their 1:1 ratchet sessions (so non-thread-members never see it).
β
Once everyone has everyone's sender keys, encrypt/decrypt happens with sender keys
(chain ratchet only; no DH ratchet on the group session itself).
When a member is added later, the inviter must re-distribute all existing senders' current chain states to the new member (rewinds to the message they should start being able to read β usually the current state, not history).
When a member is removed, existing sender keys are still known to them. All members must rotate their sender keys to achieve forward secrecy after removal. UI prompts this.
4.3 Out-of-order messages
Up to E2E_RATCHET_MAX_OUT_OF_ORDER (32) skipped message keys are cached per session. Beyond that, out_of_order_too_far is raised; the message is dropped and the sender notified (out-of-band) that they should rekey.
4.4 Rekeying
After E2E_RATCHET_REKEY_AFTER_MESSAGES messages on the same DH ratchet, the next message includes a new DH ephemeral. Standard Double Ratchet behaviour. Transparent to users.
4.5 Session loss recovery
If a node's session state is lost (disk corruption, fresh install with same keys), the peer doesn't know β messages will fail to decrypt. Recovery flow:
- Decrypting node returns
e2e_decrypt_failedvia pubsub - Sending node sees this and re-initiates X3DH
- New session replaces old; resends recent messages
UI shows "session was reset" so users know context might have been lost.
4.6 Identity X25519 derivation
We derive a per-device X25519 identity key from the Ed25519 identity key, using libsodium's crypto_sign_ed25519_pk_to_curve25519. This way:
- Only one identity key to maintain
- Anyone with the public Ed25519 (in the community manifest) can derive the X25519 pub
- Signed prekey signatures use the Ed25519 key (already established as device identity)
4.7 Prekey publication
Each node publishes a fresh e2e.prekeys.published event on startup if their last one is > 24h old. The event contains:
identity_pubkey(X25519 form)signed_prekey(with Ed25519 signature)one_time_prekeys[](up toE2E_PREKEY_BUNDLE_SIZE= 20)
Consumers find a peer's bundle by reading their latest e2e.prekeys.published event from the log.
4.8 What is NOT E2E
Even with M23 active:
- Event envelope (sender, recipient, lamport, event_type, wall_clock) is cleartext within the community
- Signatures over events remain valid for community-level audit
- Message metadata leaks to community members (who talked to whom and when), just not content
This is intentional: communities are trust roots; complete anonymity within a community is not a goal.
4.9 File envelope
For file blobs, encrypt_blob_for(recipients, plaintext, ...) produces a single ciphertext, with a small per-recipient header. Senders pick recipients explicitly (e.g. group thread members for an attachment). Bystanders cannot decrypt even if they fetch the blob via M07 file.read.
The blob's CID is the hash of the ciphertext, so the same plaintext sent to different recipient sets has different CIDs. Costs more storage; needed for security.
5. Persistence
5.1 Sessions table
CREATE TABLE ratchet_sessions (
peer_node_id_full TEXT PRIMARY KEY,
session_blob BLOB NOT NULL, -- serialised RatchetSession
established_at INTEGER NOT NULL,
last_used INTEGER NOT NULL
);
5.2 Group sessions table
CREATE TABLE group_sessions (
thread_id TEXT PRIMARY KEY,
session_blob BLOB NOT NULL,
updated_at INTEGER NOT NULL
);
5.3 Prekey private halves
CREATE TABLE prekey_private (
kind TEXT NOT NULL, -- 'identity'|'signed_prekey'|'one_time'
index_or_id TEXT NOT NULL, -- '0' for identity; 'spk_v1' for signed; otp index for OTPs
private_key BLOB NOT NULL,
consumed_at INTEGER, -- only set for one-time, when used
PRIMARY KEY (kind, index_or_id)
);
Files locked at 0600. Backed up nightly via hearthnet export (encrypted with user passphrase).
6. Errors
RatchetError codes (M23-internal):
session_not_establisheddecrypt_failedout_of_order_too_farmessage_too_oldaad_mismatch
Wire mapping per CAP2 Β§9:
e2e_session_missingβsession_not_establishede2e_decrypt_failedβdecrypt_failed,aad_mismatchratchet_out_of_orderβout_of_order_too_far
7. Configuration
config.e2e.enabled = True
config.e2e.chat_default_enabled = True # new 1:1 chats default to E2E
config.e2e.group_default_enabled = True
config.e2e.file_default_enabled = False # opt-in per blob
config.e2e.prekey_refill_count = E2E_PREKEY_BUNDLE_SIZE
config.e2e.rekey_after_messages = E2E_RATCHET_REKEY_AFTER_MESSAGES
config.e2e.max_out_of_order = E2E_RATCHET_MAX_OUT_OF_ORDER
8. Tests
Unit
test_x25519_dh_symmetrictest_x3dh_initiator_responder_agreetest_ratchet_encrypt_decrypt_roundtriptest_ratchet_out_of_order_within_windowtest_ratchet_out_of_order_too_far_rejectedtest_rekey_after_n_messagestest_group_sender_key_distribution_pairwise_onlytest_blob_envelope_recipient_only_can_decrypt
Integration
test_two_node_first_message_x3dh_session_persiststest_session_recovery_after_disk_wipetest_group_add_member_can_decrypt_subsequenttest_group_remove_member_cannot_decrypt_after_rotationtest_file_envelope_2_recipients
Adversarial
test_replay_old_ratchet_message_rejectedtest_modified_ciphertext_decrypt_failstest_one_time_prekey_consumed_once
9. Cross-references
| What | Where |
|---|---|
e2e.* events |
CAP2 Β§7 |
| Encrypted chat body envelope | CAP2 Β§1.1, Β§7.2 chat.message.sent |
| Chat service hook | M10 ext β Phase 2 extension |
| Group chat | M25 |
| File envelope use | M07 ext |
| Identity key conversion | M01 |
10. Open questions
- Post-quantum readiness. X25519 + ChaCha20-Poly1305 is not PQ-safe. Hybrid (X25519 + ML-KEM-768) is Phase 3.
- Verification of session identity. Signal does safety numbers; HearthNet can do the same. UI ergonomics deferred.
- Multi-device per identity. If a user has anchor + mobile + laptop, do they share keys or have separate ones? Currently separate (each device is a separate NodeID; group threads include all of them). Could unify with a "linked devices" Phase 3 feature.
- Forward secrecy on group membership change. Current spec asks members to rotate sender keys on removal. UX of forcing this needs design.
- Cryptographic auditing. This module should be reviewed by a real cryptographer before going to civil-defence pilots. Listed in
THREAT_MODEL_v2.md.