# AeX402 E2EE Specification v3

End-to-end encryption for aexchat direct messages using X3DH key agreement, AES-256-GCM authenticated encryption, and per-message ephemeral keys.

## 1. Overview

AeX402 E2EE provides confidential, authenticated, deniable, and forward-secret messaging between Solana wallets. The protocol operates at the message layer — the server stores and relays opaque ciphertext without access to plaintext or key material.

### 1.1 Properties

| Property | Mechanism | Guarantee |
|----------|-----------|-----------|
| **Confidentiality** | AES-256-GCM | Only sender and recipient can read content |
| **Authentication** | X3DH (DH1: identity × identity) | Messages provably from a known wallet |
| **Forward Secrecy** | Per-message ephemeral X25519 key | Past messages safe if long-term key compromised |
| **Deniability** | Triple DH (DH1 computable by either party) | Neither party can prove the other sent a message |
| **Replay Protection** | Monotonic message counter in KDF | Replayed ciphertext decrypts to wrong key |
| **Integrity** | GCM authentication tag (128-bit) | Tampered ciphertext is rejected |
| **Self-Destruct** | TTL field in wire format | Recipient UI enforces message expiry |

### 1.2 Non-Goals

- **Group encryption**: This spec covers 1:1 DMs only. Group E2EE requires Megolm or similar ratchet (future work).
- **Metadata protection**: Server sees sender/recipient pubkeys, timestamps, and message sizes. Use Tor/mix networks for metadata privacy.
- **Key revocation**: Compromised keys cannot be revoked in this version. Re-derive from a new wallet.

## 2. Cryptographic Primitives

All primitives are from Zig's `std.crypto` (constant-time implementations):

| Primitive | Algorithm | Reference |
|-----------|-----------|-----------|
| Key agreement | X25519 ECDH | RFC 7748 |
| Key conversion | Ed25519 → X25519 (birational map) | RFC 8032 §5.1.5 |
| Authenticated encryption | AES-256-GCM | NIST SP 800-38D |
| Hash / KDF | SHA-256 | FIPS 180-4 |
| Clamping | SHA-512(seed)[0..32] + bit ops | RFC 7748 §5 |

## 3. Key Derivation

### 3.1 Ed25519 Seed → X25519 Private Key

```
expanded = SHA-512(ed25519_seed)   // 64 bytes
expanded[0]  &= 248               // clear low 3 bits
expanded[31] &= 127               // clear high bit
expanded[31] |= 64                // set bit 254
x25519_private = expanded[0..32]
```

### 3.2 Ed25519 Public → X25519 Public

Uses the birational map from Edwards to Montgomery form:
```
u = (1 + y) / (1 - y) mod p       // p = 2^255 - 19
```
Where `y` is the Edwards y-coordinate of the Ed25519 public point.

### 3.3 X3DH Key Agreement

Sender has identity key pair `(isk_s, ipk_s)` and generates ephemeral `(esk, epk)`.
Recipient has identity key pair `(isk_r, ipk_r)`.

```
DH1 = X25519(isk_s, ipk_r)        // sender identity × recipient identity
DH2 = X25519(esk,   ipk_r)        // ephemeral × recipient identity
```

Key material is derived using HKDF-SHA256 (RFC 5869):
```
IKM = DH1 || DH2                          // 64 bytes
PRK = HKDF-Extract(salt="aex402-e2ee-v3", IKM)
info = "msg" || counter_u32LE             // 7 bytes
OKM = HKDF-Expand(PRK, info, 44)         // 44 bytes output
key = OKM[0..32]                           // AES-256 key
nonce = OKM[32..44]                        // deterministic GCM IV
```

The nonce is deterministic (derived from HKDF), not random. This eliminates nonce reuse risk — each unique (DH, counter) pair produces a unique nonce. The nonce field in the wire format stores the derived value for verification.

**Deniability**: DH1 = X25519(isk_s, ipk_r) = X25519(isk_r, ipk_s) by commutativity. Either party can compute DH1 using only public information + their own private key. A third party cannot determine who initiated the exchange.

## 4. Wire Format

```
Offset  Size  Field
0       5     Header: "E2EE:" (ASCII)
5       32    sender_x25519_pub (identity, not ephemeral)
37      32    ephemeral_x25519_pub
69      12    nonce (AES-GCM IV)
81      4     msg_counter (u32 LE, monotonic per peer)
85      4     ttl_secs (u32 LE, 0 = permanent)
89      16    authentication_tag (GCM)
105     N     ciphertext

Total overhead: 105 bytes
Maximum plaintext: 4096 bytes
Transport encoding: base64(wire_bytes) in message body
Message type: "m.e2ee"
```

## 5. Protocol Flow

### 5.1 Encrypt (Sender)

1. Generate 32-byte ephemeral seed from `crypto.getRandomValues()`.
2. Generate 12-byte nonce from `crypto.getRandomValues()`.
3. Read per-peer counter from localStorage, increment and store.
4. Write to WASM xfer_buf: `[recipient_pub][eph_seed][nonce][counter][ttl][plaintext]`.
5. Call `e2ee_encrypt(plaintext_len)` → returns wire-format length.
6. WASM zeroes ephemeral seed after encryption.
7. Base64-encode wire bytes, send as `{ msgtype: "m.e2ee", body: base64 }`.

### 5.2 Decrypt (Recipient)

1. Detect `msgtype: "m.e2ee"` in incoming message.
2. Base64-decode body, check for `"E2EE:"` prefix.
3. Write wire bytes to WASM xfer_buf.
4. Call `e2ee_decrypt(wire_len)` → returns plaintext length (0 = failure).
5. If TTL > 0, schedule message deletion after TTL seconds.

### 5.3 Key Verification

Both parties can verify they share the same keys by comparing **safety numbers**:

```
fp_a = SHA-256(x25519_pub_a)[0..4] as hex  // 8 chars
fp_b = SHA-256(x25519_pub_b)[0..4] as hex  // 8 chars
safety_number = SHA-256(min(fp_a, fp_b) || max(fp_a, fp_b))[0..6] as hex  // 12 chars
```

Both parties compute the same 12-character safety number. Compare out-of-band (in person, phone call, trusted channel). If they differ, a MITM is present.

## 6. Replay Protection

The message counter is a monotonically increasing u32 per sender-recipient pair. It is:
- Included in the KDF (different counter → different AES key).
- Stored in localStorage per peer (`aex_e2ee_ctr_<peer_hex>`).
- Validated on decrypt: `if (counter <= last_seen) reject`.
- Transmitted in cleartext (not secret — only ensures uniqueness).

Attack scenario: An attacker captures ciphertext and replays it later. The recipient has already seen counter=5 and advanced `last_seen` to 5. The replayed message has counter=5 → rejected.

## 7. Self-Destruct (TTL)

The `ttl_secs` field (u32 LE at offset 85) specifies how long the decrypted content should be retained:
- `0` = permanent (no auto-delete).
- `>0` = delete from UI after N seconds post-decryption.

**Enforcement**: TTL is enforced by the recipient's UI, not cryptographically. A malicious client can ignore TTL. This provides convenience privacy, not guaranteed deletion.

## 8. Threat Model

### 8.1 What is Protected

| Attacker | Sees | Cannot See |
|----------|------|------------|
| Server operator | Wire format, sender/recipient pubkeys, timestamps, sizes | Plaintext content, AES keys, ephemeral seeds |
| Network observer | Encrypted traffic | Same as server operator |
| Compromised recipient key (post-hoc) | Nothing from past messages | Ephemeral seeds were zeroed (forward secrecy) |
| Third-party auditor | Wire format | Cannot prove who sent a message (deniability) |

### 8.2 What is NOT Protected

| Threat | Mitigation Required |
|--------|-------------------|
| Compromised device (keylogger) | Hardware security module, secure enclave |
| Metadata analysis | Mix networks, padding, cover traffic |
| Malicious recipient (screenshots) | None — social problem, not cryptographic |
| Key compromise during active session | Limit session duration, re-key periodically |

## 9. Implementation

| Component | File | Language |
|-----------|------|----------|
| Crypto core | `cli/src/shared/e2ee.zig` | Zig |
| WASM exports | `cli/src/wasm/wallet_exports.zig` | Zig |
| JS encrypt/decrypt | `site/dex/js/http.js` | JavaScript |
| Key conversion | `std.crypto.dh.X25519` | Zig stdlib |
| AEAD | `std.crypto.aead.aes_gcm.Aes256Gcm` | Zig stdlib |

### 9.1 Test Suite

```bash
cd cli && zig test src/shared/e2ee.zig
```

9 tests covering: round-trip, wrong recipient, counter replay, TTL metadata, tamper detection, DH commutativity (deniability), safety number symmetry, safety number uniqueness, fingerprint determinism.

## 10. Dual Crypto Stack

AeX402 uses two encryption systems depending on room type:

| Room Type | Members | Crypto | Message Type | Wire Format |
|-----------|---------|--------|--------------|-------------|
| DM (1:1) | 2 | X3DH + AES-256-GCM (Zig WASM) | `m.e2ee` | `E2EE:` prefix, 105 bytes overhead |
| Group | 3+ | Megolm ratchet (server WASM) | `m.megolm` | Megolm session, counter-based |
| Public | any | None | `m.text` | Plaintext |

### 10.1 X3DH Path (DMs)

Used when `room.encrypted && room.member_count <= 2`. Fully stateless — each message does a fresh X3DH. Sender's identity seed is unmasked briefly for DH1.

### 10.2 Megolm Path (Groups)

Used when `room.encrypted && room.member_count >= 3`. The server at `rpc.aex402.com` provides:
- `GET /aexchat/megolm.js` + `/aexchat/megolm.wasm` — compiled ratchet module
- `POST /aexchat/keys/upload` — register device keys + one-time prekeys
- `POST /aexchat/keys/claim` — consume prekeys for session setup (X3DH handshake)
- `POST /aexchat/keys/query` — lookup peer device keys

Megolm operations: `megolm_init` (seed with 128 random bytes), `megolm_advance` (ratchet step), `megolm_derive_message_key` (per-message AES key). Session state maintained per-room in JS memory.

### 10.3 Room Encryption Detection

JS queries `get_chat_room_info(idx)` → returns `encrypted` flag + `member_count`. The `encrypted` flag comes from the server's room state (set at room creation via `visibility: 'private'` or explicitly enabled).

## 11. Self-Destruct (TTL)

The `ttl_secs` field (u32 LE at offset 85) specifies message retention:
- `0` = permanent.
- `>0` = recipient UI zeroes content buffer after N seconds.
- Burned messages show `~ message burned ~` and cannot be recovered from WASM memory.
- Active countdown shown inline: `~30s`, `~5s` (red text).

TTL is enforced by the recipient's UI, not cryptographically. The content buffer is overwritten with zeros (`@memset(&msg.content_buf, 0)`) and `msg.burned = true`.

## 12. Server API

Base URL: `https://rpc.aex402.com/aexchat` (proxied to `rpc.slonana.com`; svm.run is dead as of ~2026-07)

### Auth (no token required)
- `POST /auth/challenge` — `{"wallet":"BASE58"}` → `{"nonce","message","expires_at"}`
- `POST /auth/verify` — `{"wallet","signature","nonce"}` → `{"access_token":"slt_..."}`

### Rooms (Bearer token required)
- `GET /rooms/public` — list public rooms
- `POST /rooms/create` — `{"name","topic","visibility"}`
- `POST /rooms/{id}/join`, `/leave`, `/invite`, `/ban`
- `GET /rooms/{id}/members`, `/state`, `/messages`
- `PUT /rooms/{id}/send` — `{"msgtype","body"}`

### E2EE Key Management (Bearer token required)
- `POST /keys/upload` — `{"device_id","ed25519_key","x25519_key","one_time_keys"}`
- `POST /keys/claim` — `{"targets":[{"user_id","device_id"}]}` → one-time prekeys
- `POST /keys/query` — lookup device keys for users

### Governance
- `POST /gov/propose`, `/vote`, `/finalize`
- `GET /gov/proposals`, `/tally`

### Assets (no auth)
- `GET /megolm.js`, `/megolm.wasm` — Megolm WASM ratchet module
- `GET /app` — web client
- `GET /docs` — Swagger UI (`/openapi.yaml`)

## 13. Security Audit (v3.1)

Issues found in internal review. Rows 1-4 were fixed; 5-9 are dispositions --
a limitation accepted, a mitigation relied on, or an available option. Not fixes.

| # | Severity | Issue | Status |
|---|----------|-------|--------|
| 1 | **CRITICAL** | Raw SHA-256 concatenation as KDF | Replaced with HKDF-SHA256 (extract/expand) |
| 2 | **CRITICAL** | Random nonce — collision risk with same key | Deterministic nonce derived from HKDF (counter guarantees uniqueness) |
| 3 | **CRITICAL** | Empty AAD — header not authenticated | Wire header passed as GCM associated data |
| 4 | **CRITICAL** | Counter=0 replay bypass (`and lsc > 0` clause) | Removed — `counter <= last_seen` is always enforced |
| 5 | HIGH | 48-bit safety number (2^24 birthday attack) | Documented as known limitation. Upgrade path: extend to 128 bits |
| 6 | HIGH | Sender identity pub in cleartext | Documented as design choice (metadata protection is non-goal) |
| 7 | MED | `decryptSimple` skips replay protection | `decrypt()` takes an optional counter; a caller passing null opts out of the check |
| 8 | MED | AES-GCM is not key-committing | Mitigated by ECDH key derivation (attacker can't control both keys) |
| 9 | LOW | Ephemeral seed zeroing unreliable in WASM | Documented — JS memory model prevents guaranteed erasure |

### 13.1 Remaining Limitations

- **No key-committing AEAD**: AES-GCM allows invisible salamanders (craft one ciphertext valid under two keys). Mitigated because keys come from ECDH, not adversarial choice. Future: switch to AES-GCM-SIV or AEGIS.
- **Safety numbers too short**: 48 bits (12 hex chars). Adequate for casual verification, not for high-value targets. Signal uses 60 decimal digits (~200 bits).
- **Sender identity leaked in wire format**: By design. Use Tor if sender metadata privacy is needed.
- **WASM memory not zeroizable**: `@memset` may be optimized out or observable via `ArrayBuffer`. True forward secrecy requires hardware key storage.

## 14. Future Work

- **Megolm decryption** (receive-side session key distribution via `keys/claim`)
- **Double ratchet** for long-lived DM sessions (Signal Protocol-style)
- **Key revocation** (on-chain key rotation announcements)
- **Metadata padding** (fixed-size messages to prevent length analysis)
- **Cross-app interop** (standardized wire format for Solana wallet DMs)
- **Token-gated rooms** (SPL token balance verification for room access)
