# 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 ```python # hearthnet/crypto/kem.py from dataclasses import dataclass @dataclass(frozen=True) class X25519KeyPair: private: bytes # 32 bytes public: bytes # 32 bytes "x25519:" 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 ```python # 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 ```python # 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 ```python # 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 ```python # 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": "" } ↓ 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: 1. Decrypting node returns `e2e_decrypt_failed` via pubsub 2. Sending node sees this and re-initiates X3DH 3. 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 to `E2E_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 ```sql 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 ```sql CREATE TABLE group_sessions ( thread_id TEXT PRIMARY KEY, session_blob BLOB NOT NULL, updated_at INTEGER NOT NULL ); ``` ### 5.3 Prekey private halves ```sql 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_established` - `decrypt_failed` - `out_of_order_too_far` - `message_too_old` - `aad_mismatch` Wire mapping per [CAP2 §9](../CAPABILITY_CONTRACT_v2.md): - `e2e_session_missing` ← `session_not_established` - `e2e_decrypt_failed` ← `decrypt_failed`, `aad_mismatch` - `ratchet_out_of_order` ← `out_of_order_too_far` --- ## 7. Configuration ```python 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_symmetric` - `test_x3dh_initiator_responder_agree` - `test_ratchet_encrypt_decrypt_roundtrip` - `test_ratchet_out_of_order_within_window` - `test_ratchet_out_of_order_too_far_rejected` - `test_rekey_after_n_messages` - `test_group_sender_key_distribution_pairwise_only` - `test_blob_envelope_recipient_only_can_decrypt` ### Integration - `test_two_node_first_message_x3dh_session_persists` - `test_session_recovery_after_disk_wipe` - `test_group_add_member_can_decrypt_subsequent` - `test_group_remove_member_cannot_decrypt_after_rotation` - `test_file_envelope_2_recipients` ### Adversarial - `test_replay_old_ratchet_message_rejected` - `test_modified_ciphertext_decrypt_fails` - `test_one_time_prekey_consumed_once` --- ## 9. Cross-references | What | Where | |------|-------| | `e2e.*` events | [CAP2 §7](../CAPABILITY_CONTRACT_v2.md) | | Encrypted chat body envelope | [CAP2 §1.1, §7.2 chat.message.sent](../CAPABILITY_CONTRACT_v2.md) | | Chat service hook | [M10 ext](../../modules/M10-chat.md) — Phase 2 extension | | Group chat | [M25](M25-group-chat.md) | | File envelope use | [M07 ext](../../modules/M07-file-blobs.md) | | Identity key conversion | [M01](../../modules/M01-identity.md) | --- ## 10. Open questions 1. **Post-quantum readiness.** X25519 + ChaCha20-Poly1305 is not PQ-safe. Hybrid (X25519 + ML-KEM-768) is Phase 3. 2. **Verification of session identity.** Signal does safety numbers; HearthNet can do the same. UI ergonomics deferred. 3. **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. 4. **Forward secrecy on group membership change.** Current spec asks members to rotate sender keys on removal. UX of forcing this needs design. 5. **Cryptographic auditing.** This module should be reviewed by a real cryptographer before going to civil-defence pilots. Listed in `THREAT_MODEL_v2.md`.