File size: 17,296 Bytes
70650b7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 | # 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`.
|