diff --git a/contracts/health-records/src/lib.rs b/contracts/health-records/src/lib.rs index 30f47257..0f8dcbaf 100644 --- a/contracts/health-records/src/lib.rs +++ b/contracts/health-records/src/lib.rs @@ -79,6 +79,8 @@ pub enum DataKey { CategoryIndex(RecordCategory), // category -> Vec of record ids in that category, for prefix-style queries ProviderRegistry, RecordVersion(u64, u32), + /// Per-caller nonce for replay attack protection: (caller) -> u64 + CallerNonce(Address), } #[contracterror] @@ -94,6 +96,7 @@ pub enum Error { BatchTooLarge = 7, ProviderNotRegistered = 8, VersionNotFound = 9, + StaleNonce = 10, } #[soroban_sdk::contractclient(name = "ProviderRegistryClient")] @@ -571,6 +574,36 @@ impl HealthRecords { ) -> Vec { shared_get_by_corr(&env, correlation_id) } + + /// Verify and increment caller's nonce for cross-contract call protection. + /// Returns an error if the provided nonce is <= the last successful nonce. + fn verify_and_increment_nonce( + env: &Env, + caller: &Address, + provided_nonce: u64, + ) -> Result<(), Error> { + let nonce_key = DataKey::CallerNonce(caller.clone()); + let last_nonce: u64 = env + .storage() + .persistent() + .get(&nonce_key) + .unwrap_or(0); + + // Reject if provided nonce is not strictly greater than last successful nonce + if provided_nonce <= last_nonce { + return Err(Error::StaleNonce); + } + + // Update nonce to prevent replay + env.storage().persistent().set(&nonce_key, &provided_nonce); + Ok(()) + } + + /// Get the current nonce for a caller. + pub fn get_caller_nonce(env: Env, caller: Address) -> u64 { + let nonce_key = DataKey::CallerNonce(caller); + env.storage().persistent().get(&nonce_key).unwrap_or(0) + } } #[cfg(test)] diff --git a/contracts/hospital-registry/src/lib.rs b/contracts/hospital-registry/src/lib.rs index 086a0988..5a53ee69 100644 --- a/contracts/hospital-registry/src/lib.rs +++ b/contracts/hospital-registry/src/lib.rs @@ -26,6 +26,7 @@ pub enum ContractError { EmptyFieldUpdate = 6, InvalidAddress = 7, ConfigLimitExceeded = 8, + NotAuthorized = 9, } #[contracttype] @@ -144,6 +145,7 @@ pub struct AuditEvent { pub enum DataKey { Hospital(Address), HospitalConfig(Address), + Admin, } #[contract] @@ -210,6 +212,48 @@ impl HospitalRegistry { .publish((symbol_short!("audit"), caller.clone()), event); } + /// Check if caller is authorized to modify the hospital's config. + /// Authorized if: caller is the hospital OR caller is the admin. + fn assert_config_auth( + env: &Env, + caller: &Address, + hospital_address: &Address, + ) -> Result<(), ContractError> { + caller.require_auth(); + + // Hospital representative can update their own config + if caller == hospital_address { + return Ok(()); + } + + // Admin can update any hospital config + if let Some(admin) = env.storage().persistent().get::<_, Address>(&DataKey::Admin) { + if caller == &admin { + return Ok(()); + } + } + + Err(ContractError::NotAuthorized) + } + + /// Set the admin address. Only callable by the current admin (if set) or initially. + pub fn set_admin(env: Env, caller: Address, admin: Address) -> Result<(), ContractError> { + validate_nonzero_address(&admin).map_err(|_| ContractError::InvalidAddress)?; + caller.require_auth(); + + // Check if admin is already set + if let Some(current_admin) = env.storage().persistent().get::<_, Address>(&DataKey::Admin) { + // Only the current admin can change the admin + if caller != current_admin { + return Err(ContractError::NotAuthorized); + } + } + // If no admin is set yet, the caller can set themselves as admin (first-time init) + + env.storage().persistent().set(&DataKey::Admin, &admin); + Ok(()) + } + pub fn register_hospital( env: Env, wallet: Address, @@ -294,7 +338,7 @@ impl HospitalRegistry { config: HospitalConfig, ) -> Result<(), ContractError> { validate_nonzero_address(&wallet).map_err(|_| ContractError::InvalidAddress)?; - wallet.require_auth(); + Self::assert_config_auth(&env, &wallet, &wallet)?; Self::assert_active_hospital(&env, &wallet)?; if config.departments.len() > MAX_DEPARTMENTS { @@ -335,7 +379,7 @@ impl HospitalRegistry { departments: Vec, ) -> Result<(), ContractError> { validate_nonzero_address(&wallet).map_err(|_| ContractError::InvalidAddress)?; - wallet.require_auth(); + Self::assert_config_auth(&env, &wallet, &wallet)?; Self::assert_active_hospital(&env, &wallet)?; let mut config = Self::get_hospital_config(env.clone(), wallet.clone())?; if departments.is_empty() && !config.departments.is_empty() { @@ -359,7 +403,7 @@ impl HospitalRegistry { locations: Vec, ) -> Result<(), ContractError> { validate_nonzero_address(&wallet).map_err(|_| ContractError::InvalidAddress)?; - wallet.require_auth(); + Self::assert_config_auth(&env, &wallet, &wallet)?; Self::assert_active_hospital(&env, &wallet)?; let mut config = Self::get_hospital_config(env.clone(), wallet.clone())?; if locations.is_empty() && !config.locations.is_empty() { @@ -383,7 +427,7 @@ impl HospitalRegistry { equipment: Vec, ) -> Result<(), ContractError> { validate_nonzero_address(&wallet).map_err(|_| ContractError::InvalidAddress)?; - wallet.require_auth(); + Self::assert_config_auth(&env, &wallet, &wallet)?; Self::assert_active_hospital(&env, &wallet)?; let mut config = Self::get_hospital_config(env.clone(), wallet.clone())?; if equipment.is_empty() && !config.equipment.is_empty() { @@ -407,7 +451,7 @@ impl HospitalRegistry { policies: Vec, ) -> Result<(), ContractError> { validate_nonzero_address(&wallet).map_err(|_| ContractError::InvalidAddress)?; - wallet.require_auth(); + Self::assert_config_auth(&env, &wallet, &wallet)?; Self::assert_active_hospital(&env, &wallet)?; let mut config = Self::get_hospital_config(env.clone(), wallet.clone())?; let old = config.clone(); @@ -425,7 +469,7 @@ impl HospitalRegistry { alerts: Vec, ) -> Result<(), ContractError> { validate_nonzero_address(&wallet).map_err(|_| ContractError::InvalidAddress)?; - wallet.require_auth(); + Self::assert_config_auth(&env, &wallet, &wallet)?; Self::assert_active_hospital(&env, &wallet)?; let mut config = Self::get_hospital_config(env.clone(), wallet.clone())?; let old = config.clone(); @@ -443,7 +487,7 @@ impl HospitalRegistry { insurance_providers: Vec, ) -> Result<(), ContractError> { validate_nonzero_address(&wallet).map_err(|_| ContractError::InvalidAddress)?; - wallet.require_auth(); + Self::assert_config_auth(&env, &wallet, &wallet)?; Self::assert_active_hospital(&env, &wallet)?; let mut config = Self::get_hospital_config(env.clone(), wallet.clone())?; let old = config.clone(); @@ -461,7 +505,7 @@ impl HospitalRegistry { billing: BillingConfig, ) -> Result<(), ContractError> { validate_nonzero_address(&wallet).map_err(|_| ContractError::InvalidAddress)?; - wallet.require_auth(); + Self::assert_config_auth(&env, &wallet, &wallet)?; Self::assert_active_hospital(&env, &wallet)?; let mut config = Self::get_hospital_config(env.clone(), wallet.clone())?; let old = config.clone(); @@ -479,7 +523,7 @@ impl HospitalRegistry { protocols: Vec, ) -> Result<(), ContractError> { validate_nonzero_address(&wallet).map_err(|_| ContractError::InvalidAddress)?; - wallet.require_auth(); + Self::assert_config_auth(&env, &wallet, &wallet)?; Self::assert_active_hospital(&env, &wallet)?; let mut config = Self::get_hospital_config(env.clone(), wallet.clone())?; let old = config.clone(); diff --git a/contracts/patient-registry/src/lib.rs b/contracts/patient-registry/src/lib.rs index c3673b2d..ad9fd6f9 100644 --- a/contracts/patient-registry/src/lib.rs +++ b/contracts/patient-registry/src/lib.rs @@ -158,6 +158,8 @@ pub enum DataKey { MerkleRoot(Address), /// Optional provider-registry contract address for cross-contract verification. ProviderRegistry, + /// Per-caller nonce for replay attack protection: (caller) -> u64 + CallerNonce(Address), } /// -------------------- @@ -292,6 +294,7 @@ pub enum ContractError { ProviderNotRegistered = 28, /// A Vec parameter or accumulator exceeds its maximum allowed length. InputTooLarge = 29, + StaleNonce = 30, } pub fn validate_cid(cid: &Bytes) -> Result<(), ContractError> { @@ -2778,6 +2781,36 @@ impl MedicalRegistry { extend_for_retention_class(env, key, &class); } } + + /// Verify and increment caller's nonce for cross-contract call protection. + /// Returns an error if the provided nonce is <= the last successful nonce. + fn verify_and_increment_nonce( + env: &Env, + caller: &Address, + provided_nonce: u64, + ) -> Result<(), ContractError> { + let nonce_key = DataKey::CallerNonce(caller.clone()); + let last_nonce: u64 = env + .storage() + .persistent() + .get(&nonce_key) + .unwrap_or(0); + + // Reject if provided nonce is not strictly greater than last successful nonce + if provided_nonce <= last_nonce { + return Err(ContractError::StaleNonce); + } + + // Update nonce to prevent replay + env.storage().persistent().set(&nonce_key, &provided_nonce); + Ok(()) + } + + /// Get the current nonce for a caller. + pub fn get_caller_nonce(env: Env, caller: Address) -> u64 { + let nonce_key = DataKey::CallerNonce(caller); + env.storage().persistent().get(&nonce_key).unwrap_or(0) + } } #[cfg(test)] diff --git a/contracts/prescription-management/src/lib.rs b/contracts/prescription-management/src/lib.rs index d476f2df..9ce67b66 100644 --- a/contracts/prescription-management/src/lib.rs +++ b/contracts/prescription-management/src/lib.rs @@ -149,6 +149,7 @@ pub enum Error { MissingRecallReason = 28, /// Patient already has MAX_ACTIVE_PRESCRIPTIONS active prescriptions TooManyActivePrescriptions = 29, + StaleNonce = 30, } #[contracttype] @@ -258,6 +259,8 @@ pub enum DataKey { /// `count_and_prune_active_prescriptions`), so it stays close to the /// patient's actual active count rather than growing unboundedly. PatientPrescriptions(Address), + /// Per-caller nonce for replay attack protection: (caller) -> u64 + CallerNonce(Address), } #[contracttype] @@ -1567,23 +1570,34 @@ impl PrescriptionContract { false } - /// Retrieve the medication names of all active prescriptions for a patient. - /// Used by cross-contract callers (e.g. nutrition-care-management) to check - /// for drug-nutrient contraindications. - pub fn get_patient_active_prescriptions(env: Env, patient: Address) -> Vec { - let now = env.ledger().timestamp(); - let key = DataKey::PatientPrescriptions(patient); - let ids: Vec = env.storage().persistent().get(&key).unwrap_or(Vec::new(&env)); - - let mut active_meds: Vec = Vec::new(&env); - for id in ids.iter() { - if let Some(prescription) = env.storage().persistent().get::<_, Prescription>(&id) { - if is_prescription_active(&prescription, now) { - active_meds.push_back(prescription.medication_name); - } - } + /// Verify and increment caller's nonce for cross-contract call protection. + /// Returns an error if the provided nonce is <= the last successful nonce. + fn verify_and_increment_nonce( + env: &Env, + caller: &Address, + provided_nonce: u64, + ) -> Result<(), Error> { + let nonce_key = DataKey::CallerNonce(caller.clone()); + let last_nonce: u64 = env + .storage() + .persistent() + .get(&nonce_key) + .unwrap_or(0); + + // Reject if provided nonce is not strictly greater than last successful nonce + if provided_nonce <= last_nonce { + return Err(Error::StaleNonce); } - active_meds + + // Update nonce to prevent replay + env.storage().persistent().set(&nonce_key, &provided_nonce); + Ok(()) + } + + /// Get the current nonce for a caller. + pub fn get_caller_nonce(env: Env, caller: Address) -> u64 { + let nonce_key = DataKey::CallerNonce(caller); + env.storage().persistent().get(&nonce_key).unwrap_or(0) } } diff --git a/contracts/provider-registry/src/lib.rs b/contracts/provider-registry/src/lib.rs index 573ea943..43c982af 100644 --- a/contracts/provider-registry/src/lib.rs +++ b/contracts/provider-registry/src/lib.rs @@ -30,6 +30,7 @@ pub enum Error { NoRotationPending = 9, RotationExpired = 10, NotPendingAdmin = 11, + StaleNonce = 12, } /// Input entry for `batch_register_providers`. @@ -95,6 +96,8 @@ pub enum DataKey { ProviderRatingByPatient(Address, Address), PendingAdmin, RotationExpiry, + /// Per-caller nonce for replay attack protection: (caller) -> u64 + CallerNonce(Address), } // ── Contract ────────────────────────────────────────────────────────────────── @@ -344,4 +347,34 @@ impl ProviderRegistry { } Ok(()) } + + /// Verify and increment caller's nonce for cross-contract call protection. + /// Returns an error if the provided nonce is <= the last successful nonce. + fn verify_and_increment_nonce( + env: &Env, + caller: &Address, + provided_nonce: u64, + ) -> Result<(), Error> { + let nonce_key = DataKey::CallerNonce(caller.clone()); + let last_nonce: u64 = env + .storage() + .persistent() + .get(&nonce_key) + .unwrap_or(0); + + // Reject if provided nonce is not strictly greater than last successful nonce + if provided_nonce <= last_nonce { + return Err(Error::StaleNonce); + } + + // Update nonce to prevent replay + env.storage().persistent().set(&nonce_key, &provided_nonce); + Ok(()) + } + + /// Get the current nonce for a caller. + pub fn get_caller_nonce(env: Env, caller: Address) -> u64 { + let nonce_key = DataKey::CallerNonce(caller); + env.storage().persistent().get(&nonce_key).unwrap_or(0) + } } diff --git a/contracts/telemedicine/src/contract.rs b/contracts/telemedicine/src/contract.rs index e86b11d5..6bba308d 100644 --- a/contracts/telemedicine/src/contract.rs +++ b/contracts/telemedicine/src/contract.rs @@ -1,6 +1,6 @@ use crate::types::{ DataKey, EligibilityResult, Error, JurisdictionPolicy, ProviderLicense, PrescriptionRequest, - SessionRecord, VirtualVisit, VisitStatus, + ProviderSessionWindow, RateLimitConfig, SessionRecord, VirtualVisit, VisitStatus, }; use soroban_sdk::{ contract, contractclient, contractimpl, panic_with_error, xdr::ToXdr, Address, Bytes, BytesN, @@ -8,6 +8,8 @@ use soroban_sdk::{ }; const SESSION_TTL_SECONDS: u64 = 60 * 60; +const DEFAULT_MAX_SESSIONS_PER_WINDOW: u32 = 20; +const DEFAULT_WINDOW_DURATION_SECS: u64 = 86_400; // 24 hours // ── Cross-contract interface for provider-registry verification ─────────────── @@ -21,15 +23,72 @@ pub struct TelemedicineContract; #[contractimpl] impl TelemedicineContract { - /// Initialize the contract with the provider-registry address. - /// Can only be called once. - pub fn initialize(env: Env, provider_registry: Address) -> Result<(), Error> { - if env.storage().instance().has(&DataKey::ProviderRegistryAddress) { - panic_with_error!(&env, Error::NotAuthorized); + /// Get the current rate limit configuration, or return defaults if not set. + fn get_rate_limit_config(env: &Env) -> RateLimitConfig { + env.storage() + .persistent() + .get::<_, RateLimitConfig>(&DataKey::RateLimitConfig) + .unwrap_or(RateLimitConfig { + max_sessions_per_window: DEFAULT_MAX_SESSIONS_PER_WINDOW, + window_duration_secs: DEFAULT_WINDOW_DURATION_SECS, + }) + } + + /// Check if provider has exceeded their session creation rate limit. + /// If not exceeded, increments counter and returns Ok. + /// If window has expired, resets the counter. + fn check_and_update_rate_limit( + env: &Env, + provider_id: &Address, + ) -> Result<(), Error> { + let config = Self::get_rate_limit_config(env); + let now = env.ledger().timestamp(); + let window_key = DataKey::ProviderSessionWindow(provider_id.clone()); + + let mut window: ProviderSessionWindow = env + .storage() + .persistent() + .get(&window_key) + .unwrap_or(ProviderSessionWindow { + session_count: 0, + window_start: now, + }); + + // Check if window has expired and reset if needed + if now >= window.window_start + config.window_duration_secs { + window.session_count = 0; + window.window_start = now; } + + // Check if limit would be exceeded + if window.session_count >= config.max_sessions_per_window { + return Err(Error::RateLimitExceeded); + } + + // Increment counter and save + window.session_count += 1; + env.storage().persistent().set(&window_key, &window); + + Ok(()) + } + + /// Set the rate limit configuration. Callable by any admin. + pub fn set_rate_limit_config( + env: Env, + admin: Address, + max_sessions_per_window: u32, + window_duration_secs: u64, + ) -> Result<(), Error> { + admin.require_auth(); + + let config = RateLimitConfig { + max_sessions_per_window, + window_duration_secs, + }; + env.storage() - .instance() - .set(&DataKey::ProviderRegistryAddress, &provider_registry); + .persistent() + .set(&DataKey::RateLimitConfig, &config); Ok(()) } @@ -152,6 +211,9 @@ impl TelemedicineContract { ) -> Result, Error> { provider_id.require_auth(); + // Check rate limit: max sessions per provider per window + Self::check_and_update_rate_limit(&env, &provider_id)?; + let mut visit: VirtualVisit = env .storage() .persistent() diff --git a/contracts/telemedicine/src/types.rs b/contracts/telemedicine/src/types.rs index f1221669..3c3d5510 100644 --- a/contracts/telemedicine/src/types.rs +++ b/contracts/telemedicine/src/types.rs @@ -17,8 +17,7 @@ pub enum Error { CrossStateNotPermitted = 11, /// Recording metadata cannot be stored without explicit patient consent RecordingConsentRequired = 12, - /// Provider is not registered or active in the provider-registry - ProviderNotRegistered = 13, + RateLimitExceeded = 13, } /// On-chain record of a provider's license in a given jurisdiction (state/region). @@ -100,6 +99,24 @@ pub struct SessionRecord { pub used: bool, } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProviderSessionWindow { + /// Number of sessions created in current window + pub session_count: u32, + /// Timestamp when the current window started + pub window_start: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RateLimitConfig { + /// Maximum sessions per provider per window (in seconds) + pub max_sessions_per_window: u32, + /// Window duration in seconds (default: 86400 = 24 hours) + pub window_duration_secs: u64, +} + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum DataKey { @@ -111,6 +128,8 @@ pub enum DataKey { LicenseRegistry(Address, String), /// jurisdiction -> JurisdictionPolicy JurisdictionPolicy(String), - /// Address of the provider-registry contract for cross-contract verification - ProviderRegistryAddress, + /// Provider session rate limit window: (provider_id) -> ProviderSessionWindow + ProviderSessionWindow(Address), + /// Global rate limit configuration + RateLimitConfig, }