From 96f2b55b187c8543536538e1d7292cf1bc126b0c Mon Sep 17 00:00:00 2001 From: saboleee Date: Mon, 29 Jun 2026 17:42:09 +0100 Subject: [PATCH 1/3] =?UTF-8?q?security:=20hospital-registry=20=E2=80=94?= =?UTF-8?q?=20add=20auth=20check=20on=20update=5Fhospital=5Fconfig=20(#514?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add NotAuthorized error to ContractError enum - Add Admin storage key to DataKey enum - Add assert_config_auth() helper to verify caller is hospital or admin - Add set_admin() method to configure contract admin - Update all config methods to use assert_config_auth() instead of wallet.require_auth() - Hospital representative can update their own config - Admin can update any hospital config - Third-party addresses are rejected with NotAuthorized --- contracts/hospital-registry/src/lib.rs | 62 ++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 9 deletions(-) diff --git a/contracts/hospital-registry/src/lib.rs b/contracts/hospital-registry/src/lib.rs index 74bcff5a..0ca06e79 100644 --- a/contracts/hospital-registry/src/lib.rs +++ b/contracts/hospital-registry/src/lib.rs @@ -27,6 +27,7 @@ pub enum ContractError { EmptyFieldUpdate = 6, InvalidAddress = 7, ConfigLimitExceeded = 8, + NotAuthorized = 9, } #[contracttype] @@ -145,6 +146,7 @@ pub struct AuditEvent { pub enum DataKey { Hospital(Address), HospitalConfig(Address), + Admin, } #[contract] @@ -211,6 +213,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, @@ -295,7 +339,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 { @@ -336,7 +380,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() { @@ -360,7 +404,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() { @@ -384,7 +428,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() { @@ -408,7 +452,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(); @@ -426,7 +470,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(); @@ -444,7 +488,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(); @@ -462,7 +506,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(); @@ -480,7 +524,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(); From 7d2217a3430348bae18f6c9bd6de783ac78a5e60 Mon Sep 17 00:00:00 2001 From: saboleee Date: Mon, 29 Jun 2026 17:48:42 +0100 Subject: [PATCH 2/3] =?UTF-8?q?security:=20telemedicine=20=E2=80=94=20add?= =?UTF-8?q?=20rate-limit=20for=20session=20creation=20to=20prevent=20DoS?= =?UTF-8?q?=20(#512)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add RateLimitExceeded error to Error enum - Add ProviderSessionWindow struct to track session counts per provider - Add RateLimitConfig struct for configurable rate limiting - Add ProviderSessionWindow and RateLimitConfig to DataKey enum - Add check_and_update_rate_limit() helper to enforce limits - Add get_rate_limit_config() to get config or return defaults - Add set_rate_limit_config() method for admin to configure limits - Default: 20 sessions per 24-hour window per provider - Window automatically resets after 24 hours - Rate limit check added to start_virtual_session() before processing --- contracts/telemedicine/src/contract.rs | 76 +++++++++++++++++++++++++- contracts/telemedicine/src/types.rs | 23 ++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/contracts/telemedicine/src/contract.rs b/contracts/telemedicine/src/contract.rs index 87601ca8..eef77646 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, contractimpl, panic_with_error, xdr::ToXdr, Address, Bytes, BytesN, Env, String, @@ -8,12 +8,83 @@ 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 #[contract] pub struct TelemedicineContract; #[contractimpl] impl TelemedicineContract { + /// 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() + .persistent() + .set(&DataKey::RateLimitConfig, &config); + Ok(()) + } + pub fn schedule_virtual_visit( env: Env, patient_id: Address, @@ -121,6 +192,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 d9a3eeb6..3c3d5510 100644 --- a/contracts/telemedicine/src/types.rs +++ b/contracts/telemedicine/src/types.rs @@ -17,6 +17,7 @@ pub enum Error { CrossStateNotPermitted = 11, /// Recording metadata cannot be stored without explicit patient consent RecordingConsentRequired = 12, + RateLimitExceeded = 13, } /// On-chain record of a provider's license in a given jurisdiction (state/region). @@ -98,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 { @@ -109,4 +128,8 @@ pub enum DataKey { LicenseRegistry(Address, String), /// jurisdiction -> JurisdictionPolicy JurisdictionPolicy(String), + /// Provider session rate limit window: (provider_id) -> ProviderSessionWindow + ProviderSessionWindow(Address), + /// Global rate limit configuration + RateLimitConfig, } From 1930cf8152802de0fab46605e33de7524d77b966 Mon Sep 17 00:00:00 2001 From: saboleee Date: Mon, 29 Jun 2026 18:03:01 +0100 Subject: [PATCH 3/3] security: add replay-attack protection with nonces to cross-contract calls (#513) - Add StaleNonce error to all involved contracts - Add CallerNonce(Address) DataKey to track per-caller nonces - Add verify_and_increment_nonce() method to validate and update nonces - Add get_caller_nonce() method to query current caller nonce - Nonce must be strictly greater than last successful nonce to be accepted - Prevents replay attacks on cross-contract calls - Implemented in: - provider-registry - patient-registry - prescription-management - health-records --- contracts/health-records/src/lib.rs | 33 ++++++++++++++++++++ contracts/patient-registry/src/lib.rs | 33 ++++++++++++++++++++ contracts/prescription-management/src/lib.rs | 33 ++++++++++++++++++++ contracts/provider-registry/src/lib.rs | 33 ++++++++++++++++++++ 4 files changed, 132 insertions(+) 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/patient-registry/src/lib.rs b/contracts/patient-registry/src/lib.rs index 4a391257..7265d782 100644 --- a/contracts/patient-registry/src/lib.rs +++ b/contracts/patient-registry/src/lib.rs @@ -159,6 +159,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), } /// -------------------- @@ -293,6 +295,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> { @@ -2779,6 +2782,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 6beef7e5..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] @@ -1566,6 +1569,36 @@ impl PrescriptionContract { } false } + + /// 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) + } } /// Inner implementation shared by `issue_prescription` and `issue_from_template`. diff --git a/contracts/provider-registry/src/lib.rs b/contracts/provider-registry/src/lib.rs index 93bba2c9..0d6bdb0a 100644 --- a/contracts/provider-registry/src/lib.rs +++ b/contracts/provider-registry/src/lib.rs @@ -31,6 +31,7 @@ pub enum Error { NoRotationPending = 9, RotationExpired = 10, NotPendingAdmin = 11, + StaleNonce = 12, } /// Input entry for `batch_register_providers`. @@ -96,6 +97,8 @@ pub enum DataKey { ProviderRatingByPatient(Address, Address), PendingAdmin, RotationExpiry, + /// Per-caller nonce for replay attack protection: (caller) -> u64 + CallerNonce(Address), } // ── Contract ────────────────────────────────────────────────────────────────── @@ -345,4 +348,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) + } }