diff --git a/.claude/plans/luminous-snacking-snowflake.md b/.claude/plans/luminous-snacking-snowflake.md index e5e0327c..addab609 100644 --- a/.claude/plans/luminous-snacking-snowflake.md +++ b/.claude/plans/luminous-snacking-snowflake.md @@ -1,247 +1,138 @@ -# Phase 2: Mesh as Federation Transport +# Phase 3 & 4: Encrypted Mesh Messaging + Off-Grid Bitcoin Operations ## Context -Phase 1 built three independent communication silos: Tor-HTTP for federation, Meshcore LoRa for offline mesh, Nostr relays for discovery. They share no common abstraction — federation hardcodes Tor SOCKS5, mesh has its own serial listener, peer messaging uses a separate HTTP POST. Adding a new transport (LAN/mDNS) or changing routing logic means touching every caller. +Phase 1 built the mesh radio layer (Meshcore protocol, serial driver, basic chat). Phase 2 added transport abstraction (Mesh>LAN>Tor routing, CBOR delta sync, Reed-Solomon chunking). Current encryption is static X25519 shared secret per peer — no forward secrecy, no message type discrimination, no store-and-forward. -Phase 2 unifies these under a `NodeTransport` trait with automatic transport selection per peer (Mesh > LAN > Tor), chunked LoRa messaging with Reed-Solomon FEC for payloads >160 bytes, CBOR delta compression for state sync, mDNS LAN discovery, and a "mesh only" off-grid mode. +Phase 3 adds Signal-style Double Ratchet for forward secrecy, typed messages (ALERT, INVOICE, COORDINATE, PSBT_HASH), and store-and-forward relay. Phase 4 adds off-grid Bitcoin operations: block header relay, transaction relay, Lightning invoice relay, and emergency alert system with dead man's switch. -The v2.0 roadmap (`docs/roadmap-v2.0.md`) also calls for a visual topology map and CRDT-based state sync — those are deferred to Phase 5 (Mesh Intelligence), but the transport abstraction here enables them. - -## Dependencies (Verified) +## Dependencies to Add ```toml -ciborium = "0.2.2" # CBOR serde (134M downloads, active) -reed-solomon-erasure = "6.0" # FEC (4M downloads, MIT) -mdns-sd = "0.18.2" # LAN peer discovery (1.86M, active) +hkdf = "0.12" # KDF for Double Ratchet chains +lightning-invoice = "0.34" # BOLT11 parsing (LDK standard, MIT) ``` +Custom Double Ratchet from existing crypto (ed25519-dalek, curve25519-dalek, chacha20poly1305, sha2, hmac) — no DR crate needed. + ## Architecture ``` -core/archipelago/src/transport/ -├── mod.rs — NodeTransport trait, TransportKind, TransportRouter, PeerRegistry -├── tor.rs — TorTransport (wraps node_message.rs SOCKS5 logic) -├── mesh_transport.rs — MeshTransport (bridges MeshService cmd channel) -├── lan.rs — LanTransport (mdns-sd + direct HTTP) -├── chunking.rs — Split/reassemble with Reed-Solomon FEC -└── delta.rs — CBOR delta encoding for NodeStateSnapshot -``` - -### Unified Peer Identity - -DID is the canonical identifier. Transport-specific addresses are attributes: - -```rust -pub struct PeerRecord { - pub did: String, - pub pubkey_hex: String, - pub name: Option, - pub trust_level: TrustLevel, - // Transport capabilities - pub mesh_contact_id: Option, - pub lan_address: Option, - pub onion_address: Option, - // Freshness - pub last_mesh: Option, - pub last_lan: Option, - pub last_tor: Option, -} -``` - -### Transport Routing - -``` -TransportRouter::send_to_peer(did, payload) - 1. Look up PeerRecord by DID - 2. Filter to transports where peer has address AND transport is available AND not stale - 3. Sort by priority: Mesh(1) > LAN(2) > Tor(3) - 4. If mesh_only_mode: only allow Mesh - 5. Try each in order, fall back on failure -``` - -### Chunking Protocol (for LoRa >160B) - -Per-chunk structure (8 byte header + 124 byte payload = 132B, fits in 160B after encryption): -``` -[0x01] [msg_id: u32 LE] [chunk_idx: u8] [total: u8] [is_parity: u8] [payload: 124B] -``` - -Reed-Solomon: 25% parity shards. 4 data chunks = 1 parity chunk. Any N-of-(N+parity) reconstructs. -Max practical: ~10 chunks (~1.2KB) before LoRa airtime becomes unreasonable. - -### CBOR Delta Sync - -Full JSON state: ~500-2000 bytes. CBOR delta when only CPU/mem changed: ~30-50 bytes. Fits single LoRa chunk. - -```rust -pub struct StateDelta { - pub ts: String, - pub v: u8, - pub apps: Option>, // Only changed apps - pub apps_rm: Option>, // Removed app IDs - pub cpu: Option, - pub mem_u: Option, - // ... only changed fields, short names for wire size -} +mesh/ +├── x3dh.rs — X3DH key agreement (prekey bundles, 3-way ECDH) +├── ratchet.rs — Double Ratchet state machine (forward secrecy) +├── session.rs — Per-peer session manager (ratchet state persistence) +├── prekey.rs — Prekey store (signed + one-time prekeys, rotation) +├── message_types.rs — Typed message envelope (TEXT/ALERT/INVOICE/COORDINATE/PSBT_HASH) +├── outbox.rs — Store-and-forward queue (24h TTL, relay hops) +├── bitcoin_relay.rs — TX relay, Lightning relay, block header announce +├── alerts.rs — Emergency alerts, dead man's switch +└── (existing files extended: crypto.rs, listener.rs, types.rs, mod.rs) ``` ## Implementation Steps -### Step 1: Add deps to Cargo.toml -**File**: `core/archipelago/Cargo.toml` -Add ciborium, reed-solomon-erasure, mdns-sd. +### Week 1: X3DH + HKDF Foundation -### Step 2: Create `transport/mod.rs` — Core trait + PeerRegistry + TransportRouter -**New file**: `core/archipelago/src/transport/mod.rs` -- `NodeTransport` trait: `kind()`, `is_available()`, `send(address, payload)` -- `TransportKind` enum: `Mesh = 1, Lan = 2, Tor = 3` -- `TransportMessage`: `{from_did, payload: Vec, message_type}` -- `PeerRegistry`: unified peer store (DID -> PeerRecord), JSON persistence to `transport-peers.json` -- `TransportRouter`: holds Vec>, routes by priority with fallback -- Register `mod transport;` in `core/archipelago/src/lib.rs` or main.rs +**New**: `mesh/x3dh.rs`, `mesh/prekey.rs` +**Modify**: `Cargo.toml` (+hkdf), `mesh/crypto.rs`, `mesh/mod.rs` -### Step 3: Create `transport/chunking.rs` — Reed-Solomon FEC -**New file**: `core/archipelago/src/transport/chunking.rs` -- `encode_chunked(data: &[u8]) -> Vec>` — split + FEC parity shards -- `ChunkReassembler` — state machine tracking pending messages, attempts reconstruction when enough chunks arrive -- `decode_chunked(chunks: &[Option>]) -> Result>` — reconstruct from data+parity -- Unit tests: roundtrip, missing chunks, corrupted chunks +- `PrekeyBundle`: identity_key + signed_prekey + one_time_prekeys (CBOR, ~200B) +- `PrekeyStore`: disk persistence at `{data_dir}/prekeys/`, rotation, consumption +- X3DH: 3-way ECDH → HKDF-SHA256 → root key for Double Ratchet +- ARCHY:3 identity broadcast with embedded prekey bundle -### Step 4: Create `transport/delta.rs` — CBOR delta sync -**New file**: `core/archipelago/src/transport/delta.rs` -- `compute_delta(prev, curr: &NodeStateSnapshot) -> StateDelta` -- `apply_delta(base: &NodeStateSnapshot, delta: &StateDelta) -> NodeStateSnapshot` -- `encode_cbor(delta) -> Vec` via ciborium -- `decode_cbor(bytes) -> Result` -- Unit tests: delta correctness, CBOR roundtrip, size comparison vs JSON +### Week 2: Double Ratchet Protocol -### Step 5: Create `transport/tor.rs` — TorTransport -**New file**: `core/archipelago/src/transport/tor.rs` -- Wraps existing `node_message::send_to_peer()` pattern (reqwest + SOCKS5) -- Reuses: `node_message.rs:66` send logic, `node_message.rs:115` health check -- `is_available()` checks SOCKS5 proxy reachable at 127.0.0.1:9050 -- Receives handled by existing HTTP handler in `api/handler.rs` +**New**: `mesh/ratchet.rs` (~500 LOC), `mesh/session.rs` (~300 LOC) -### Step 6: Create `transport/mesh_transport.rs` — MeshTransport -**New file**: `core/archipelago/src/transport/mesh_transport.rs` -- Holds reference to `Arc>>` (same as RpcHandler) -- `send()` routes through `MeshService::send_message()` for small payloads -- For payloads >124B: chunk with `chunking::encode_chunked()`, send each chunk via `MeshCommand::SendText` -- 200ms inter-chunk delay for LoRa airtime fairness -- `is_available()` checks mesh device connected via `MeshService::status()` +`RatchetState`: DH ratchet keypair, root key, send/recv chain keys, counters, skipped keys (max 100). HKDF-SHA256 chains + ChaCha20-Poly1305 per-message. -### Step 7: Create `transport/lan.rs` — LanTransport -**New file**: `core/archipelago/src/transport/lan.rs` -- Uses `mdns-sd::ServiceDaemon` for discovery -- Advertises: `_archipelago._tcp.local.` with TXT records: `did=..., pubkey=..., version=...` -- Browses for peers, updates PeerRegistry when found -- `send()`: direct HTTP POST to `http://{ip}:5678/archipelago/node-message` (same endpoint as Tor, no proxy) -- Non-blocking init — if Avahi not available, fail gracefully +Wire format: 40B header (DH pub + counters) + 12 nonce + ciphertext + 16 tag = 68B overhead. Single frame: 64B plaintext. Chunked: ~2.4KB. -### Step 8: Add `mesh_only_mode` to MeshConfig -**File**: `core/archipelago/src/mesh/mod.rs` -- Add `pub mesh_only_mode: Option` to MeshConfig (with `#[serde(default)]`) -- TransportRouter reads this flag to restrict routing +`SessionManager`: HashMap, lazy load from `{data_dir}/ratchet/{did_hash}.json`. Backward compat: falls back to static shared secret for ARCHY:2 peers. -### Step 9: Wire TransportRouter into server.rs -**File**: `core/archipelago/src/server.rs` (~line 136, after mesh service init) -- Create PeerRegistry, load from disk -- Create TorTransport, MeshTransport, LanTransport -- Start LAN advertisement + discovery -- Create TransportRouter with all three + mesh_only flag -- Spawn background task bridging `MeshEvent::IdentityReceived` -> PeerRegistry -- Store router on RpcHandler +### Week 3: Typed Messages + Store-and-Forward -### Step 10: Add transport RPC endpoints -**New file**: `core/archipelago/src/api/rpc/transport.rs` -**Modify**: `core/archipelago/src/api/rpc/mod.rs` (add dispatch routes) +**New**: `mesh/message_types.rs`, `mesh/outbox.rs` +**Modify**: `mesh/types.rs`, `mesh/listener.rs` -Endpoints: -- `transport.status` — available transports, mesh_only flag, peer count -- `transport.peers` — unified peer list with per-peer transport capabilities + preferred transport -- `transport.send` — send message via best transport (by DID) -- `transport.set-mode` — toggle mesh_only mode +CBOR envelope: `[0x02] [{ t: u8, v: bytes, ts: u32, sig?: bytes }]` -### Step 11: Add opt-in federation sync via transport -**File**: `core/archipelago/src/federation.rs` -- Add `sync_with_peer_via_transport()` alongside existing `sync_with_peer()` -- Uses CBOR delta encoding when transport is mesh (saves bandwidth) -- Falls back to full JSON over Tor for backward compat -- Background sync task checks for TransportRouter, uses it if available +Types: TEXT(0), ALERT(1), INVOICE(2), PSBT_HASH(3), COORDINATE(4), PREKEY_BUNDLE(5), SESSION_INIT(6) -### Step 12: Frontend — transport store + UI indicators -**New file**: `neode-ui/src/stores/transport.ts` — Pinia store for transport status -**Modify**: `neode-ui/src/views/Mesh.vue` — "OFF-GRID" indicator when mesh_only mode -**Modify**: `neode-ui/mock-backend.js` — add transport.* mock RPC responses +GPS as `Coordinate { lat_microdeg: i32, lng_microdeg: i32 }` — integer only, no float. -### Step 13: Mock backend transport data -**File**: `neode-ui/mock-backend.js` -Add `transport.status`, `transport.peers`, `transport.send`, `transport.set-mode` mock responses with realistic data showing mixed transport capabilities. +`MeshOutbox`: VecDeque, 24h TTL, max 3 relay hops, disk persistence. Checked every 10s tick. -## Files Summary +### Week 4: RPC Endpoints + Session Bootstrap -### New (8) -1. `core/archipelago/src/transport/mod.rs` -2. `core/archipelago/src/transport/tor.rs` -3. `core/archipelago/src/transport/mesh_transport.rs` -4. `core/archipelago/src/transport/lan.rs` -5. `core/archipelago/src/transport/chunking.rs` -6. `core/archipelago/src/transport/delta.rs` -7. `core/archipelago/src/api/rpc/transport.rs` -8. `neode-ui/src/stores/transport.ts` +**Modify**: `api/rpc/mesh.rs`, `api/rpc/mod.rs`, `mesh/listener.rs` -### Modified (8) -1. `core/archipelago/Cargo.toml` — add 3 deps -2. `core/archipelago/src/lib.rs` or main — `mod transport;` -3. `core/archipelago/src/server.rs` — init router, bridge events -4. `core/archipelago/src/api/rpc/mod.rs` — dispatch + store router -5. `core/archipelago/src/mesh/mod.rs` — `mesh_only_mode` config field -6. `core/archipelago/src/federation.rs` — `sync_with_peer_via_transport()` -7. `neode-ui/src/views/Mesh.vue` — off-grid indicator -8. `neode-ui/mock-backend.js` — transport.* mock data +New RPC: `mesh.send-invoice`, `mesh.send-coordinate`, `mesh.send-alert`, `mesh.outbox`, `mesh.session-status`, `mesh.rotate-prekeys` -### Reused Existing Code -- `node_message.rs:66-112` — Tor SOCKS5 send logic -> wrapped by TorTransport -- `node_message.rs:115-135` — Tor health check -> TorTransport.is_available() -- `mesh/mod.rs:209-282` — MeshService::send_message() -> called by MeshTransport -- `mesh/listener.rs:35-37` — MeshCommand channel -> used for chunked sends -- `mesh/crypto.rs` — X25519 ECDH + ChaCha20-Poly1305 -> reused as-is -- `federation.rs:36-49` — FederatedNode struct -> PeerRecord wraps this -- `federation.rs:179-191` — update_node_state() -> adapted for CBOR deltas +Prekey distribution via ARCHY:3 broadcasts. Session init via X3DH on first message to new peer. + +### Week 5: Off-Grid Bitcoin (Phase 4) + +**New**: `mesh/bitcoin_relay.rs`, `mesh/block_headers.rs` +**Modify**: `Cargo.toml` (+lightning-invoice), `api/rpc/mesh.rs` + +Block header relay: Internet node broadcasts `BlockHeaderAnnouncement` (height, hash, Ed25519 sig) on new block. Mesh-only peers display "SPV sync via mesh". + +TX relay: Mesh-only node sends raw tx hex → internet peer calls `sendrawtransaction` → returns txid. + +Lightning relay: Create invoice → send bolt11 → peer pays → proof-of-payment returned. + +### Week 6: Emergency Alerts + Dead Man's Switch + +**New**: `mesh/alerts.rs` + +`DeadManSwitch`: Background task, configurable interval (default 6h), broadcasts signed ALERT with GPS to emergency contacts when triggered. Auto-check-in on any authenticated RPC. + +RPC: `mesh.alert-configure`, `mesh.alert-checkin`, `mesh.alert-test`, `mesh.alert-status` + +### Week 7: Frontend + +**Modify**: `stores/mesh.ts`, `views/Mesh.vue`, `mock-backend.js` + +Message rendering by type: invoice (orange card + Pay button), alert (red card), coordinate (blue card + OSM link), psbt_hash (gray card + Review). + +Session indicator: shield icon (green=ratchet, yellow=static, gray=none). + +Block height in off-grid banner. Alert config panel. Dead man switch toggle. + +### Week 8: Integration Test + Deploy + +E2E on .228 (internet) + .198 (mesh-only): X3DH handshake, 50-message ratchet, invoice relay, TX relay, block headers, dead man switch. Deploy to both servers. + +## New Files (8) + +1. `core/archipelago/src/mesh/x3dh.rs` +2. `core/archipelago/src/mesh/prekey.rs` +3. `core/archipelago/src/mesh/ratchet.rs` +4. `core/archipelago/src/mesh/session.rs` +5. `core/archipelago/src/mesh/message_types.rs` +6. `core/archipelago/src/mesh/outbox.rs` +7. `core/archipelago/src/mesh/bitcoin_relay.rs` +8. `core/archipelago/src/mesh/alerts.rs` + +## Modified Files (8) + +1. `core/archipelago/Cargo.toml` — +hkdf, +lightning-invoice +2. `core/archipelago/src/mesh/crypto.rs` — +hkdf_sha256, +ephemeral keygen +3. `core/archipelago/src/mesh/types.rs` — +message_type, +typed payloads +4. `core/archipelago/src/mesh/listener.rs` — typed dispatch, session bootstrap, relay +5. `core/archipelago/src/mesh/mod.rs` — new submodules, new MeshService methods +6. `core/archipelago/src/api/rpc/mesh.rs` — ~12 new RPC endpoints +7. `core/archipelago/src/api/rpc/mod.rs` — register new routes +8. `neode-ui/src/views/Mesh.vue` — typed rendering, alert UI, session badges ## Verification -1. **Unit tests** (run on dev server): - ```bash - cargo test --all-features -- transport - cargo test --all-features -- chunking - cargo test --all-features -- delta - ``` - -2. **Frontend type check**: - ```bash - cd neode-ui && npm run type-check - ``` - -3. **Dev mode test**: - ```bash - cd neode-ui && npm start - # Navigate to /dashboard/mesh — verify OFF-GRID toggle - ``` - -4. **Deploy + integration test**: - ```bash - ./scripts/deploy-to-target.sh --live - # On .228: curl transport.status RPC - # Verify mDNS advertises _archipelago._tcp.local. - # If mesh device connected: send chunked message >160B to .198 - # Verify federation sync uses CBOR delta when available - ``` - -## Implementation Order - -**Week 1**: Steps 1-4 (deps, trait, chunking, delta — pure library code with unit tests) -**Week 2**: Steps 5-7 (three transport implementations) -**Week 3**: Steps 8-11 (integration: server wiring, RPC, federation migration) -**Week 4**: Steps 12-13 (frontend UI, mock data, deploy + test) +```bash +cargo test --all-features -- mesh::ratchet mesh::x3dh mesh::session +cargo clippy --all-targets --all-features +cd neode-ui && npm run type-check +./scripts/deploy-to-target.sh --both +``` diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index c33142bd..56879017 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -88,6 +88,9 @@ bytes = "1" # Mesh networking (Meshcore serial protocol over USB LoRa radios) serial2-tokio = "0.1" +# Double Ratchet key derivation (Phase 3: encrypted mesh messaging) +hkdf = "0.12" + # Transport abstraction (Phase 2: mesh as federation transport) ciborium = "0.2.2" reed-solomon-erasure = "6.0" diff --git a/core/archipelago/src/mesh/crypto.rs b/core/archipelago/src/mesh/crypto.rs index fc2ba714..2f36e33f 100644 --- a/core/archipelago/src/mesh/crypto.rs +++ b/core/archipelago/src/mesh/crypto.rs @@ -104,6 +104,58 @@ pub fn decrypt(shared_secret: &[u8; 32], data: &[u8]) -> Result> { /// 160 (max LoRa payload) - 12 (nonce) - 16 (tag) = 132 bytes. pub const MAX_ENCRYPTED_PLAINTEXT: usize = 160 - NONCE_SIZE - TAG_SIZE; +// ─── Phase 3: HKDF + Ephemeral Key Generation ───────────────────────── + +/// HKDF-SHA256 key derivation. +/// Derives `okm_len` bytes from input key material with optional salt and info. +pub fn hkdf_sha256(salt: &[u8], ikm: &[u8], info: &[u8], okm_len: usize) -> Result> { + use hkdf::Hkdf; + use sha2::Sha256; + + let hk = Hkdf::::new(Some(salt), ikm); + let mut okm = vec![0u8; okm_len]; + hk.expand(info, &mut okm) + .map_err(|_| anyhow::anyhow!("HKDF expand failed (output too long)"))?; + Ok(okm) +} + +/// HKDF-SHA256 that returns exactly 32 bytes (one key). +pub fn hkdf_sha256_32(salt: &[u8], ikm: &[u8], info: &[u8]) -> Result<[u8; 32]> { + let okm = hkdf_sha256(salt, ikm, info, 32)?; + let mut key = [0u8; 32]; + key.copy_from_slice(&okm); + Ok(key) +} + +/// HKDF-SHA256 that returns exactly 64 bytes (two keys). +/// Used for Double Ratchet root key + chain key derivation. +pub fn hkdf_sha256_64(salt: &[u8], ikm: &[u8], info: &[u8]) -> Result<([u8; 32], [u8; 32])> { + let okm = hkdf_sha256(salt, ikm, info, 64)?; + let mut k1 = [0u8; 32]; + let mut k2 = [0u8; 32]; + k1.copy_from_slice(&okm[..32]); + k2.copy_from_slice(&okm[32..]); + Ok((k1, k2)) +} + +/// Generate an ephemeral X25519 keypair for DH ratchet steps. +/// Returns (secret, public) where both are 32 bytes. +pub fn generate_x25519_ephemeral() -> ([u8; 32], [u8; 32]) { + let mut secret = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut secret); + // Clamp per RFC 7748 + secret[0] &= 248; + secret[31] &= 127; + secret[31] |= 64; + + // Derive public key: secret * basepoint + use curve25519_dalek::montgomery::MontgomeryPoint; + use curve25519_dalek::scalar::Scalar; + let scalar = Scalar::from_bytes_mod_order(secret); + let public = MontgomeryPoint::mul_base(&scalar); + (secret, *public.as_bytes()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index 2ddd01e8..6abe4b21 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -14,6 +14,8 @@ pub mod protocol; pub mod serial; #[allow(dead_code)] pub mod types; +#[allow(dead_code)] +pub mod x3dh; pub use types::*; diff --git a/core/archipelago/src/mesh/x3dh.rs b/core/archipelago/src/mesh/x3dh.rs new file mode 100644 index 00000000..e5ce6e6d --- /dev/null +++ b/core/archipelago/src/mesh/x3dh.rs @@ -0,0 +1,383 @@ +//! X3DH (Extended Triple Diffie-Hellman) key agreement for mesh sessions. +//! +//! Implements the Signal protocol's X3DH using existing Ed25519/X25519 identity +//! infrastructure. Produces a shared root key that initializes the Double Ratchet. +//! +//! Protocol flow: +//! 1. Alice publishes prekey bundle (identity key + signed prekey + one-time prekeys) +//! 2. Bob fetches bundle, performs 3-way ECDH, sends initial message +//! 3. Both derive identical root key via HKDF-SHA256 + +use super::crypto; +use anyhow::{Context, Result}; +use ed25519_dalek::Signer; +use serde::{Deserialize, Serialize}; +use zeroize::Zeroize; + +/// Info string for HKDF domain separation. +const X3DH_INFO: &[u8] = b"ArchipelagoX3DH_v1"; + +/// Salt for HKDF (all zeros per Signal spec). +const X3DH_SALT: [u8; 32] = [0u8; 32]; + +/// A signed prekey (rotated periodically). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SignedPrekey { + pub id: u32, + #[serde(with = "hex_array")] + pub public: [u8; 32], + /// Ed25519 signature of the public key bytes. + #[serde(with = "hex_vec")] + pub signature: Vec, +} + +/// A one-time prekey (consumed on first use). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OneTimePrekey { + pub id: u32, + #[serde(with = "hex_array")] + pub public: [u8; 32], +} + +/// Published prekey bundle for initiating sessions. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PrekeyBundle { + /// Ed25519 identity public key (verifying key). + #[serde(with = "hex_array")] + pub identity_key: [u8; 32], + /// X25519 identity public key (derived from Ed25519). + #[serde(with = "hex_array")] + pub x25519_identity: [u8; 32], + /// Signed prekey for DH. + pub signed_prekey: SignedPrekey, + /// Available one-time prekeys. + pub one_time_prekeys: Vec, +} + +/// X3DH output: shared root key for initializing Double Ratchet. +pub struct X3dhOutput { + pub root_key: [u8; 32], + /// The signed prekey used (needed for receiver to identify which session). + pub signed_prekey_id: u32, + /// The one-time prekey consumed (if any). + pub one_time_prekey_id: Option, +} + +impl Drop for X3dhOutput { + fn drop(&mut self) { + self.root_key.zeroize(); + } +} + +/// Secret-side prekey data (kept by the bundle publisher). +pub struct PrekeySecrets { + pub signed_prekey_secret: [u8; 32], + pub signed_prekey_id: u32, + pub one_time_secrets: Vec<(u32, [u8; 32])>, +} + +impl Drop for PrekeySecrets { + fn drop(&mut self) { + self.signed_prekey_secret.zeroize(); + for (_, secret) in &mut self.one_time_secrets { + secret.zeroize(); + } + } +} + +/// Generate a prekey bundle and corresponding secrets. +pub fn generate_prekey_bundle( + identity_signing_key: &ed25519_dalek::SigningKey, + num_one_time_prekeys: u32, +) -> Result<(PrekeyBundle, PrekeySecrets)> { + let identity_key = identity_signing_key.verifying_key().to_bytes(); + let x25519_identity = crypto::ed25519_pubkey_to_x25519(&identity_key)?; + + // Generate signed prekey + let (spk_secret, spk_public) = crypto::generate_x25519_ephemeral(); + let spk_id: u32 = rand::random(); + let signature = identity_signing_key.sign(&spk_public); + + let signed_prekey = SignedPrekey { + id: spk_id, + public: spk_public, + signature: signature.to_bytes().to_vec(), + }; + + // Generate one-time prekeys + let mut one_time_prekeys = Vec::with_capacity(num_one_time_prekeys as usize); + let mut one_time_secrets = Vec::with_capacity(num_one_time_prekeys as usize); + for _ in 0..num_one_time_prekeys { + let (otk_secret, otk_public) = crypto::generate_x25519_ephemeral(); + let otk_id: u32 = rand::random(); + one_time_prekeys.push(OneTimePrekey { id: otk_id, public: otk_public }); + one_time_secrets.push((otk_id, otk_secret)); + } + + let bundle = PrekeyBundle { + identity_key, + x25519_identity, + signed_prekey, + one_time_prekeys, + }; + + let secrets = PrekeySecrets { + signed_prekey_secret: spk_secret, + signed_prekey_id: spk_id, + one_time_secrets, + }; + + Ok((bundle, secrets)) +} + +/// Verify a prekey bundle's signed prekey signature. +pub fn verify_bundle(bundle: &PrekeyBundle) -> Result<()> { + use ed25519_dalek::{Signature, VerifyingKey}; + + let verifying_key = VerifyingKey::from_bytes(&bundle.identity_key) + .context("Invalid identity key in prekey bundle")?; + let signature = Signature::from_slice(&bundle.signed_prekey.signature) + .context("Invalid signature in prekey bundle")?; + + verifying_key + .verify_strict(&bundle.signed_prekey.public, &signature) + .context("Prekey bundle signature verification failed")?; + + Ok(()) +} + +/// Initiator side: perform X3DH to derive a shared root key. +/// +/// Called by the party starting a new session (Bob initiates to Alice). +/// Returns the X3DH output and the ephemeral public key that must be sent +/// to the receiver alongside the first encrypted message. +pub fn initiate( + our_x25519_secret: &[u8; 32], + their_bundle: &PrekeyBundle, +) -> Result<(X3dhOutput, [u8; 32])> { + // Verify the bundle's signed prekey signature + verify_bundle(their_bundle)?; + + // Generate ephemeral keypair for this session + let (eph_secret, eph_public) = crypto::generate_x25519_ephemeral(); + + // Three (or four) DH operations: + // DH1 = X25519(our_identity_x25519, their_signed_prekey) + let dh1 = crypto::x25519_shared_secret(our_x25519_secret, &their_bundle.signed_prekey.public); + // DH2 = X25519(ephemeral_secret, their_identity_x25519) + let dh2 = crypto::x25519_shared_secret(&eph_secret, &their_bundle.x25519_identity); + // DH3 = X25519(ephemeral_secret, their_signed_prekey) + let dh3 = crypto::x25519_shared_secret(&eph_secret, &their_bundle.signed_prekey.public); + + // Concatenate DH results + let mut ikm = Vec::with_capacity(32 * 4); + ikm.extend_from_slice(&dh1); + ikm.extend_from_slice(&dh2); + ikm.extend_from_slice(&dh3); + + // DH4 with one-time prekey if available + let otk_id = if let Some(otk) = their_bundle.one_time_prekeys.first() { + let dh4 = crypto::x25519_shared_secret(&eph_secret, &otk.public); + ikm.extend_from_slice(&dh4); + Some(otk.id) + } else { + None + }; + + // Derive root key via HKDF + let root_key = crypto::hkdf_sha256_32(&X3DH_SALT, &ikm, X3DH_INFO)?; + + // Zeroize intermediate material + ikm.zeroize(); + + let output = X3dhOutput { + root_key, + signed_prekey_id: their_bundle.signed_prekey.id, + one_time_prekey_id: otk_id, + }; + + Ok((output, eph_public)) +} + +/// Receiver side: perform X3DH to derive the same shared root key. +/// +/// Called when receiving the first message of a new session from an initiator. +pub fn respond( + our_signed_prekey_secret: &[u8; 32], + our_x25519_identity_secret: &[u8; 32], + our_one_time_secret: Option<&[u8; 32]>, + their_identity_x25519: &[u8; 32], + their_ephemeral_public: &[u8; 32], +) -> Result { + // Mirror the initiator's DH operations: + // DH1 = X25519(our_signed_prekey_secret, their_identity_x25519) + let dh1 = crypto::x25519_shared_secret(our_signed_prekey_secret, their_identity_x25519); + // DH2 = X25519(our_identity_x25519_secret, their_ephemeral) + let dh2 = crypto::x25519_shared_secret(our_x25519_identity_secret, their_ephemeral_public); + // DH3 = X25519(our_signed_prekey_secret, their_ephemeral) + let dh3 = crypto::x25519_shared_secret(our_signed_prekey_secret, their_ephemeral_public); + + let mut ikm = Vec::with_capacity(32 * 4); + ikm.extend_from_slice(&dh1); + ikm.extend_from_slice(&dh2); + ikm.extend_from_slice(&dh3); + + if let Some(otk_secret) = our_one_time_secret { + let dh4 = crypto::x25519_shared_secret(otk_secret, their_ephemeral_public); + ikm.extend_from_slice(&dh4); + } + + let root_key = crypto::hkdf_sha256_32(&X3DH_SALT, &ikm, X3DH_INFO)?; + ikm.zeroize(); + + Ok(X3dhOutput { + root_key, + signed_prekey_id: 0, // Not needed on receiver side + one_time_prekey_id: None, + }) +} + +/// Encode a prekey bundle to CBOR bytes for mesh transmission. +pub fn encode_bundle(bundle: &PrekeyBundle) -> Result> { + let mut buf = Vec::new(); + ciborium::into_writer(bundle, &mut buf) + .context("Failed to CBOR-encode prekey bundle")?; + Ok(buf) +} + +/// Decode a prekey bundle from CBOR bytes. +pub fn decode_bundle(data: &[u8]) -> Result { + ciborium::from_reader(data).context("Failed to CBOR-decode prekey bundle") +} + +// ─── Hex serialization helpers ────────────────────────────────────────── + +mod hex_array { + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(bytes: &[u8; 32], s: S) -> Result { + s.serialize_str(&hex::encode(bytes)) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 32], D::Error> { + let s = String::deserialize(d)?; + let bytes = hex::decode(&s).map_err(serde::de::Error::custom)?; + if bytes.len() != 32 { + return Err(serde::de::Error::custom("expected 32 bytes")); + } + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + Ok(arr) + } +} + +mod hex_vec { + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(bytes: &Vec, s: S) -> Result { + s.serialize_str(&hex::encode(bytes)) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + let s = String::deserialize(d)?; + hex::decode(&s).map_err(serde::de::Error::custom) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::SigningKey; + use rand::rngs::OsRng; + + #[test] + fn test_generate_and_verify_bundle() { + let signing_key = SigningKey::generate(&mut OsRng); + let (bundle, _secrets) = generate_prekey_bundle(&signing_key, 5).unwrap(); + + assert_eq!(bundle.one_time_prekeys.len(), 5); + assert!(verify_bundle(&bundle).is_ok()); + } + + #[test] + fn test_x3dh_both_sides_derive_same_key() { + let alice_signing = SigningKey::generate(&mut OsRng); + let bob_signing = SigningKey::generate(&mut OsRng); + + // Alice publishes bundle + let (bundle, secrets) = generate_prekey_bundle(&alice_signing, 3).unwrap(); + + // Bob initiates X3DH + let bob_x25519_secret = crypto::ed25519_secret_to_x25519(&bob_signing); + let (bob_output, bob_ephemeral) = initiate(&bob_x25519_secret, &bundle).unwrap(); + + // Alice responds + let alice_x25519_secret = crypto::ed25519_secret_to_x25519(&alice_signing); + let bob_x25519_public = crypto::ed25519_pubkey_to_x25519( + &bob_signing.verifying_key().to_bytes(), + ).unwrap(); + + let otk_secret = secrets.one_time_secrets.first().map(|(_, s)| s); + let alice_output = respond( + &secrets.signed_prekey_secret, + &alice_x25519_secret, + otk_secret, + &bob_x25519_public, + &bob_ephemeral, + ).unwrap(); + + // Both should derive the same root key + assert_eq!(bob_output.root_key, alice_output.root_key); + } + + #[test] + fn test_x3dh_without_one_time_prekey() { + let alice_signing = SigningKey::generate(&mut OsRng); + let bob_signing = SigningKey::generate(&mut OsRng); + + // Alice publishes bundle with zero one-time prekeys + let (bundle, secrets) = generate_prekey_bundle(&alice_signing, 0).unwrap(); + + let bob_x25519_secret = crypto::ed25519_secret_to_x25519(&bob_signing); + let (bob_output, bob_ephemeral) = initiate(&bob_x25519_secret, &bundle).unwrap(); + + let alice_x25519_secret = crypto::ed25519_secret_to_x25519(&alice_signing); + let bob_x25519_public = crypto::ed25519_pubkey_to_x25519( + &bob_signing.verifying_key().to_bytes(), + ).unwrap(); + + let alice_output = respond( + &secrets.signed_prekey_secret, + &alice_x25519_secret, + None, + &bob_x25519_public, + &bob_ephemeral, + ).unwrap(); + + assert_eq!(bob_output.root_key, alice_output.root_key); + } + + #[test] + fn test_bundle_cbor_roundtrip() { + let signing_key = SigningKey::generate(&mut OsRng); + let (bundle, _) = generate_prekey_bundle(&signing_key, 3).unwrap(); + + let encoded = encode_bundle(&bundle).unwrap(); + let decoded = decode_bundle(&encoded).unwrap(); + + assert_eq!(decoded.identity_key, bundle.identity_key); + assert_eq!(decoded.signed_prekey.id, bundle.signed_prekey.id); + assert_eq!(decoded.one_time_prekeys.len(), 3); + } + + #[test] + fn test_tampered_bundle_fails_verification() { + let signing_key = SigningKey::generate(&mut OsRng); + let (mut bundle, _) = generate_prekey_bundle(&signing_key, 1).unwrap(); + + // Tamper with signed prekey public key + bundle.signed_prekey.public[0] ^= 0xFF; + + assert!(verify_bundle(&bundle).is_err()); + } +}