From 11b68e67d3201d92c8764c8825f61cc57fd5d535 Mon Sep 17 00:00:00 2001 From: CeceOs92 Date: Tue, 23 Jun 2026 08:27:43 +0100 Subject: [PATCH] feat: health authority contract and tests --- README.md | 4 +- contracts/src/errors.rs | 4 + contracts/src/health_authority.rs | 203 ++++++++++++++++++++++++ contracts/src/health_authority_tests.rs | 89 +++++++++++ contracts/src/lib.rs | 5 + contracts/src/test.rs | 2 - 6 files changed, 303 insertions(+), 4 deletions(-) create mode 100644 contracts/src/health_authority.rs create mode 100644 contracts/src/health_authority_tests.rs diff --git a/README.md b/README.md index f59fb948..567a204c 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,8 @@ A decentralized health credential platform built on the Stellar Soroban Smart Co #### Health Authority Contract - Manages authorized health authority issuers - Validates credential issuance from certified authorities -- Stores health authority public keys and signatures -- Enables credential authority verification +- Stores authority Ed25519 public keys and active/suspended status +- Verifies authority signatures against the registered key #### Upgrade Mechanism - **Proxy Pattern**: Seamless contract upgrades without data loss diff --git a/contracts/src/errors.rs b/contracts/src/errors.rs index 09be593e..bb5cf42d 100644 --- a/contracts/src/errors.rs +++ b/contracts/src/errors.rs @@ -26,4 +26,8 @@ pub enum Error { IssuerNotAuthorized = 18, CredentialRevoked = 19, CredentialNotFound = 20, + // Health authority errors + AuthorityNotFound = 21, + AuthorityAlreadyRegistered = 22, + AuthorityInactive = 23, } diff --git a/contracts/src/health_authority.rs b/contracts/src/health_authority.rs new file mode 100644 index 00000000..c060ed70 --- /dev/null +++ b/contracts/src/health_authority.rs @@ -0,0 +1,203 @@ +//! Registry for the public keys of health-credential issuers. +//! +//! An authority owns its entry: registration, key rotation, and status updates +//! all require authorization from the authority address. Credential consumers +//! can use [`HealthAuthority::validate_authority_signature`] to verify an +//! Ed25519 signature against the currently registered key. + +use soroban_sdk::{contract, contractimpl, contracttype, Address, Bytes, BytesN, Env}; + +use crate::errors::Error; + +/// Public, on-chain metadata for a health authority. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Authority { + /// The account that controls this authority's registry entry. + pub address: Address, + /// The authority's Ed25519 verification key. + pub public_key: BytesN<32>, + /// Whether signatures from this authority may currently be trusted. + pub is_active: bool, + pub registered_at: u64, + pub updated_at: u64, +} + +#[contracttype] +pub enum DataKey { + Authority(Address), +} + +// Persistent authority entries are refreshed to roughly one year whenever +// they are read or written. This avoids an active issuer being archived while +// keeping the registry scalable: every operation touches only one entry. +const PERSISTENT_TTL_THRESHOLD: u32 = 1_051_200; // ~61 days +const PERSISTENT_TTL_EXTEND: u32 = 6_307_200; // ~365 days + +#[contract] +pub struct HealthAuthority; + +#[contractimpl] +impl HealthAuthority { + /// Register an authority and its Ed25519 public key. + /// + /// The authority must authorise registration, preventing a third party + /// from registering a key for an address it does not control. + pub fn register_authority( + env: &Env, + authority: Address, + public_key: BytesN<32>, + ) -> Result<(), Error> { + authority.require_auth(); + + let key = DataKey::Authority(authority.clone()); + if env.storage().persistent().has(&key) { + return Err(Error::AuthorityAlreadyRegistered); + } + + let now = env.ledger().timestamp(); + let record = Authority { + address: authority, + public_key, + is_active: true, + registered_at: now, + updated_at: now, + }; + + write_authority(env, &key, &record); + Ok(()) + } + + /// Return an authority's public registry record. + pub fn get_authority(env: &Env, authority: Address) -> Result { + read_authority(env, authority) + } + + /// Return an authority's current Ed25519 public key. + pub fn get_authority_public_key(env: &Env, authority: Address) -> Result, Error> { + Ok(read_authority(env, authority)?.public_key) + } + + /// Rotate an authority's signing key. + pub fn update_authority_public_key( + env: &Env, + authority: Address, + public_key: BytesN<32>, + ) -> Result<(), Error> { + authority.require_auth(); + + let key = DataKey::Authority(authority.clone()); + let mut record = read_authority_by_key(env, &key)?; + record.public_key = public_key; + record.updated_at = env.ledger().timestamp(); + + write_authority(env, &key, &record); + Ok(()) + } + + /// Activate or suspend an authority. Only the authority can change its + /// own status. + pub fn set_authority_status( + env: &Env, + authority: Address, + is_active: bool, + ) -> Result<(), Error> { + authority.require_auth(); + + let key = DataKey::Authority(authority.clone()); + let mut record = read_authority_by_key(env, &key)?; + record.is_active = is_active; + record.updated_at = env.ledger().timestamp(); + + write_authority(env, &key, &record); + Ok(()) + } + + /// Convenience operation for suspending an authority. + pub fn deactivate_authority(env: &Env, authority: Address) -> Result<(), Error> { + Self::set_authority_status(env, authority, false) + } + + /// Convenience operation for restoring a suspended authority. + pub fn activate_authority(env: &Env, authority: Address) -> Result<(), Error> { + Self::set_authority_status(env, authority, true) + } + + /// Returns `true` only when an authority is registered and active. + /// + /// This deliberately returns `false` for unknown addresses so consumers + /// can use it as a safe, non-panicking trust check. + pub fn verify_authority(env: &Env, authority: Address) -> bool { + let key = DataKey::Authority(authority); + match env.storage().persistent().get::<_, Authority>(&key) { + Some(record) => { + bump_persistent(env, &key); + record.is_active + } + None => false, + } + } + + /// Alias for callers that want to check status without fetching metadata. + pub fn is_authority_active(env: &Env, authority: Address) -> bool { + Self::verify_authority(env, authority) + } + + /// Validate an Ed25519 signature made by an active authority. + /// + /// Soroban's crypto host function aborts the invocation when the signature + /// is malformed or does not match the supplied message/key. A successful + /// return therefore means the signature is valid. Unknown and inactive + /// authorities instead return a typed contract error. + pub fn validate_authority_signature( + env: &Env, + authority: Address, + message: Bytes, + signature: BytesN<64>, + ) -> Result { + let record = read_authority(env, authority)?; + if !record.is_active { + return Err(Error::AuthorityInactive); + } + + env.crypto() + .ed25519_verify(&record.public_key, &message, &signature); + Ok(true) + } + + /// Compatibility-friendly name for signature validation. + pub fn verify_authority_signature( + env: &Env, + authority: Address, + message: Bytes, + signature: BytesN<64>, + ) -> Result { + Self::validate_authority_signature(env, authority, message, signature) + } +} + +fn read_authority(env: &Env, authority: Address) -> Result { + let key = DataKey::Authority(authority); + read_authority_by_key(env, &key) +} + +fn read_authority_by_key(env: &Env, key: &DataKey) -> Result { + let record = env + .storage() + .persistent() + .get(key) + .ok_or(Error::AuthorityNotFound)?; + bump_persistent(env, key); + Ok(record) +} + +fn write_authority(env: &Env, key: &DataKey, authority: &Authority) { + env.storage().persistent().set(key, authority); + bump_persistent(env, key); +} + +fn bump_persistent(env: &Env, key: &DataKey) { + env.storage() + .persistent() + .extend_ttl(key, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND); +} diff --git a/contracts/src/health_authority_tests.rs b/contracts/src/health_authority_tests.rs new file mode 100644 index 00000000..15d42053 --- /dev/null +++ b/contracts/src/health_authority_tests.rs @@ -0,0 +1,89 @@ +extern crate std; + +use soroban_sdk::{testutils::Address as _, Address, Bytes, BytesN, Env}; + +use crate::health_authority::{HealthAuthority, HealthAuthorityClient}; + +fn setup() -> (Env, HealthAuthorityClient<'static>, Address) { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, HealthAuthority {}); + let client = HealthAuthorityClient::new(&env, &contract_id); + let authority = Address::generate(&env); + + (env, client, authority) +} + +fn rfc8032_public_key(env: &Env) -> BytesN<32> { + BytesN::from_array( + env, + &[ + 0xd7, 0x5a, 0x98, 0x01, 0x82, 0xb1, 0x0a, 0xb7, 0xd5, 0x4b, 0xfe, 0xd3, 0xc9, 0x64, + 0x07, 0x3a, 0x0e, 0xe1, 0x72, 0xf3, 0xda, 0xa6, 0x23, 0x25, 0xaf, 0x02, 0x1a, 0x68, + 0xf7, 0x07, 0x51, 0x1a, + ], + ) +} + +fn rfc8032_empty_message_signature(env: &Env) -> BytesN<64> { + BytesN::from_array( + env, + &[ + 0xe5, 0x56, 0x43, 0x00, 0xc3, 0x60, 0xac, 0x72, 0x90, 0x86, 0xe2, 0xcc, 0x80, 0x6e, + 0x82, 0x8a, 0x84, 0x87, 0x7f, 0x1e, 0xb8, 0xe5, 0xd9, 0x74, 0xd8, 0x73, 0xe0, 0x65, + 0x22, 0x49, 0x01, 0x55, 0x5f, 0xb8, 0x82, 0x15, 0x90, 0xa3, 0x3b, 0xac, 0xc6, 0x1e, + 0x39, 0x70, 0x1c, 0xf9, 0xb4, 0x6b, 0xd2, 0x5b, 0xf5, 0xf0, 0x59, 0x5b, 0xbe, 0x24, + 0x65, 0x51, 0x41, 0x43, 0x8e, 0x7a, 0x10, 0x0b, + ], + ) +} + +#[test] +fn registration_stores_the_authority_key() { + let (env, client, authority) = setup(); + let public_key = rfc8032_public_key(&env); + + client.register_authority(&authority, &public_key); + + let record = client.get_authority(&authority); + assert_eq!(record.address, authority); + assert_eq!(record.public_key, public_key); + assert!(record.is_active); + assert_eq!(client.get_authority_public_key(&authority), public_key); + assert!(client.verify_authority(&authority)); +} + +#[test] +fn authority_status_can_be_managed() { + let (env, client, authority) = setup(); + client.register_authority(&authority, &rfc8032_public_key(&env)); + + client.deactivate_authority(&authority); + assert!(!client.is_authority_active(&authority)); + + client.activate_authority(&authority); + assert!(client.is_authority_active(&authority)); +} + +#[test] +fn valid_authority_signature_is_accepted() { + let (env, client, authority) = setup(); + client.register_authority(&authority, &rfc8032_public_key(&env)); + + let message = Bytes::new(&env); + let signature = rfc8032_empty_message_signature(&env); + assert!(client.validate_authority_signature(&authority, &message, &signature)); +} + +#[test] +fn rotating_an_authority_key_replaces_the_stored_key() { + let (env, client, authority) = setup(); + let original_key = rfc8032_public_key(&env); + let replacement_key = BytesN::from_array(&env, &[7; 32]); + client.register_authority(&authority, &original_key); + + client.update_authority_public_key(&authority, &replacement_key); + + assert_eq!(client.get_authority_public_key(&authority), replacement_key); +} diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index b3dc34c0..dc8715ad 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -5,6 +5,7 @@ pub mod auditing; pub mod data_sharing; pub mod errors; pub mod events; +pub mod health_authority; pub mod identity_registry; pub mod storage; pub mod types; @@ -20,11 +21,15 @@ mod integration_tests; #[cfg(test)] mod benchmarks; +#[cfg(test)] +mod health_authority_tests; + #[cfg(test)] mod test; pub use access_control::AccessControl; pub use data_sharing::DataSharing; pub use errors::Error; +pub use health_authority::HealthAuthority; pub use identity_registry::IdentityRegistry; pub use verification::Verification; diff --git a/contracts/src/test.rs b/contracts/src/test.rs index 171344f4..e83b6ae5 100644 --- a/contracts/src/test.rs +++ b/contracts/src/test.rs @@ -1,5 +1,3 @@ -#![cfg(test)] - use crate::auditing::{Auditing, AuditingClient}; use soroban_sdk::{testutils::Address as _, Address, BytesN, Env, String, Symbol};