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`.