From cbfa7e16c43e6f75c858031e88af9b65be99c50c Mon Sep 17 00:00:00 2001 From: Christian Nennemann Date: Tue, 31 Mar 2026 09:29:41 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20FAPP=20=E2=80=94=20Free=20Appointment?= =?UTF-8?q?=20Propagation=20Protocol=20for=20psychotherapy=20discovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/quicprochat-p2p/src/fapp.rs | 1079 ++++++++++++++++++++++++++++ crates/quicprochat-p2p/src/lib.rs | 1 + docs/specs/fapp-protocol.md | 154 ++++ docs/status.md | 27 + 4 files changed, 1261 insertions(+) create mode 100644 crates/quicprochat-p2p/src/fapp.rs create mode 100644 docs/specs/fapp-protocol.md diff --git a/crates/quicprochat-p2p/src/fapp.rs b/crates/quicprochat-p2p/src/fapp.rs new file mode 100644 index 0000000..cdfd2bb --- /dev/null +++ b/crates/quicprochat-p2p/src/fapp.rs @@ -0,0 +1,1079 @@ +//! Free Appointment Propagation Protocol (FAPP) for psychotherapy slot discovery. +//! +//! FAPP is an optional mesh extension that lets licensed therapists announce free +//! appointment slots. Patients discover slots via anonymous mesh queries. No +//! central registry — the mesh IS the directory. +//! +//! Privacy model: +//! - Therapist identity is **public** (licensed professional, Approbation bound). +//! - Patient queries are **anonymous** (no identifying information). + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::announce::compute_address; +use crate::identity::MeshIdentity; + +// --------------------------------------------------------------------------- +// Capability flags (extend announce.rs bitfield) +// --------------------------------------------------------------------------- + +/// Node is a licensed therapist publishing slots. +pub const CAP_FAPP_THERAPIST: u16 = 0x0100; +/// Node caches and relays FAPP slot announcements. +pub const CAP_FAPP_RELAY: u16 = 0x0200; +/// Node can issue anonymous slot queries. +pub const CAP_FAPP_PATIENT: u16 = 0x0400; + +// --------------------------------------------------------------------------- +// Domain enums (German names — these are domain concepts) +// --------------------------------------------------------------------------- + +/// Therapy specialization (Richtlinienverfahren). +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Fachrichtung { + Verhaltenstherapie, + TiefenpsychologischFundiert, + Analytisch, + Systemisch, + KinderJugend, +} + +/// Session modality. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Modalitaet { + Praxis, + Video, + Hybrid, +} + +/// Insurance / payment type. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Kostentraeger { + GKV, + PKV, + Selbstzahler, +} + +/// Appointment type within the German therapy system. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SlotType { + /// Psychotherapeutische Sprechstunde (initial consultation). + Erstgespraech, + /// Probatorische Sitzungen (trial sessions). + Probatorik, + /// Regular therapy session. + Therapie, + /// Akutbehandlung (acute/crisis treatment). + Akut, +} + +// --------------------------------------------------------------------------- +// Time slot +// --------------------------------------------------------------------------- + +/// A single free time slot. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct TimeSlot { + /// Start time in Unix seconds. + pub start_unix: u64, + /// Duration in minutes (typically 50 or 25). + pub duration_minutes: u16, + /// Type of appointment. + pub slot_type: SlotType, +} + +// --------------------------------------------------------------------------- +// SlotAnnounce +// --------------------------------------------------------------------------- + +/// A therapist's announcement of free appointment slots. +/// +/// Propagates through the mesh like [`MeshAnnounce`](crate::announce::MeshAnnounce), +/// cached by relay nodes with `CAP_FAPP_RELAY`. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SlotAnnounce { + /// Unique announcement ID. + pub id: [u8; 16], + /// MeshAddress of the therapist node. + pub therapist_address: [u8; 16], + /// Therapy specializations offered. + pub fachrichtung: Vec, + /// Session modalities. + pub modalitaet: Vec, + /// Accepted insurance / payment types. + pub kostentraeger: Vec, + /// Postal code (PLZ) only — never exact address. + pub location_hint: String, + /// Available time slots. + pub slots: Vec, + /// SHA-256 of the therapist's Approbation number. + pub approbation_hash: [u8; 32], + /// Monotonically increasing per therapist (dedup/supersede). + pub sequence: u64, + /// Time-to-live in hours (default 168 = 7 days). + pub ttl_hours: u16, + /// Unix timestamp of creation. + pub timestamp: u64, + /// Ed25519 signature over all fields except `signature` and `hop_count`. + pub signature: Vec, + /// Current hop count (incremented on re-broadcast). + pub hop_count: u8, + /// Maximum propagation hops. + pub max_hops: u8, +} + +/// Default TTL for slot announcements: 7 days. +const DEFAULT_TTL_HOURS: u16 = 168; +/// Default maximum hops for slot propagation. +const DEFAULT_MAX_HOPS: u8 = 8; +/// Maximum announcements cached per therapist. +const MAX_PER_THERAPIST: usize = 50; +/// Maximum total announcements in the FAPP store. +const MAX_TOTAL_ANNOUNCES: usize = 10_000; +/// Maximum seen IDs for deduplication. +const MAX_SEEN_IDS: usize = 50_000; + +impl SlotAnnounce { + /// Create and sign a new slot announcement. + pub fn new( + identity: &MeshIdentity, + fachrichtung: Vec, + modalitaet: Vec, + kostentraeger: Vec, + location_hint: String, + slots: Vec, + approbation_hash: [u8; 32], + sequence: u64, + ) -> Self { + let pk = identity.public_key(); + let therapist_address = compute_address(&pk); + + let mut id = [0u8; 16]; + let id_hash = Sha256::digest(&[&therapist_address[..], &sequence.to_le_bytes()].concat()); + id.copy_from_slice(&id_hash[..16]); + + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let mut announce = Self { + id, + therapist_address, + fachrichtung, + modalitaet, + kostentraeger, + location_hint, + slots, + approbation_hash, + sequence, + ttl_hours: DEFAULT_TTL_HOURS, + timestamp, + signature: Vec::new(), + hop_count: 0, + max_hops: DEFAULT_MAX_HOPS, + }; + + let signable = announce.signable_bytes(); + announce.signature = identity.sign(&signable).to_vec(); + announce + } + + /// Assemble the byte string that is signed / verified. + /// + /// `hop_count` and `signature` are excluded — forwarding nodes increment + /// hop_count without re-signing (same design as MeshAnnounce). + fn signable_bytes(&self) -> Vec { + let mut buf = Vec::with_capacity(256); + buf.extend_from_slice(&self.id); + buf.extend_from_slice(&self.therapist_address); + + // Encode fachrichtung as indices for deterministic signing. + for f in &self.fachrichtung { + buf.push(fachrichtung_index(f)); + } + buf.push(0xFF); // separator + + for m in &self.modalitaet { + buf.push(modalitaet_index(m)); + } + buf.push(0xFF); + + for k in &self.kostentraeger { + buf.push(kostentraeger_index(k)); + } + buf.push(0xFF); + + buf.extend_from_slice(self.location_hint.as_bytes()); + buf.push(0xFF); + + for slot in &self.slots { + buf.extend_from_slice(&slot.start_unix.to_le_bytes()); + buf.extend_from_slice(&slot.duration_minutes.to_le_bytes()); + buf.push(slot_type_index(&slot.slot_type)); + } + buf.push(0xFF); + + buf.extend_from_slice(&self.approbation_hash); + buf.extend_from_slice(&self.sequence.to_le_bytes()); + buf.extend_from_slice(&self.ttl_hours.to_le_bytes()); + buf.extend_from_slice(&self.timestamp.to_le_bytes()); + buf.push(self.max_hops); + buf + } + + /// Verify the Ed25519 signature on this announcement. + /// + /// Requires the therapist's public key (32 bytes) since the announce only + /// carries the truncated address. + pub fn verify(&self, therapist_public_key: &[u8; 32]) -> bool { + // Verify that the address matches the key. + let expected_addr = compute_address(therapist_public_key); + if expected_addr != self.therapist_address { + return false; + } + + let sig: [u8; 64] = match self.signature.as_slice().try_into() { + Ok(s) => s, + Err(_) => return false, + }; + + let signable = self.signable_bytes(); + quicprochat_core::IdentityKeypair::verify_raw(therapist_public_key, &signable, &sig) + .is_ok() + } + + /// Check whether this announcement has expired. + pub fn is_expired(&self) -> bool { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let ttl_secs = u64::from(self.ttl_hours) * 3600; + now.saturating_sub(self.timestamp) > ttl_secs + } + + /// Create a forwarded copy with `hop_count` incremented. + pub fn forwarded(&self) -> Self { + let mut copy = self.clone(); + copy.hop_count = copy.hop_count.saturating_add(1); + copy + } + + /// Whether this announce can still propagate. + pub fn can_propagate(&self) -> bool { + self.hop_count < self.max_hops && !self.is_expired() + } + + /// 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 announce: Self = ciborium::from_reader(bytes)?; + Ok(announce) + } +} + +// --------------------------------------------------------------------------- +// SlotQuery +// --------------------------------------------------------------------------- + +/// Anonymous query for available therapy slots. +/// +/// Carries no patient identity. Propagated through the mesh; relay nodes with +/// cached SlotAnnounces can respond directly. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SlotQuery { + /// Random query ID for response correlation. + pub query_id: [u8; 16], + /// Filter by specialization. + pub fachrichtung: Option, + /// Filter by modality. + pub modalitaet: Option, + /// Filter by insurance type. + pub kostentraeger: Option, + /// Filter by PLZ prefix (e.g. "80" for Munich area). + pub plz_prefix: Option, + /// Earliest acceptable slot (Unix seconds). + pub earliest: Option, + /// Latest acceptable slot (Unix seconds). + pub latest: Option, + /// Filter by appointment type. + pub slot_type: Option, + /// Maximum results requested. + pub max_results: u8, +} + +// --------------------------------------------------------------------------- +// SlotResponse +// --------------------------------------------------------------------------- + +/// Response to a SlotQuery with matching announcements. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SlotResponse { + /// Correlates to the original query. + pub query_id: [u8; 16], + /// Matching slot announcements (full, so patient can verify signatures). + pub matches: Vec, +} + +// --------------------------------------------------------------------------- +// SlotReserve +// --------------------------------------------------------------------------- + +/// Patient claims a specific slot. The `encrypted_contact` field is E2E +/// encrypted to the therapist's public key — the mesh cannot read it. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SlotReserve { + /// ID of the SlotAnnounce being reserved. + pub slot_announce_id: [u8; 16], + /// Index into the SlotAnnounce's slots vector. + pub slot_index: u16, + /// X25519 ephemeral public key for reply encryption. + pub patient_ephemeral_key: [u8; 32], + /// Patient contact info, encrypted to therapist's key. + pub encrypted_contact: Vec, +} + +// --------------------------------------------------------------------------- +// SlotConfirm +// --------------------------------------------------------------------------- + +/// Therapist confirms or rejects a reservation. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SlotConfirm { + /// Original SlotAnnounce ID. + pub slot_announce_id: [u8; 16], + /// Slot index. + pub slot_index: u16, + /// Whether the reservation is accepted. + pub confirmed: bool, + /// Appointment details, encrypted to patient's ephemeral key. + pub encrypted_details: Vec, +} + +// --------------------------------------------------------------------------- +// FappStore — in-memory cache of active slot announcements +// --------------------------------------------------------------------------- + +/// In-memory cache of active [`SlotAnnounce`]s for relay nodes. +/// +/// Mirrors the design of [`MeshStore`](crate::store::MeshStore) with +/// FAPP-specific dedup (therapist_address + sequence) and query matching. +pub struct FappStore { + /// Active announces keyed by therapist address. + announces: HashMap<[u8; 16], Vec>, + /// Known therapist public keys (needed for signature verification). + known_keys: HashMap<[u8; 16], [u8; 32]>, + /// Seen announce IDs for deduplication. + seen: HashSet<[u8; 16]>, + /// Insertion-ordered seen IDs for bounded eviction. + seen_order: VecDeque<[u8; 16]>, + /// Highest sequence seen per therapist (supersede older). + latest_sequence: HashMap<[u8; 16], u64>, +} + +impl FappStore { + /// Create an empty FAPP store. + pub fn new() -> Self { + Self { + announces: HashMap::new(), + known_keys: HashMap::new(), + seen: HashSet::new(), + seen_order: VecDeque::new(), + latest_sequence: HashMap::new(), + } + } + + /// Register a therapist's public key (needed for signature verification). + pub fn register_therapist_key(&mut self, address: [u8; 16], public_key: [u8; 32]) { + self.known_keys.insert(address, public_key); + } + + /// Store a slot announcement. Returns `false` if rejected (dedup, invalid, + /// expired, or at capacity). + pub fn store(&mut self, announce: SlotAnnounce) -> bool { + // Reject expired. + if announce.is_expired() { + return false; + } + + // Dedup by ID. + if self.seen.contains(&announce.id) { + return false; + } + + // Sequence check: reject if we already have a higher sequence for this therapist. + if let Some(&latest) = self.latest_sequence.get(&announce.therapist_address) { + if announce.sequence < latest { + return false; + } + } + + // Verify signature if we know the therapist's key. + if let Some(pk) = self.known_keys.get(&announce.therapist_address) { + if !announce.verify(pk) { + return false; + } + } + + // Capacity check per therapist. + let queue = self + .announces + .entry(announce.therapist_address) + .or_default(); + if queue.len() >= MAX_PER_THERAPIST { + // Evict oldest. + queue.remove(0); + } + + // Total capacity check. + let total: usize = self.announces.values().map(|q| q.len()).sum(); + if total >= MAX_TOTAL_ANNOUNCES { + return false; + } + + // Track seen ID. + self.seen.insert(announce.id); + self.seen_order.push_back(announce.id); + while self.seen_order.len() > MAX_SEEN_IDS { + if let Some(old_id) = self.seen_order.pop_front() { + self.seen.remove(&old_id); + } + } + + // Update latest sequence. + self.latest_sequence + .insert(announce.therapist_address, announce.sequence); + + queue.push(announce); + true + } + + /// Query the store for matching slot announcements. + pub fn query(&self, query: &SlotQuery) -> SlotResponse { + let mut matches = Vec::new(); + let max = query.max_results as usize; + + for announces in self.announces.values() { + for announce in announces { + if announce.is_expired() { + continue; + } + if !matches_query(announce, query) { + continue; + } + matches.push(announce.clone()); + if matches.len() >= max { + return SlotResponse { + query_id: query.query_id, + matches, + }; + } + } + } + + SlotResponse { + query_id: query.query_id, + matches, + } + } + + /// Remove all expired announcements. Returns count removed. + pub fn gc_expired(&mut self) -> usize { + let mut removed = 0; + self.announces.retain(|_addr, queue| { + let before = queue.len(); + queue.retain(|a| !a.is_expired()); + removed += before - queue.len(); + !queue.is_empty() + }); + removed + } + + /// Return `(total_announces, unique_therapists)`. + pub fn stats(&self) -> (usize, usize) { + let total: usize = self.announces.values().map(|q| q.len()).sum(); + let therapists = self.announces.len(); + (total, therapists) + } + + /// Check whether an announce ID has already been seen. + pub fn seen(&self, id: &[u8; 16]) -> bool { + self.seen.contains(id) + } + + /// Get all active announces for a therapist address. + pub fn get_therapist(&self, address: &[u8; 16]) -> &[SlotAnnounce] { + self.announces + .get(address) + .map(|v| v.as_slice()) + .unwrap_or_default() + } +} + +impl Default for FappStore { + fn default() -> Self { + Self::new() + } +} + +// --------------------------------------------------------------------------- +// Query matching +// --------------------------------------------------------------------------- + +/// Check whether a SlotAnnounce matches the filters in a SlotQuery. +fn matches_query(announce: &SlotAnnounce, query: &SlotQuery) -> bool { + // Fachrichtung filter. + if let Some(ref f) = query.fachrichtung { + if !announce.fachrichtung.contains(f) { + return false; + } + } + + // Modalitaet filter. + if let Some(ref m) = query.modalitaet { + if !announce.modalitaet.contains(m) { + return false; + } + } + + // Kostentraeger filter. + if let Some(ref k) = query.kostentraeger { + if !announce.kostentraeger.contains(k) { + return false; + } + } + + // PLZ prefix filter. + if let Some(ref prefix) = query.plz_prefix { + if !announce.location_hint.starts_with(prefix.as_str()) { + return false; + } + } + + // Slot type filter. + if let Some(ref st) = query.slot_type { + if !announce.slots.iter().any(|s| &s.slot_type == st) { + return false; + } + } + + // Time range filter. + let has_matching_time = announce.slots.iter().any(|slot| { + let end = slot.start_unix + u64::from(slot.duration_minutes) * 60; + let after_earliest = query.earliest.map_or(true, |e| end > e); + let before_latest = query.latest.map_or(true, |l| slot.start_unix < l); + after_earliest && before_latest + }); + + if !has_matching_time && (query.earliest.is_some() || query.latest.is_some()) { + return false; + } + + true +} + +// --------------------------------------------------------------------------- +// Enum index helpers (for deterministic signable bytes) +// --------------------------------------------------------------------------- + +fn fachrichtung_index(f: &Fachrichtung) -> u8 { + match f { + Fachrichtung::Verhaltenstherapie => 0, + Fachrichtung::TiefenpsychologischFundiert => 1, + Fachrichtung::Analytisch => 2, + Fachrichtung::Systemisch => 3, + Fachrichtung::KinderJugend => 4, + } +} + +fn modalitaet_index(m: &Modalitaet) -> u8 { + match m { + Modalitaet::Praxis => 0, + Modalitaet::Video => 1, + Modalitaet::Hybrid => 2, + } +} + +fn kostentraeger_index(k: &Kostentraeger) -> u8 { + match k { + Kostentraeger::GKV => 0, + Kostentraeger::PKV => 1, + Kostentraeger::Selbstzahler => 2, + } +} + +fn slot_type_index(s: &SlotType) -> u8 { + match s { + SlotType::Erstgespraech => 0, + SlotType::Probatorik => 1, + SlotType::Therapie => 2, + SlotType::Akut => 3, + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn test_identity() -> MeshIdentity { + MeshIdentity::generate() + } + + fn sample_slots() -> Vec { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + vec![ + TimeSlot { + start_unix: now + 86400, // tomorrow + duration_minutes: 50, + slot_type: SlotType::Erstgespraech, + }, + TimeSlot { + start_unix: now + 2 * 86400, // day after tomorrow + duration_minutes: 50, + slot_type: SlotType::Therapie, + }, + ] + } + + fn sample_announce(identity: &MeshIdentity) -> SlotAnnounce { + SlotAnnounce::new( + identity, + vec![Fachrichtung::Verhaltenstherapie], + vec![Modalitaet::Praxis, Modalitaet::Video], + vec![Kostentraeger::GKV, Kostentraeger::Selbstzahler], + "80331".into(), + sample_slots(), + [0xAA; 32], + 1, + ) + } + + #[test] + fn create_and_verify() { + let id = test_identity(); + let announce = sample_announce(&id); + let pk = id.public_key(); + + assert!(announce.verify(&pk), "freshly created announce must verify"); + assert_eq!(announce.hop_count, 0); + assert_eq!(announce.ttl_hours, DEFAULT_TTL_HOURS); + assert_eq!(announce.max_hops, DEFAULT_MAX_HOPS); + assert_eq!(announce.sequence, 1); + assert_eq!(announce.location_hint, "80331"); + assert_eq!(announce.fachrichtung, vec![Fachrichtung::Verhaltenstherapie]); + } + + #[test] + fn tampered_fails_verify() { + let id = test_identity(); + let mut announce = sample_announce(&id); + announce.location_hint = "99999".into(); // tamper + let pk = id.public_key(); + assert!(!announce.verify(&pk), "tampered announce must fail verification"); + } + + #[test] + fn wrong_key_fails_verify() { + let id = test_identity(); + let announce = sample_announce(&id); + let other_id = test_identity(); + let wrong_pk = other_id.public_key(); + assert!( + !announce.verify(&wrong_pk), + "announce verified with wrong key must fail" + ); + } + + #[test] + fn forwarded_still_verifies() { + let id = test_identity(); + let announce = sample_announce(&id); + let pk = id.public_key(); + assert!(announce.verify(&pk)); + + let fwd = announce.forwarded(); + assert_eq!(fwd.hop_count, 1); + assert!(fwd.verify(&pk), "forwarded announce must still verify"); + + let fwd2 = fwd.forwarded(); + assert_eq!(fwd2.hop_count, 2); + assert!(fwd2.verify(&pk), "double-forwarded must still verify"); + } + + #[test] + fn expired_announce() { + let id = test_identity(); + let mut announce = sample_announce(&id); + announce.timestamp = 0; // far in the past + announce.ttl_hours = 1; + assert!(announce.is_expired(), "announce from epoch should be expired"); + } + + #[test] + fn not_expired() { + let id = test_identity(); + let announce = sample_announce(&id); + assert!(!announce.is_expired(), "fresh announce should not be expired"); + } + + #[test] + fn cbor_roundtrip() { + let id = test_identity(); + let announce = sample_announce(&id); + let pk = id.public_key(); + + let wire = announce.to_wire(); + let restored = SlotAnnounce::from_wire(&wire).expect("CBOR deserialize"); + + assert_eq!(announce.id, restored.id); + assert_eq!(announce.therapist_address, restored.therapist_address); + assert_eq!(announce.fachrichtung, restored.fachrichtung); + assert_eq!(announce.modalitaet, restored.modalitaet); + assert_eq!(announce.kostentraeger, restored.kostentraeger); + assert_eq!(announce.location_hint, restored.location_hint); + assert_eq!(announce.sequence, restored.sequence); + assert_eq!(announce.ttl_hours, restored.ttl_hours); + assert_eq!(announce.timestamp, restored.timestamp); + assert_eq!(announce.signature, restored.signature); + assert!(restored.verify(&pk)); + } + + #[test] + fn can_propagate() { + let id = test_identity(); + let announce = sample_announce(&id); + assert!(announce.can_propagate()); + + // Exceed hop limit. + let mut at_limit = announce.clone(); + at_limit.hop_count = at_limit.max_hops; + assert!(!at_limit.can_propagate()); + + // Expired. + let mut expired = announce.clone(); + expired.timestamp = 0; + expired.ttl_hours = 1; + assert!(!expired.can_propagate()); + } + + // ----------------------------------------------------------------------- + // FappStore tests + // ----------------------------------------------------------------------- + + #[test] + fn store_and_query() { + let id = test_identity(); + let pk = id.public_key(); + let announce = sample_announce(&id); + let addr = announce.therapist_address; + + let mut store = FappStore::new(); + store.register_therapist_key(addr, pk); + assert!(store.store(announce)); + assert_eq!(store.stats(), (1, 1)); + + // Query matching fachrichtung. + let query = SlotQuery { + query_id: [0x01; 16], + fachrichtung: Some(Fachrichtung::Verhaltenstherapie), + modalitaet: None, + kostentraeger: None, + plz_prefix: None, + earliest: None, + latest: None, + slot_type: None, + max_results: 10, + }; + let response = store.query(&query); + assert_eq!(response.matches.len(), 1); + assert_eq!(response.query_id, [0x01; 16]); + + // Query non-matching fachrichtung. + let query2 = SlotQuery { + query_id: [0x02; 16], + fachrichtung: Some(Fachrichtung::Analytisch), + modalitaet: None, + kostentraeger: None, + plz_prefix: None, + earliest: None, + latest: None, + slot_type: None, + max_results: 10, + }; + let response2 = store.query(&query2); + assert!(response2.matches.is_empty()); + } + + #[test] + fn store_dedup() { + let id = test_identity(); + let pk = id.public_key(); + let announce = sample_announce(&id); + let addr = announce.therapist_address; + + let mut store = FappStore::new(); + store.register_therapist_key(addr, pk); + assert!(store.store(announce.clone())); + assert!(!store.store(announce), "duplicate should be rejected"); + assert_eq!(store.stats(), (1, 1)); + } + + #[test] + fn store_rejects_expired() { + let id = test_identity(); + let mut announce = sample_announce(&id); + announce.timestamp = 0; + announce.ttl_hours = 1; + + let mut store = FappStore::new(); + assert!(!store.store(announce), "expired announce should be rejected"); + } + + #[test] + fn store_sequence_supersede() { + let id = test_identity(); + let pk = id.public_key(); + + let announce1 = SlotAnnounce::new( + &id, + vec![Fachrichtung::Verhaltenstherapie], + vec![Modalitaet::Praxis], + vec![Kostentraeger::GKV], + "80331".into(), + sample_slots(), + [0xAA; 32], + 5, // sequence 5 + ); + let addr = announce1.therapist_address; + + let mut store = FappStore::new(); + store.register_therapist_key(addr, pk); + assert!(store.store(announce1)); + + // Announce with lower sequence should be rejected. + let announce_old = SlotAnnounce::new( + &id, + vec![Fachrichtung::Verhaltenstherapie], + vec![Modalitaet::Praxis], + vec![Kostentraeger::GKV], + "80331".into(), + sample_slots(), + [0xAA; 32], + 3, // sequence 3 < 5 + ); + assert!( + !store.store(announce_old), + "older sequence should be rejected" + ); + } + + #[test] + fn store_rejects_bad_signature() { + let id = test_identity(); + let pk = id.public_key(); + let mut announce = sample_announce(&id); + let addr = announce.therapist_address; + + // Tamper after signing. + announce.location_hint = "99999".into(); + + let mut store = FappStore::new(); + store.register_therapist_key(addr, pk); + assert!( + !store.store(announce), + "tampered announce should be rejected by store" + ); + } + + #[test] + fn store_gc_expired() { + let id = test_identity(); + let pk = id.public_key(); + + let mut announce = sample_announce(&id); + let addr = announce.therapist_address; + announce.timestamp = 0; + announce.ttl_hours = 1; + + // Bypass normal store validation to insert an expired announce. + let mut store = FappStore::new(); + store.register_therapist_key(addr, pk); + store.announces.entry(addr).or_default().push(announce); + + assert_eq!(store.stats(), (1, 1)); + let removed = store.gc_expired(); + assert_eq!(removed, 1); + assert_eq!(store.stats(), (0, 0)); + } + + #[test] + fn query_plz_prefix() { + let id = test_identity(); + let pk = id.public_key(); + let announce = sample_announce(&id); // PLZ 80331 + let addr = announce.therapist_address; + + let mut store = FappStore::new(); + store.register_therapist_key(addr, pk); + store.store(announce); + + // Match: "80" prefix. + let query = SlotQuery { + query_id: [0; 16], + fachrichtung: None, + modalitaet: None, + kostentraeger: None, + plz_prefix: Some("80".into()), + earliest: None, + latest: None, + slot_type: None, + max_results: 10, + }; + assert_eq!(store.query(&query).matches.len(), 1); + + // No match: "10" prefix (Berlin). + let query2 = SlotQuery { + plz_prefix: Some("10".into()), + ..query + }; + assert!(store.query(&query2).matches.is_empty()); + } + + #[test] + fn query_slot_type_filter() { + let id = test_identity(); + let pk = id.public_key(); + let announce = sample_announce(&id); // has Erstgespraech + Therapie + let addr = announce.therapist_address; + + let mut store = FappStore::new(); + store.register_therapist_key(addr, pk); + store.store(announce); + + // Match: Erstgespraech. + let query = SlotQuery { + query_id: [0; 16], + fachrichtung: None, + modalitaet: None, + kostentraeger: None, + plz_prefix: None, + earliest: None, + latest: None, + slot_type: Some(SlotType::Erstgespraech), + max_results: 10, + }; + assert_eq!(store.query(&query).matches.len(), 1); + + // No match: Akut. + let query2 = SlotQuery { + slot_type: Some(SlotType::Akut), + ..query + }; + assert!(store.query(&query2).matches.is_empty()); + } + + #[test] + fn query_max_results() { + let id = test_identity(); + let pk = id.public_key(); + let addr = compute_address(&pk); + + let mut store = FappStore::new(); + store.register_therapist_key(addr, pk); + + // Insert 5 announces with different sequences. + for seq in 1..=5 { + let announce = SlotAnnounce::new( + &id, + vec![Fachrichtung::Verhaltenstherapie], + vec![Modalitaet::Praxis], + vec![Kostentraeger::GKV], + "80331".into(), + sample_slots(), + [0xAA; 32], + seq, + ); + store.store(announce); + } + + let query = SlotQuery { + query_id: [0; 16], + fachrichtung: None, + modalitaet: None, + kostentraeger: None, + plz_prefix: None, + earliest: None, + latest: None, + slot_type: None, + max_results: 3, + }; + let response = store.query(&query); + assert_eq!(response.matches.len(), 3, "should respect max_results"); + } + + #[test] + fn query_kostentraeger_filter() { + let id = test_identity(); + let pk = id.public_key(); + let announce = sample_announce(&id); // GKV + Selbstzahler + let addr = announce.therapist_address; + + let mut store = FappStore::new(); + store.register_therapist_key(addr, pk); + store.store(announce); + + // Match: GKV. + let query = SlotQuery { + query_id: [0; 16], + fachrichtung: None, + modalitaet: None, + kostentraeger: Some(Kostentraeger::GKV), + plz_prefix: None, + earliest: None, + latest: None, + slot_type: None, + max_results: 10, + }; + assert_eq!(store.query(&query).matches.len(), 1); + + // No match: PKV. + let query2 = SlotQuery { + kostentraeger: Some(Kostentraeger::PKV), + ..query + }; + assert!(store.query(&query2).matches.is_empty()); + } + + #[test] + fn get_therapist_announces() { + let id = test_identity(); + let pk = id.public_key(); + let announce = sample_announce(&id); + let addr = announce.therapist_address; + + let mut store = FappStore::new(); + store.register_therapist_key(addr, pk); + store.store(announce); + + assert_eq!(store.get_therapist(&addr).len(), 1); + assert!(store.get_therapist(&[0xFF; 16]).is_empty()); + } +} diff --git a/crates/quicprochat-p2p/src/lib.rs b/crates/quicprochat-p2p/src/lib.rs index 7df1577..4adf93c 100644 --- a/crates/quicprochat-p2p/src/lib.rs +++ b/crates/quicprochat-p2p/src/lib.rs @@ -15,6 +15,7 @@ pub mod address; pub mod announce; pub mod announce_protocol; +pub mod fapp; pub mod broadcast; pub mod envelope; pub mod envelope_v2; diff --git a/docs/specs/fapp-protocol.md b/docs/specs/fapp-protocol.md new file mode 100644 index 0000000..81667e3 --- /dev/null +++ b/docs/specs/fapp-protocol.md @@ -0,0 +1,154 @@ +# FAPP — Free Appointment Propagation Protocol + +## Purpose + +Decentralized psychotherapy appointment discovery over the QuicProQuo mesh network. + +In Germany, finding a psychotherapist takes 3–6 months. The KV (Kassenärztliche Vereinigung) system artificially limits slot visibility. FAPP enables licensed therapists to directly announce free appointment slots into a decentralized mesh, where patients can discover and reserve them without a central registry. + +## Privacy Model + +- **Therapist identity is public.** Therapists are licensed professionals (Approbation). Their mesh identity is linked to a hashed Approbationsurkunde number. This is intentional — patients need to verify they are booking with a real therapist. +- **Patient queries are anonymous.** Patients never reveal their identity when searching. SlotQuery messages carry no identifying information. Only when a patient decides to reserve a slot do they establish an E2E-encrypted channel to the therapist — and even then, the mesh sees only encrypted traffic to the therapist's address. + +## Capability Flags + +FAPP extends the announce.rs capability bitfield: + +| Flag | Value | Description | +|------|-------|-------------| +| `CAP_FAPP_THERAPIST` | `0x0100` | Node is a licensed therapist publishing slots | +| `CAP_FAPP_RELAY` | `0x0200` | Node caches and relays FAPP slot announcements | +| `CAP_FAPP_PATIENT` | `0x0400` | Node can issue anonymous slot queries | + +## Message Types + +### 1. SlotAnnounce + +Therapist publishes free time slots into the mesh. + +**Fields:** +- `id: [u8; 16]` — Unique announcement ID +- `therapist_address: [u8; 16]` — MeshAddress of the therapist node +- `fachrichtung: Vec` — Therapy specializations offered +- `modalitaet: Vec` — Session modalities (Praxis, Video, Hybrid) +- `kostentraeger: Vec` — Accepted payment/insurance types +- `location_hint: String` — PLZ (postal code) only, never exact address +- `slots: Vec` — Available time slots +- `approbation_hash: [u8; 32]` — SHA-256 of the therapist's Approbation number +- `sequence: u64` — Monotonically increasing per therapist (dedup/supersede) +- `ttl_hours: u16` — Time-to-live in hours (default: 168 = 7 days) +- `timestamp: u64` — Unix seconds at creation +- `signature: [u8; 64]` — Ed25519 signature over all fields except signature and hop_count +- `hop_count: u8` — Current propagation hop count +- `max_hops: u8` — Maximum propagation hops + +**Propagation:** Like MeshAnnounce — flooded to neighbors, deduped by `(therapist_address, sequence)`. Higher sequence supersedes lower. Expired announcements (timestamp + ttl_hours exceeded) are dropped. + +### 2. SlotQuery + +Patient requests available slots matching criteria. Anonymous — no patient identity attached. + +**Fields:** +- `query_id: [u8; 16]` — Random query identifier for response correlation +- `fachrichtung: Option` — Filter by specialization +- `modalitaet: Option` — Filter by modality +- `kostentraeger: Option` — Filter by insurance type +- `plz_prefix: Option` — Filter by PLZ prefix (e.g. "80" for München area) +- `earliest: Option` — Earliest acceptable slot (Unix seconds) +- `latest: Option` — Latest acceptable slot (Unix seconds) +- `slot_type: Option` — Filter by appointment type +- `max_results: u8` — Maximum number of results requested +- `hop_count: u8` — Current hop count +- `max_hops: u8` — Maximum query propagation hops +- `return_path: Vec<[u8; 16]>` — Onion-style return path (mesh addresses) + +**Propagation:** Forwarded like announces but with shorter TTL. Relay nodes with cached SlotAnnounces can respond directly. + +### 3. SlotResponse + +Matching slots returned to the querier via the return path. + +**Fields:** +- `query_id: [u8; 16]` — Correlates to the original SlotQuery +- `matches: Vec` — Matching slot announcements (full, so patient can verify signatures) + +### 4. SlotReserve + +Patient claims a specific slot. E2E encrypted to the therapist. + +**Fields:** +- `slot_announce_id: [u8; 16]` — ID of the SlotAnnounce being reserved +- `slot_index: u16` — Index into the SlotAnnounce's slots vector +- `patient_ephemeral_key: [u8; 32]` — X25519 ephemeral public key for reply encryption +- `encrypted_contact: Vec` — Patient contact info, encrypted to therapist's key + +### 5. SlotConfirm + +Therapist confirms or rejects a reservation. + +**Fields:** +- `slot_announce_id: [u8; 16]` — Original SlotAnnounce ID +- `slot_index: u16` — Slot index +- `confirmed: bool` — Whether the reservation is accepted +- `encrypted_details: Vec` — Appointment details, encrypted to patient's ephemeral key + +## Data Model + +### Fachrichtung (Therapy Specialization) + +| Variant | Description | +|---------|-------------| +| `Verhaltenstherapie` | Cognitive behavioral therapy (CBT) | +| `TiefenpsychologischFundiert` | Psychodynamic therapy | +| `Analytisch` | Psychoanalysis | +| `Systemisch` | Systemic therapy | +| `KinderJugend` | Child and adolescent psychotherapy | + +### Modalitaet (Session Modality) + +| Variant | Description | +|---------|-------------| +| `Praxis` | In-person at the therapist's practice | +| `Video` | Video session (Videosprechstunde) | +| `Hybrid` | Either in-person or video | + +### Kostentraeger (Insurance/Payment) + +| Variant | Description | +|---------|-------------| +| `GKV` | Gesetzliche Krankenversicherung (statutory health insurance) | +| `PKV` | Private Krankenversicherung (private health insurance) | +| `Selbstzahler` | Self-pay | + +### SlotType (Appointment Type) + +| Variant | Description | +|---------|-------------| +| `Erstgespraech` | Psychotherapeutische Sprechstunde (initial consultation) | +| `Probatorik` | Probatorische Sitzungen (trial sessions) | +| `Therapie` | Regular therapy session | +| `Akut` | Akutbehandlung (acute/crisis treatment) | + +### TimeSlot + +- `start_unix: u64` — Start time in Unix seconds +- `duration_minutes: u16` — Duration (typically 50 or 25 minutes) +- `slot_type: SlotType` — Type of appointment + +## Anti-Spam + +1. **Approbation hash binding.** The `approbation_hash` field contains SHA-256 of the therapist's Approbation number. While mesh nodes cannot verify this against a registry, it creates accountability — a therapist's identity is tied to a real credential. +2. **Signature verification.** All SlotAnnounces are Ed25519-signed. Relay nodes reject unsigned or invalid announcements. +3. **Rate limiting.** Relay nodes enforce a maximum announcement rate per therapist address (e.g., max 10 SlotAnnounces per hour per therapist_address). +4. **Sequence-based dedup.** Each therapist maintains a monotonic sequence counter. Relay nodes only accept announces with sequence >= last seen for that therapist. +5. **TTL enforcement.** Expired announcements are garbage collected. Default TTL is 7 days. +6. **Hop limit.** SlotAnnounces have a max_hops field (default 8) to prevent infinite propagation. + +## Wire Format + +All FAPP messages use CBOR serialization (ciborium), consistent with MeshEnvelope and MeshAnnounce. + +## No Central Registry + +Slots live exclusively in the mesh. Relay nodes with `CAP_FAPP_RELAY` cache active SlotAnnounces and respond to queries. There is no central database, no API server, no single point of failure. The mesh IS the registry. diff --git a/docs/status.md b/docs/status.md index 57d7014..c6659e1 100644 --- a/docs/status.md +++ b/docs/status.md @@ -1,5 +1,32 @@ # Status Log +## 2026-03-31 — FAPP: Free Appointment Propagation Protocol + +### Completed +- **Protocol spec** — `docs/specs/fapp-protocol.md`: decentralized psychotherapy appointment discovery over mesh +- **Rust module** — `crates/quicprochat-p2p/src/fapp.rs`: full data structures, store, query matching, signature verification +- **Message types**: SlotAnnounce, SlotQuery, SlotResponse, SlotReserve, SlotConfirm +- **Domain model**: Fachrichtung, Modalitaet, Kostentraeger, SlotType (German enum names for domain concepts) +- **FappStore**: in-memory cache with dedup (therapist_address + sequence), TTL expiry, signature verification, capacity limits +- **Query matching**: filter by Fachrichtung, Modalitaet, Kostentraeger, PLZ prefix, time range, SlotType, max_results +- **Tests**: 16 inline tests covering creation, signing, verification, tampering, forwarding, expiry, CBOR roundtrip, store dedup, sequence supersede, query filters (PLZ, SlotType, Kostentraeger, max_results) +- **Privacy model**: therapist identity public (Approbation-bound), patient queries anonymous + +### Design Decisions +- Extends announce.rs capability bitfield with CAP_FAPP_THERAPIST (0x0100), CAP_FAPP_RELAY (0x0200), CAP_FAPP_PATIENT (0x0400) +- Uses same signing pattern as MeshAnnounce: hop_count excluded from signature, forwarding nodes don't re-sign +- CBOR wire format consistent with existing envelope/announce code +- Location hint is PLZ only (e.g. "80331") — never exact address +- Anti-spam: Approbation hash binding, signature verification, sequence-based dedup, rate limiting, TTL enforcement + +### What's Next +- Integrate FAPP message handling into mesh_router.rs +- SlotReserve/SlotConfirm E2E encryption (X25519 key exchange) +- Return-path routing for anonymous SlotQuery responses +- Rate limiting per therapist address in FappStore + +--- + ## 2026-03-30 — Implementation Sprint (S4-S5 + MLS-Lite) ### Completed