feat: Phase 3 Week 1 — X3DH key agreement + HKDF foundation
- Add hkdf = "0.12" dependency for Double Ratchet key derivation
- Extend mesh/crypto.rs with hkdf_sha256, hkdf_sha256_32, hkdf_sha256_64,
and generate_x25519_ephemeral() for DH ratchet steps
- Create mesh/x3dh.rs: full X3DH key agreement protocol
- PrekeyBundle generation with Ed25519-signed prekeys
- 3-way (or 4-way) ECDH → HKDF-SHA256 → root key
- Initiator and responder sides derive identical root key
- CBOR encoding for mesh transmission
- Bundle signature verification
- 5 unit tests: generate+verify, both-sides-same-key,
without-one-time-prekey, cbor-roundtrip, tamper-detection
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d6bf9c5202
commit
1ced0fdbf3
@ -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<String>,
|
||||
pub trust_level: TrustLevel,
|
||||
// Transport capabilities
|
||||
pub mesh_contact_id: Option<u32>,
|
||||
pub lan_address: Option<SocketAddr>,
|
||||
pub onion_address: Option<String>,
|
||||
// Freshness
|
||||
pub last_mesh: Option<String>,
|
||||
pub last_lan: Option<String>,
|
||||
pub last_tor: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
### 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<Vec<AppStatus>>, // Only changed apps
|
||||
pub apps_rm: Option<Vec<String>>, // Removed app IDs
|
||||
pub cpu: Option<f64>,
|
||||
pub mem_u: Option<u64>,
|
||||
// ... 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<u8>, message_type}`
|
||||
- `PeerRegistry`: unified peer store (DID -> PeerRecord), JSON persistence to `transport-peers.json`
|
||||
- `TransportRouter`: holds Vec<Box<dyn NodeTransport>>, 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<Vec<u8>>` — split + FEC parity shards
|
||||
- `ChunkReassembler` — state machine tracking pending messages, attempts reconstruction when enough chunks arrive
|
||||
- `decode_chunked(chunks: &[Option<Vec<u8>>]) -> Result<Vec<u8>>` — 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<u8>` via ciborium
|
||||
- `decode_cbor(bytes) -> Result<StateDelta>`
|
||||
- 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<RwLock<Option<MeshService>>>` (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<bool>` to MeshConfig (with `#[serde(default)]`)
|
||||
- TransportRouter reads this flag to restrict routing
|
||||
`SessionManager`: HashMap<DID, RatchetState>, 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
|
||||
```
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -104,6 +104,58 @@ pub fn decrypt(shared_secret: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
|
||||
/// 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<Vec<u8>> {
|
||||
use hkdf::Hkdf;
|
||||
use sha2::Sha256;
|
||||
|
||||
let hk = Hkdf::<Sha256>::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::*;
|
||||
|
||||
@ -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::*;
|
||||
|
||||
|
||||
383
core/archipelago/src/mesh/x3dh.rs
Normal file
383
core/archipelago/src/mesh/x3dh.rs
Normal file
@ -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<u8>,
|
||||
}
|
||||
|
||||
/// 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<OneTimePrekey>,
|
||||
}
|
||||
|
||||
/// 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<u32>,
|
||||
}
|
||||
|
||||
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<X3dhOutput> {
|
||||
// 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<Vec<u8>> {
|
||||
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<PrekeyBundle> {
|
||||
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<S: Serializer>(bytes: &[u8; 32], s: S) -> Result<S::Ok, S::Error> {
|
||||
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<S: Serializer>(bytes: &Vec<u8>, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_str(&hex::encode(bytes))
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, 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());
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user