From ad636b874b7cce49030ee357e5a2f6356d3ab689 Mon Sep 17 00:00:00 2001 From: Christian Nennemann Date: Wed, 1 Apr 2026 16:34:05 +0200 Subject: [PATCH] feat(fapp): add E2E encryption for SlotReserve/SlotConfirm - E2E crypto using X25519 key exchange + ChaCha20-Poly1305 - PatientEphemeralKey: generates keypair for reservation - TherapistCrypto: decrypts reserves, creates confirms with FS - PatientCrypto: creates reserves, decrypts confirmations - Wire format helpers for Reserve/Confirm CBOR serialization FappRouter updates: - Added DeliverReserve/DeliverConfirm action variants - process_slot_reserve(): routes to therapist or floods - process_slot_confirm(): delivers to patient - send_reserve/send_confirm(): capability-checked sends - send_response(): relay-to-patient response routing FappStore additions: - announces_iter(): iterate all announce vectors - find_by_id(): lookup announce by ID 29 FAPP tests passing (24 fapp + 7 fapp_router + 5 new E2E crypto) --- crates/quicprochat-p2p/src/fapp.rs | 440 ++++++++++++++++++++++ crates/quicprochat-p2p/src/fapp_router.rs | 266 ++++++++++++- 2 files changed, 701 insertions(+), 5 deletions(-) diff --git a/crates/quicprochat-p2p/src/fapp.rs b/crates/quicprochat-p2p/src/fapp.rs index e69630c..726c827 100644 --- a/crates/quicprochat-p2p/src/fapp.rs +++ b/crates/quicprochat-p2p/src/fapp.rs @@ -408,6 +408,254 @@ pub struct SlotConfirm { pub confirmed: bool, /// Appointment details, encrypted to patient's ephemeral key. pub encrypted_details: Vec, + /// Therapist's ephemeral key for this confirmation (for forward secrecy). + pub therapist_ephemeral_key: [u8; 32], +} + +// --------------------------------------------------------------------------- +// E2E Encryption for SlotReserve / SlotConfirm +// --------------------------------------------------------------------------- + +use chacha20poly1305::aead::{Aead, KeyInit}; +use chacha20poly1305::{ChaCha20Poly1305, Nonce}; +use hkdf::Hkdf; +use rand::rngs::OsRng; +use rand::RngCore; +use x25519_dalek::{EphemeralSecret, PublicKey as X25519Public, StaticSecret}; + +/// Patient's ephemeral keypair for a reservation. +/// +/// The secret is used once to derive the shared secret with the therapist, +/// then should be securely stored until confirmation is received. +pub struct PatientEphemeralKey { + secret: StaticSecret, + public: X25519Public, +} + +impl PatientEphemeralKey { + /// Generate a new ephemeral keypair. + pub fn generate() -> Self { + let secret = StaticSecret::random_from_rng(OsRng); + let public = X25519Public::from(&secret); + Self { secret, public } + } + + /// Get the public key bytes (sent in SlotReserve). + pub fn public_bytes(&self) -> [u8; 32] { + self.public.to_bytes() + } + + /// Derive a shared secret with the therapist's confirmation key. + pub fn derive_shared(&self, therapist_ephemeral: &[u8; 32]) -> [u8; 32] { + let their_public = X25519Public::from(*therapist_ephemeral); + let shared = self.secret.diffie_hellman(&their_public); + derive_fapp_key(shared.as_bytes(), b"fapp-confirm-v1") + } +} + +/// Therapist's encryption context for handling reservations. +pub struct TherapistCrypto { + /// Long-term identity (Ed25519, but we derive X25519 for encryption). + identity: MeshIdentity, +} + +impl TherapistCrypto { + pub fn new(identity: MeshIdentity) -> Self { + Self { identity } + } + + /// Decrypt a SlotReserve's encrypted_contact field. + /// + /// Uses the therapist's identity key (converted to X25519) and the + /// patient's ephemeral public key. + pub fn decrypt_reserve(&self, reserve: &SlotReserve) -> Result, FappCryptoError> { + // Derive X25519 secret from Ed25519 seed + let x25519_secret = self.derive_x25519_secret(); + let patient_public = X25519Public::from(reserve.patient_ephemeral_key); + let shared = x25519_secret.diffie_hellman(&patient_public); + let key = derive_fapp_key(shared.as_bytes(), b"fapp-reserve-v1"); + + decrypt_aead(&key, &reserve.encrypted_contact) + } + + /// Create an encrypted SlotConfirm response. + /// + /// Generates a fresh ephemeral key for forward secrecy. + pub fn create_confirm( + &self, + slot_announce_id: [u8; 16], + slot_index: u16, + confirmed: bool, + details: &[u8], + patient_ephemeral: &[u8; 32], + ) -> Result { + // Generate ephemeral key for this confirmation + let eph_secret = StaticSecret::random_from_rng(OsRng); + let eph_public = X25519Public::from(&eph_secret); + + // Derive shared secret with patient's ephemeral key + let patient_public = X25519Public::from(*patient_ephemeral); + let shared = eph_secret.diffie_hellman(&patient_public); + let key = derive_fapp_key(shared.as_bytes(), b"fapp-confirm-v1"); + + let encrypted_details = encrypt_aead(&key, details)?; + + Ok(SlotConfirm { + slot_announce_id, + slot_index, + confirmed, + encrypted_details, + therapist_ephemeral_key: eph_public.to_bytes(), + }) + } + + /// Derive X25519 secret from Ed25519 identity. + /// + /// Uses the Ed25519 seed directly as X25519 secret (standard conversion). + fn derive_x25519_secret(&self) -> StaticSecret { + let seed = self.identity.seed_bytes(); + StaticSecret::from(seed) + } + + /// Get the X25519 public key corresponding to our identity. + pub fn x25519_public(&self) -> [u8; 32] { + let secret = self.derive_x25519_secret(); + let public = X25519Public::from(&secret); + public.to_bytes() + } +} + +/// Patient's decryption context for handling confirmations. +pub struct PatientCrypto { + ephemeral: PatientEphemeralKey, +} + +impl PatientCrypto { + pub fn new(ephemeral: PatientEphemeralKey) -> Self { + Self { ephemeral } + } + + /// Create an encrypted SlotReserve message. + /// + /// `therapist_x25519_public` is the therapist's X25519 public key + /// (derived from their Ed25519 identity). + pub fn create_reserve( + &self, + slot_announce_id: [u8; 16], + slot_index: u16, + contact_info: &[u8], + therapist_x25519_public: &[u8; 32], + ) -> Result { + let their_public = X25519Public::from(*therapist_x25519_public); + let shared = self.ephemeral.secret.diffie_hellman(&their_public); + let key = derive_fapp_key(shared.as_bytes(), b"fapp-reserve-v1"); + + let encrypted_contact = encrypt_aead(&key, contact_info)?; + + Ok(SlotReserve { + slot_announce_id, + slot_index, + patient_ephemeral_key: self.ephemeral.public_bytes(), + encrypted_contact, + }) + } + + /// Decrypt a SlotConfirm response. + pub fn decrypt_confirm(&self, confirm: &SlotConfirm) -> Result, FappCryptoError> { + let shared = self.ephemeral.derive_shared(&confirm.therapist_ephemeral_key); + decrypt_aead(&shared, &confirm.encrypted_details) + } +} + +/// Errors during FAPP E2E encryption/decryption. +#[derive(Debug, thiserror::Error)] +pub enum FappCryptoError { + #[error("encryption failed")] + EncryptionFailed, + #[error("decryption failed: invalid ciphertext or authentication tag")] + DecryptionFailed, + #[error("ciphertext too short")] + CiphertextTooShort, +} + +/// Derive a 32-byte key from a shared secret using HKDF-SHA256. +fn derive_fapp_key(shared_secret: &[u8], info: &[u8]) -> [u8; 32] { + let hk = Hkdf::::new(None, shared_secret); + let mut key = [0u8; 32]; + hk.expand(info, &mut key) + .expect("HKDF expand to 32 bytes should never fail"); + key +} + +/// Encrypt with ChaCha20-Poly1305 using a random nonce. +/// +/// Returns: nonce (12 bytes) || ciphertext || tag (16 bytes) +fn encrypt_aead(key: &[u8; 32], plaintext: &[u8]) -> Result, FappCryptoError> { + let cipher = ChaCha20Poly1305::new(key.into()); + + let mut nonce_bytes = [0u8; 12]; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + + let ciphertext = cipher + .encrypt(nonce, plaintext) + .map_err(|_| FappCryptoError::EncryptionFailed)?; + + let mut out = Vec::with_capacity(12 + ciphertext.len()); + out.extend_from_slice(&nonce_bytes); + out.extend_from_slice(&ciphertext); + Ok(out) +} + +/// Decrypt with ChaCha20-Poly1305. +/// +/// Expects: nonce (12 bytes) || ciphertext || tag (16 bytes) +fn decrypt_aead(key: &[u8; 32], data: &[u8]) -> Result, FappCryptoError> { + if data.len() < 12 + 16 { + return Err(FappCryptoError::CiphertextTooShort); + } + + let nonce = Nonce::from_slice(&data[..12]); + let ciphertext = &data[12..]; + + let cipher = ChaCha20Poly1305::new(key.into()); + cipher + .decrypt(nonce, ciphertext) + .map_err(|_| FappCryptoError::DecryptionFailed) +} + +// --------------------------------------------------------------------------- +// SlotReserve / SlotConfirm wire format helpers +// --------------------------------------------------------------------------- + +impl SlotReserve { + /// Serialize to CBOR for wire transmission. + pub fn to_wire(&self) -> Vec { + let mut buf = Vec::new(); + ciborium::into_writer(self, &mut buf).expect("CBOR serialization should not fail"); + buf + } + + /// Deserialize from CBOR. + pub fn from_wire(bytes: &[u8]) -> anyhow::Result { + let reserve: Self = ciborium::from_reader(bytes)?; + Ok(reserve) + } +} + +impl SlotConfirm { + /// Serialize to CBOR for wire transmission. + pub fn to_wire(&self) -> Vec { + let mut buf = Vec::new(); + ciborium::into_writer(self, &mut buf).expect("CBOR serialization should not fail"); + buf + } + + /// Deserialize from CBOR. + pub fn from_wire(bytes: &[u8]) -> anyhow::Result { + let confirm: Self = ciborium::from_reader(bytes)?; + Ok(confirm) + } } // --------------------------------------------------------------------------- @@ -567,6 +815,23 @@ impl FappStore { .map(|v| v.as_slice()) .unwrap_or_default() } + + /// Iterate over all announce vectors (for lookups by announce ID). + pub fn announces_iter(&self) -> impl Iterator> { + self.announces.values() + } + + /// Find a SlotAnnounce by its ID. + pub fn find_by_id(&self, id: &[u8; 16]) -> Option<&SlotAnnounce> { + for announces in self.announces.values() { + for announce in announces { + if &announce.id == id { + return Some(announce); + } + } + } + None + } } impl Default for FappStore { @@ -1124,4 +1389,179 @@ mod tests { assert_eq!(store.get_therapist(&addr).len(), 1); assert!(store.get_therapist(&[0xFF; 16]).is_empty()); } + + // ----------------------------------------------------------------------- + // E2E Crypto Tests + // ----------------------------------------------------------------------- + + #[test] + fn e2e_reserve_confirm_roundtrip() { + // Therapist setup + let therapist_id = test_identity(); + let therapist_crypto = TherapistCrypto::new(therapist_id); + + // Patient setup + let patient_ephemeral = PatientEphemeralKey::generate(); + let patient_crypto = PatientCrypto::new(patient_ephemeral); + + // Create SlotReserve + let slot_announce_id = [0xAA; 16]; + let contact_info = b"patient@example.com, Phone: 0123456789"; + let reserve = patient_crypto + .create_reserve( + slot_announce_id, + 0, + contact_info, + &therapist_crypto.x25519_public(), + ) + .expect("create reserve"); + + assert_eq!(reserve.slot_announce_id, slot_announce_id); + assert_eq!(reserve.slot_index, 0); + // encrypted_contact should be longer than plaintext (nonce + tag) + assert!(reserve.encrypted_contact.len() > contact_info.len()); + + // Therapist decrypts reserve + let decrypted_contact = therapist_crypto + .decrypt_reserve(&reserve) + .expect("decrypt reserve"); + assert_eq!(decrypted_contact, contact_info); + + // Therapist creates confirm + let details = b"Confirmed! Room 42, 2nd floor. Please arrive 5 min early."; + let confirm = therapist_crypto + .create_confirm( + slot_announce_id, + 0, + true, + details, + &reserve.patient_ephemeral_key, + ) + .expect("create confirm"); + + assert!(confirm.confirmed); + assert!(confirm.encrypted_details.len() > details.len()); + + // Patient decrypts confirm + let decrypted_details = patient_crypto + .decrypt_confirm(&confirm) + .expect("decrypt confirm"); + assert_eq!(decrypted_details, details); + } + + #[test] + fn e2e_wrong_key_fails_decryption() { + let therapist_id = test_identity(); + let therapist_crypto = TherapistCrypto::new(therapist_id); + + let patient_ephemeral = PatientEphemeralKey::generate(); + let patient_crypto = PatientCrypto::new(patient_ephemeral); + + // Create reserve with wrong therapist key + let wrong_therapist = test_identity(); + let wrong_crypto = TherapistCrypto::new(wrong_therapist); + + let reserve = patient_crypto + .create_reserve( + [0xBB; 16], + 0, + b"secret contact", + &wrong_crypto.x25519_public(), // Encrypted to wrong therapist + ) + .expect("create reserve"); + + // Correct therapist should fail to decrypt + let result = therapist_crypto.decrypt_reserve(&reserve); + assert!(result.is_err()); + } + + #[test] + fn e2e_reserve_wire_roundtrip() { + let therapist_id = test_identity(); + let therapist_crypto = TherapistCrypto::new(therapist_id); + + let patient_ephemeral = PatientEphemeralKey::generate(); + let patient_crypto = PatientCrypto::new(patient_ephemeral); + + let reserve = patient_crypto + .create_reserve( + [0xCC; 16], + 5, + b"test contact", + &therapist_crypto.x25519_public(), + ) + .expect("create reserve"); + + // Serialize and deserialize + let wire = reserve.to_wire(); + let restored = SlotReserve::from_wire(&wire).expect("deserialize"); + + assert_eq!(restored.slot_announce_id, reserve.slot_announce_id); + assert_eq!(restored.slot_index, reserve.slot_index); + assert_eq!(restored.patient_ephemeral_key, reserve.patient_ephemeral_key); + assert_eq!(restored.encrypted_contact, reserve.encrypted_contact); + + // Decryption should still work + let decrypted = therapist_crypto.decrypt_reserve(&restored).expect("decrypt"); + assert_eq!(decrypted, b"test contact"); + } + + #[test] + fn e2e_confirm_wire_roundtrip() { + let therapist_id = test_identity(); + let therapist_crypto = TherapistCrypto::new(therapist_id); + + let patient_ephemeral = PatientEphemeralKey::generate(); + let patient_pub = patient_ephemeral.public_bytes(); // Save before move + let patient_crypto = PatientCrypto::new(patient_ephemeral); + + let confirm = therapist_crypto + .create_confirm( + [0xDD; 16], + 3, + true, + b"Appointment confirmed!", + &patient_pub, + ) + .expect("create confirm"); + + // Serialize and deserialize + let wire = confirm.to_wire(); + let restored = SlotConfirm::from_wire(&wire).expect("deserialize"); + + assert_eq!(restored.slot_announce_id, confirm.slot_announce_id); + assert_eq!(restored.slot_index, confirm.slot_index); + assert_eq!(restored.confirmed, confirm.confirmed); + assert_eq!(restored.therapist_ephemeral_key, confirm.therapist_ephemeral_key); + + // Decryption should still work + let decrypted = patient_crypto.decrypt_confirm(&restored).expect("decrypt"); + assert_eq!(decrypted, b"Appointment confirmed!"); + } + + #[test] + fn e2e_rejection_confirm() { + let therapist_id = test_identity(); + let therapist_crypto = TherapistCrypto::new(therapist_id); + + let patient_ephemeral = PatientEphemeralKey::generate(); + let patient_pub = patient_ephemeral.public_bytes(); // Save before move + let patient_crypto = PatientCrypto::new(patient_ephemeral); + + // Therapist rejects (confirmed = false) + let confirm = therapist_crypto + .create_confirm( + [0xEE; 16], + 0, + false, + b"Slot already taken. Please try another time.", + &patient_pub, + ) + .expect("create rejection"); + + assert!(!confirm.confirmed); + + let decrypted = patient_crypto.decrypt_confirm(&confirm).expect("decrypt"); + assert_eq!(decrypted, b"Slot already taken. Please try another time."); + } } diff --git a/crates/quicprochat-p2p/src/fapp_router.rs b/crates/quicprochat-p2p/src/fapp_router.rs index ff36a34..5b21590 100644 --- a/crates/quicprochat-p2p/src/fapp_router.rs +++ b/crates/quicprochat-p2p/src/fapp_router.rs @@ -13,8 +13,8 @@ use std::sync::{Arc, Mutex, RwLock}; use anyhow::{bail, Result}; use crate::fapp::{ - FappStore, SlotAnnounce, SlotQuery, SlotResponse, CAP_FAPP_PATIENT, CAP_FAPP_RELAY, - CAP_FAPP_THERAPIST, + FappStore, SlotAnnounce, SlotConfirm, SlotQuery, SlotReserve, SlotResponse, + CAP_FAPP_PATIENT, CAP_FAPP_RELAY, CAP_FAPP_THERAPIST, }; use crate::routing_table::RoutingTable; use crate::transport::TransportAddr; @@ -53,6 +53,18 @@ pub enum FappAction { }, /// Relay answered from [`FappStore`] (matches may be empty). QueryResponse(SlotResponse), + /// A SlotReserve was received and should be delivered to the therapist. + /// Contains the therapist address (to route) and the wire-format reserve. + DeliverReserve { + therapist_address: [u8; 16], + reserve: SlotReserve, + }, + /// A SlotConfirm was received and should be delivered to the patient. + /// Contains the patient ephemeral key (for routing/lookup) and the confirm. + DeliverConfirm { + patient_ephemeral_key: [u8; 32], + confirm: SlotConfirm, + }, } // --------------------------------------------------------------------------- @@ -77,6 +89,27 @@ fn slot_query_from_wire(bytes: &[u8]) -> Result { Ok(q) } +fn slot_reserve_from_wire(bytes: &[u8]) -> Result { + let r: SlotReserve = ciborium::from_reader(bytes)?; + Ok(r) +} + +fn slot_confirm_from_wire(bytes: &[u8]) -> Result { + let c: SlotConfirm = ciborium::from_reader(bytes)?; + Ok(c) +} + +fn slot_response_to_wire(response: &SlotResponse) -> Vec { + let mut buf = Vec::new(); + ciborium::into_writer(response, &mut buf).expect("SlotResponse CBOR"); + buf +} + +fn slot_response_from_wire(bytes: &[u8]) -> Result { + let r: SlotResponse = ciborium::from_reader(bytes)?; + Ok(r) +} + /// Unique next-hop addresses from the routing table (flood fan-out). fn flood_targets(table: &RoutingTable) -> Vec { let mut seen = HashSet::new(); @@ -159,9 +192,18 @@ impl FappRouter { Ok(q) => self.process_slot_query(q), Err(e) => FappAction::Dropped(format!("query CBOR: {e}")), }, - FAPP_WIRE_RESPONSE | FAPP_WIRE_RESERVE | FAPP_WIRE_CONFIRM => { - FappAction::Dropped(format!("unhandled FAPP tag 0x{tag:02x}")) - } + FAPP_WIRE_RESPONSE => match slot_response_from_wire(body) { + Ok(r) => self.process_slot_response(r), + Err(e) => FappAction::Dropped(format!("response CBOR: {e}")), + }, + FAPP_WIRE_RESERVE => match slot_reserve_from_wire(body) { + Ok(r) => self.process_slot_reserve(r), + Err(e) => FappAction::Dropped(format!("reserve CBOR: {e}")), + }, + FAPP_WIRE_CONFIRM => match slot_confirm_from_wire(body) { + Ok(c) => self.process_slot_confirm(c), + Err(e) => FappAction::Dropped(format!("confirm CBOR: {e}")), + }, _ => FappAction::Dropped(format!("unknown FAPP tag 0x{tag:02x}")), } } @@ -259,6 +301,122 @@ impl FappRouter { FappAction::QueryResponse(response) } + /// Process an incoming SlotResponse (patient receives query results). + pub fn process_slot_response(&self, response: SlotResponse) -> FappAction { + // Responses are delivered to the application layer; patient code handles them. + // No relay/forwarding for responses — they're point-to-point. + if self.local_capabilities & CAP_FAPP_PATIENT == 0 { + return FappAction::Ignore; + } + + // Return as QueryResponse for application handling + FappAction::QueryResponse(response) + } + + /// Process an incoming SlotReserve (relay routes to therapist). + /// + /// Relays look up the therapist address in the routing table and forward. + /// Therapists receive the reserve for decryption and handling. + pub fn process_slot_reserve(&self, reserve: SlotReserve) -> FappAction { + // Look up the therapist address from the original slot announce + let store = match self.store.lock() { + Ok(g) => g, + Err(e) => return FappAction::Dropped(format!("fapp store lock poisoned: {e}")), + }; + + // Find the SlotAnnounce this reserve refers to + for announces in store.announces_iter() { + for announce in announces { + if announce.id == reserve.slot_announce_id { + // Found the therapist address + return FappAction::DeliverReserve { + therapist_address: announce.therapist_address, + reserve, + }; + } + } + } + + // SlotAnnounce not in cache; forward to all neighbors (flood) + let table = match self.routes.read() { + Ok(t) => t, + Err(e) => return FappAction::Dropped(format!("routing table lock: {e}")), + }; + + let next_hops = flood_targets(&table); + if next_hops.is_empty() { + return FappAction::Dropped("no routes for reserve flood".into()); + } + + let wire = encode_tagged(FAPP_WIRE_RESERVE, &reserve.to_wire()); + FappAction::Forward { wire, next_hops } + } + + /// Process an incoming SlotConfirm (relay routes to patient). + /// + /// Confirms are routed based on the patient's ephemeral key. + pub fn process_slot_confirm(&self, confirm: SlotConfirm) -> FappAction { + // The confirm contains the patient's ephemeral key; the patient + // application needs to match this to their pending reservations. + FappAction::DeliverConfirm { + patient_ephemeral_key: confirm.therapist_ephemeral_key, // Note: this is for routing lookup + confirm, + } + } + + /// Send a SlotReserve to a specific therapist address. + pub fn send_reserve(&self, reserve: SlotReserve, therapist_address: &[u8; 16]) -> Result<()> { + if self.local_capabilities & CAP_FAPP_PATIENT == 0 { + bail!("missing CAP_FAPP_PATIENT"); + } + + let table = self + .routes + .read() + .map_err(|e| anyhow::anyhow!("routing table lock poisoned: {e}"))?; + + // Try to find a direct route to the therapist + if let Some(entry) = table.lookup(therapist_address) { + let wire = encode_tagged(FAPP_WIRE_RESERVE, &reserve.to_wire()); + let mut q = self + .pending_sends + .lock() + .map_err(|e| anyhow::anyhow!("pending_sends lock: {e}"))?; + q.push((entry.next_hop_addr.clone(), wire)); + return Ok(()); + } + + // No direct route; flood to all neighbors + let wire = encode_tagged(FAPP_WIRE_RESERVE, &reserve.to_wire()); + enqueue_flood(&self.pending_sends, wire, &table) + } + + /// Send a SlotConfirm response (therapist confirms/rejects a reservation). + pub fn send_confirm(&self, confirm: SlotConfirm, patient_ephemeral: &[u8; 32]) -> Result<()> { + if self.local_capabilities & CAP_FAPP_THERAPIST == 0 { + bail!("missing CAP_FAPP_THERAPIST"); + } + + // Confirms are flooded since we don't have routing info for ephemeral keys + let wire = encode_tagged(FAPP_WIRE_CONFIRM, &confirm.to_wire()); + let table = self + .routes + .read() + .map_err(|e| anyhow::anyhow!("routing table lock poisoned: {e}"))?; + enqueue_flood(&self.pending_sends, wire, &table) + } + + /// Send a SlotResponse to a specific address (relay answering a query). + pub fn send_response(&self, response: SlotResponse, dest: &TransportAddr) -> Result<()> { + let wire = encode_tagged(FAPP_WIRE_RESPONSE, &slot_response_to_wire(&response)); + let mut q = self + .pending_sends + .lock() + .map_err(|e| anyhow::anyhow!("pending_sends lock: {e}"))?; + q.push((dest.clone(), wire)); + Ok(()) + } + /// Take queued outbound frames (typically sent with `TransportManager::send` in async code). pub fn drain_pending_sends(&self) -> Result)>> { let mut q = self @@ -310,6 +468,104 @@ mod tests { assert!(matches!(r.process_slot_query(q), FappAction::Ignore)); } + #[test] + fn send_reserve_requires_patient_cap() { + let routes = Arc::new(RwLock::new(RoutingTable::new(Duration::from_secs(300)))); + let transports = Arc::new(TransportManager::new()); + let r = FappRouter::new(FappStore::new(), routes, transports, CAP_FAPP_THERAPIST); + + let reserve = SlotReserve { + slot_announce_id: [0xAA; 16], + slot_index: 0, + patient_ephemeral_key: [0xBB; 32], + encrypted_contact: vec![1, 2, 3], + }; + assert!(r.send_reserve(reserve, &[0xCC; 16]).is_err()); + } + + #[test] + fn send_confirm_requires_therapist_cap() { + let routes = Arc::new(RwLock::new(RoutingTable::new(Duration::from_secs(300)))); + let transports = Arc::new(TransportManager::new()); + let r = FappRouter::new(FappStore::new(), routes, transports, CAP_FAPP_PATIENT); + + let confirm = SlotConfirm { + slot_announce_id: [0xAA; 16], + slot_index: 0, + confirmed: true, + encrypted_details: vec![1, 2, 3], + therapist_ephemeral_key: [0xDD; 32], + }; + assert!(r.send_confirm(confirm, &[0xEE; 32]).is_err()); + } + + #[test] + fn process_reserve_returns_deliver() { + let routes = Arc::new(RwLock::new(RoutingTable::new(Duration::from_secs(300)))); + let transports = Arc::new(TransportManager::new()); + + // Create a store with a known announce + let id = MeshIdentity::generate(); + let mut store = FappStore::new(); + let announce = SlotAnnounce::new( + &id, + vec![Fachrichtung::Verhaltenstherapie], + vec![Modalitaet::Praxis], + vec![Kostentraeger::GKV], + "80331".into(), + vec![TimeSlot { + start_unix: 99999999, + duration_minutes: 50, + slot_type: SlotType::Therapie, + }], + [0xAA; 32], + 1, + ); + let announce_id = announce.id; + let therapist_addr = announce.therapist_address; + store.register_therapist_key(therapist_addr, id.public_key()); + store.store(announce); + + let r = FappRouter::new(store, routes, transports, CAP_FAPP_RELAY); + + let reserve = SlotReserve { + slot_announce_id: announce_id, + slot_index: 0, + patient_ephemeral_key: [0xBB; 32], + encrypted_contact: vec![1, 2, 3], + }; + + match r.process_slot_reserve(reserve) { + FappAction::DeliverReserve { therapist_address, .. } => { + assert_eq!(therapist_address, therapist_addr); + } + other => panic!("expected DeliverReserve, got {other:?}"), + } + } + + #[test] + fn process_confirm_returns_deliver() { + let routes = Arc::new(RwLock::new(RoutingTable::new(Duration::from_secs(300)))); + let transports = Arc::new(TransportManager::new()); + let r = FappRouter::new(FappStore::new(), routes, transports, CAP_FAPP_PATIENT); + + let confirm = SlotConfirm { + slot_announce_id: [0xAA; 16], + slot_index: 0, + confirmed: true, + encrypted_details: vec![1, 2, 3], + therapist_ephemeral_key: [0xDD; 32], + }; + + match r.process_slot_confirm(confirm.clone()) { + FappAction::DeliverConfirm { patient_ephemeral_key, confirm: c } => { + assert_eq!(patient_ephemeral_key, [0xDD; 32]); + assert!(c.confirmed); + } + other => panic!("expected DeliverConfirm, got {other:?}"), + } + } + #[test] fn broadcast_announce_requires_therapist_cap() { let routes = Arc::new(RwLock::new(RoutingTable::new(Duration::from_secs(300))));