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)
This commit is contained in:
@@ -408,6 +408,254 @@ pub struct SlotConfirm {
|
||||
pub confirmed: bool,
|
||||
/// Appointment details, encrypted to patient's ephemeral key.
|
||||
pub encrypted_details: Vec<u8>,
|
||||
/// 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<Vec<u8>, 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<SlotConfirm, FappCryptoError> {
|
||||
// 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<SlotReserve, FappCryptoError> {
|
||||
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<Vec<u8>, 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::<sha2::Sha256>::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<Vec<u8>, 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<Vec<u8>, 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<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 reserve: Self = ciborium::from_reader(bytes)?;
|
||||
Ok(reserve)
|
||||
}
|
||||
}
|
||||
|
||||
impl SlotConfirm {
|
||||
/// 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 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<Item = &Vec<SlotAnnounce>> {
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SlotQuery> {
|
||||
Ok(q)
|
||||
}
|
||||
|
||||
fn slot_reserve_from_wire(bytes: &[u8]) -> Result<SlotReserve> {
|
||||
let r: SlotReserve = ciborium::from_reader(bytes)?;
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn slot_confirm_from_wire(bytes: &[u8]) -> Result<SlotConfirm> {
|
||||
let c: SlotConfirm = ciborium::from_reader(bytes)?;
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
fn slot_response_to_wire(response: &SlotResponse) -> Vec<u8> {
|
||||
let mut buf = Vec::new();
|
||||
ciborium::into_writer(response, &mut buf).expect("SlotResponse CBOR");
|
||||
buf
|
||||
}
|
||||
|
||||
fn slot_response_from_wire(bytes: &[u8]) -> Result<SlotResponse> {
|
||||
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<TransportAddr> {
|
||||
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<Vec<(TransportAddr, Vec<u8>)>> {
|
||||
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))));
|
||||
|
||||
Reference in New Issue
Block a user