docs/specs/fapp-security.md: - Full threat model for patient protection - 3-level verification roadmap (transparency → endorsements → registry) - UI warning mockups - Technical implementation plan - Honest assessment of limitations SlotAnnounce changes: - Added profile_url field for therapist verification - New with_profile() constructor - profile_url included in signature docs/specs/fapp-protocol.md: - Added Security & Anti-Fraud section - Link to full security spec
1128 lines
36 KiB
Rust
1128 lines
36 KiB
Rust
//! 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`.
|
|
///
|
|
/// # Security Note
|
|
///
|
|
/// Patients should verify therapists before booking. The `profile_url` field
|
|
/// allows cross-referencing with official sources (Jameda, KBV, practice website).
|
|
/// See `docs/specs/fapp-security.md` for the full security model.
|
|
#[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<Fachrichtung>,
|
|
/// Session modalities.
|
|
pub modalitaet: Vec<Modalitaet>,
|
|
/// Accepted insurance / payment types.
|
|
pub kostentraeger: Vec<Kostentraeger>,
|
|
/// Postal code (PLZ) only — never exact address.
|
|
pub location_hint: String,
|
|
/// Available time slots.
|
|
pub slots: Vec<TimeSlot>,
|
|
/// SHA-256 of the therapist's Approbation number.
|
|
pub approbation_hash: [u8; 32],
|
|
/// Optional URL to therapist's public profile for verification.
|
|
/// Examples: Jameda profile, KBV listing, practice website.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub profile_url: Option<String>,
|
|
/// 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<u8>,
|
|
/// 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<Fachrichtung>,
|
|
modalitaet: Vec<Modalitaet>,
|
|
kostentraeger: Vec<Kostentraeger>,
|
|
location_hint: String,
|
|
slots: Vec<TimeSlot>,
|
|
approbation_hash: [u8; 32],
|
|
sequence: u64,
|
|
) -> Self {
|
|
Self::with_profile(
|
|
identity,
|
|
fachrichtung,
|
|
modalitaet,
|
|
kostentraeger,
|
|
location_hint,
|
|
slots,
|
|
approbation_hash,
|
|
sequence,
|
|
None,
|
|
)
|
|
}
|
|
|
|
/// Create and sign a new slot announcement with optional profile URL.
|
|
///
|
|
/// The `profile_url` allows patients to verify the therapist's identity
|
|
/// against official sources (Jameda, KBV, practice website).
|
|
pub fn with_profile(
|
|
identity: &MeshIdentity,
|
|
fachrichtung: Vec<Fachrichtung>,
|
|
modalitaet: Vec<Modalitaet>,
|
|
kostentraeger: Vec<Kostentraeger>,
|
|
location_hint: String,
|
|
slots: Vec<TimeSlot>,
|
|
approbation_hash: [u8; 32],
|
|
sequence: u64,
|
|
profile_url: Option<String>,
|
|
) -> 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,
|
|
profile_url,
|
|
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<u8> {
|
|
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);
|
|
|
|
// profile_url is signed to prevent tampering.
|
|
if let Some(ref url) = self.profile_url {
|
|
buf.push(0x01); // present marker
|
|
buf.extend_from_slice(url.as_bytes());
|
|
} else {
|
|
buf.push(0x00); // absent marker
|
|
}
|
|
buf.push(0xFF);
|
|
|
|
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<u8> {
|
|
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<Self> {
|
|
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<Fachrichtung>,
|
|
/// Filter by modality.
|
|
pub modalitaet: Option<Modalitaet>,
|
|
/// Filter by insurance type.
|
|
pub kostentraeger: Option<Kostentraeger>,
|
|
/// Filter by PLZ prefix (e.g. "80" for Munich area).
|
|
pub plz_prefix: Option<String>,
|
|
/// Earliest acceptable slot (Unix seconds).
|
|
pub earliest: Option<u64>,
|
|
/// Latest acceptable slot (Unix seconds).
|
|
pub latest: Option<u64>,
|
|
/// Filter by appointment type.
|
|
pub slot_type: Option<SlotType>,
|
|
/// 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<SlotAnnounce>,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<u8>,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<u8>,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<SlotAnnounce>>,
|
|
/// 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;
|
|
}
|
|
}
|
|
|
|
let addr = announce.therapist_address;
|
|
|
|
// Capacity check per therapist (evict oldest if needed).
|
|
{
|
|
let queue = self.announces.entry(addr).or_default();
|
|
if queue.len() >= MAX_PER_THERAPIST {
|
|
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(addr, announce.sequence);
|
|
|
|
self.announces.entry(addr).or_default().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<TimeSlot> {
|
|
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());
|
|
}
|
|
}
|