| # 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:<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 |
|
|
| ```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": "<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: |
|
|
| 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`. |
|
|