diff --git a/AUDIT_TRAIL_RUNBOOK.md b/AUDIT_TRAIL_RUNBOOK.md new file mode 100644 index 0000000..ba535ba --- /dev/null +++ b/AUDIT_TRAIL_RUNBOOK.md @@ -0,0 +1,50 @@ +# Audit Trail Hash Chain Runbook + +## Purpose + +The utility contract maintains a compact, tamper-evident audit trail for critical service operations. Each audit entry commits to the previous entry hash, operation metadata, and a privacy-preserving payload digest so off-chain monitors can detect deletion, reordering, or mutation of historical records. + +## Covered operations + +The initial system-wide trail appends records for the critical billing and lifecycle paths: + +- `register_meter`: meter registration and service provisioning. +- `top_up`: prepaid/postpaid funding events. +- `deduct_units`: signed IoT usage deductions. + +Additional services should call the same internal append helper when adding state-changing critical paths. + +## Verification workflow + +1. Poll `get_audit_head()` and record the returned `sequence` and `record_hash` in monitoring storage. +2. Backfill missing records with `get_audit_record(sequence)`. +3. Run `verify_audit_chain(start_sequence, end_sequence)` for every contiguous range received from the contract. +4. Alert immediately if verification returns `false`, a sequence is missing, or the observed head hash regresses. + +## Monitoring and alerting + +Recommended production alerts: + +- **AuditChainVerificationFailed**: `verify_audit_chain` returns `false` for any range. +- **AuditSequenceGap**: monitor observes a missing sequence between the previous head and current head. +- **AuditHeadRegression**: current head sequence is less than the last finalized monitor checkpoint. +- **AuditIngestionLag**: latest indexed sequence lags on-chain head for more than five minutes. + +## Deployment guidance + +Deploy the audit trail with the standard blue-green contract upgrade process: + +1. Deploy the upgraded WASM to the green environment. +2. Replay a canary workload that registers a meter, funds it, and posts a signed usage report. +3. Verify the green audit chain from sequence `1` through the canary head. +4. Compare P99 latency against the `<100ms` critical-path target. +5. Promote green only after chain verification and latency checks pass. + +## Incident response + +If a monitor detects tampering: + +1. Freeze downstream settlement automation that depends on the affected sequence range. +2. Capture `get_audit_head()` and all records around the failed range. +3. Re-run verification from the last known-good sequence. +4. Escalate to security review with the failed sequence, expected previous hash, observed previous hash, and record hash. diff --git a/contracts/utility_contracts/src/lib.rs b/contracts/utility_contracts/src/lib.rs index 51cd74b..03dd92c 100644 --- a/contracts/utility_contracts/src/lib.rs +++ b/contracts/utility_contracts/src/lib.rs @@ -189,6 +189,28 @@ pub struct ReadingRejected { pub timestamp: u64, } +/// Tamper-evident audit trail entry chained to the previous entry hash. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AuditRecord { + /// Monotonic sequence number for the contract-wide audit stream. + pub sequence: u64, + /// Previous record hash, or all zeroes for the genesis record. + pub previous_hash: BytesN<32>, + /// Hash of this record's canonical payload. + pub record_hash: BytesN<32>, + /// Service operation being audited, e.g. register/topup/deduct/claim. + pub action: Symbol, + /// Primary subject identifier for the event, usually a meter or stream id. + pub subject_id: u64, + /// Account that authorized or initiated the audited operation. + pub actor: Address, + /// Ledger timestamp at the point the audit record was appended. + pub timestamp: u64, + /// Domain-specific payload digest for compact, privacy-preserving verification. + pub payload_hash: BytesN<32>, +} + /// Emitted when the deposit is slashed at 100 % utilisation. #[contracttype] #[derive(Clone)] @@ -1123,6 +1145,9 @@ pub enum DataKey { PendingSettlement(Address, BytesN<32>), // Batch finalization idempotency guard BatchFinalized(u64), + // Tamper-evident audit trail + AuditHead, + AuditRecord(u64), } // ============================================================================ @@ -5079,6 +5104,8 @@ impl UtilityContract { env.storage().instance().set(&DataKey::Meter(count), &meter); env.storage().instance().set(&DataKey::Count, &count); + let payload_hash = audit_payload_hash(&env, &(count, off_peak_rate, priority_index, resource_type)); + append_audit_record(&env, Symbol::new(&env, "register_meter"), count, meter.user.clone(), payload_hash); count } @@ -5173,6 +5200,9 @@ impl UtilityContract { .instance() .set(&DataKey::Meter(meter_id), &meter); + let payload_hash = audit_payload_hash(&env, &(amount, converted_amount, meter.balance, meter.debt)); + append_audit_record(&env, Symbol::new(&env, "top_up"), meter_id, contributor, payload_hash); + // Emit conversion event env.events().publish( (symbol_short!("TokUp"), meter_id), @@ -5514,6 +5544,9 @@ impl UtilityContract { .instance() .set(&DataKey::Meter(signed_data.meter_id), &meter); + let payload_hash = audit_payload_hash(&env, &(signed_data.timestamp, signed_data.units_consumed, cost)); + append_audit_record(&env, Symbol::new(&env, "deduct_units"), signed_data.meter_id, meter.provider.clone(), payload_hash); + // Emit UsageReported event env.events().publish( (Symbol::new(&env, "UsageReported"), signed_data.meter_id), @@ -5521,6 +5554,67 @@ impl UtilityContract { ); } + /// Return the current audit-chain head for off-chain monitors. + pub fn get_audit_head(env: Env) -> Option { + env.storage().instance().get(&DataKey::AuditHead) + } + + /// Return an audit record by sequence number for verification or dashboard indexing. + pub fn get_audit_record(env: Env, sequence: u64) -> Option { + env.storage().instance().get(&DataKey::AuditRecord(sequence)) + } + + /// Verify the audit hash chain between two inclusive sequence numbers. + pub fn verify_audit_chain(env: Env, start_sequence: u64, end_sequence: u64) -> bool { + if start_sequence == 0 || end_sequence < start_sequence { + return false; + } + + let mut expected_previous = if start_sequence == 1 { + zero_hash(&env) + } else if let Some(previous) = env + .storage() + .instance() + .get::(&DataKey::AuditRecord(start_sequence - 1)) + { + previous.record_hash + } else { + return false; + }; + + let mut sequence = start_sequence; + while sequence <= end_sequence { + let Some(record) = env + .storage() + .instance() + .get::(&DataKey::AuditRecord(sequence)) + else { + return false; + }; + + if record.sequence != sequence || record.previous_hash != expected_previous { + return false; + } + + let expected_hash = calculate_audit_record_hash( + &env, + record.sequence, + &record.previous_hash, + &record.action, + record.subject_id, + &record.actor, + record.timestamp, + &record.payload_hash, + ); + if record.record_hash != expected_hash { + return false; + } + expected_previous = record.record_hash; + sequence = sequence.saturating_add(1); + } + true + } + pub fn claim(env: Env, meter_id: u64) { let mut meter = get_meter_or_panic(&env, meter_id); meter.provider.require_auth();