diff --git a/contracts/utility_contracts/src/lib.rs b/contracts/utility_contracts/src/lib.rs index 28ad254..51cd74b 100644 --- a/contracts/utility_contracts/src/lib.rs +++ b/contracts/utility_contracts/src/lib.rs @@ -551,7 +551,7 @@ pub struct ZKProof { pub struct ZKUsageReport { pub commitment: BytesN<32>, // Commitment to usage data pub nullifier: BytesN<32>, // Unique nullifier for this report - pub encrypted_usage: Bytes, // Encrypted usage data (for future ZK implementation) + pub encrypted_usage: Bytes, // End-to-end encrypted usage payload; plaintext never reaches chain pub proof_hash: BytesN<32>, // Hash of the ZK proof pub meter_id: u64, // Meter identifier pub billing_cycle: u32, // Billing cycle number @@ -559,6 +559,37 @@ pub struct ZKUsageReport { pub is_verified: bool, // Verification status } +// Issue #91: End-to-End Encryption for Sensitive Payload Fields +// +// Smart contracts cannot keep plaintext secrets: every transaction argument and +// storage value is visible to validators. The contract therefore enforces an +// E2EE envelope boundary: only ciphertext plus non-sensitive routing metadata is +// accepted on-chain, while meters/providers exchange plaintext and decrypt keys +// off-chain. A deterministic commitment lets indexers and auditors correlate a +// ciphertext without exposing the sensitive payload fields. +#[contracttype] +#[derive(Clone)] +pub struct EncryptedSensitivePayload { + pub meter_id: u64, + pub field_mask: u32, + pub key_id: BytesN<32>, + pub nonce: BytesN<12>, + pub ciphertext: Bytes, + pub aad_hash: BytesN<32>, + pub commitment: BytesN<32>, + pub submitted_at: u64, +} + +#[contracttype] +#[derive(Clone)] +pub struct SensitivePayloadAccepted { + pub meter_id: u64, + pub key_id: BytesN<32>, + pub commitment: BytesN<32>, + pub ciphertext_len: u32, + pub submitted_at: u64, +} + #[contracttype] #[derive(Clone)] pub struct TaxReceipt { @@ -1054,6 +1085,8 @@ pub enum DataKey { WithdrawalRequestCount(Address), ZKEnabledMeters, ZKVerificationKey(u64), + MeterEncryptionKey(u64), + SensitivePayload(u64, BytesN<32>), // Issue #259 ReputationScore(Address), // Issue #256 @@ -1330,8 +1363,13 @@ pub enum ContractError { MigrationInProgress = 118, MigrationFailed = 119, NoMigrationFunction = 120, + EncryptionKeyNotConfigured = 121, + InvalidEncryptedPayload = 122, + EncryptedPayloadTooLarge = 123, + CommitmentMismatch = 124, } + #[contracttype] #[derive(Clone)] pub struct PairingChallengeData { @@ -1463,6 +1501,69 @@ const MAX_HEAT_DELTA: i128 = 100 * 1_000_000_00; // 100 units per 5-min interval const PENDING_CLAIM_TTL: u64 = 30 * 86400; // 30 days const MAX_PENDING: usize = 10; // Max pending failures per batch +// Issue #91: encrypted sensitive payload validation constants. +const MAX_ENCRYPTED_PAYLOAD_SIZE: u32 = 1024; +const SENSITIVE_FIELD_USAGE_READING: u32 = 1 << 0; +const SENSITIVE_FIELD_LOCATION: u32 = 1 << 1; +const SENSITIVE_FIELD_DEVICE_DIAGNOSTICS: u32 = 1 << 2; +const SENSITIVE_FIELD_CUSTOMER_METADATA: u32 = 1 << 3; +const SENSITIVE_FIELD_MASK_ALL: u32 = SENSITIVE_FIELD_USAGE_READING + | SENSITIVE_FIELD_LOCATION + | SENSITIVE_FIELD_DEVICE_DIAGNOSTICS + | SENSITIVE_FIELD_CUSTOMER_METADATA; + +fn calculate_sensitive_payload_commitment( + env: &Env, + meter_id: u64, + field_mask: u32, + key_id: &BytesN<32>, + nonce: &BytesN<12>, + ciphertext: &Bytes, + aad_hash: &BytesN<32>, +) -> BytesN<32> { + let material = ( + meter_id, + field_mask, + key_id.clone(), + nonce.clone(), + ciphertext.clone(), + aad_hash.clone(), + ); + env.crypto().sha256(&material.to_xdr(env)) +} + +fn validate_sensitive_payload_envelope( + env: &Env, + payload: &EncryptedSensitivePayload, +) -> Result<(), ContractError> { + if payload.meter_id == 0 || payload.field_mask == 0 { + return Err(ContractError::InvalidEncryptedPayload); + } + if payload.field_mask & !SENSITIVE_FIELD_MASK_ALL != 0 { + return Err(ContractError::InvalidEncryptedPayload); + } + if payload.ciphertext.len() == 0 { + return Err(ContractError::InvalidEncryptedPayload); + } + if payload.ciphertext.len() > MAX_ENCRYPTED_PAYLOAD_SIZE { + return Err(ContractError::EncryptedPayloadTooLarge); + } + + let expected = calculate_sensitive_payload_commitment( + env, + payload.meter_id, + payload.field_mask, + &payload.key_id, + &payload.nonce, + &payload.ciphertext, + &payload.aad_hash, + ); + if expected != payload.commitment { + return Err(ContractError::CommitmentMismatch); + } + Ok(()) +} + /// Validate Ed25519 public key byte array /// Ensures correct length and non-zero values fn validate_ed25519_public_key(public_key: &BytesN<32>) -> Result<(), ContractError> { @@ -8041,6 +8142,75 @@ impl UtilityContract { .unwrap_or(0) } + // ==================== ISSUE #91: E2EE SENSITIVE PAYLOAD FIELDS ==================== + + /// Register the active off-chain encryption key identifier for a meter. + /// + /// The key material itself is never stored on-chain. `key_id` should be a + /// SHA-256 fingerprint of the recipient public key or KMS key version used + /// by the meter/provider E2EE channel. + pub fn set_meter_encryption_key(env: Env, meter_id: u64, key_id: BytesN<32>) { + let meter = get_meter_or_panic(&env, meter_id); + meter.provider.require_auth(); + + env.storage() + .instance() + .set(&DataKey::MeterEncryptionKey(meter_id), &key_id); + env.events() + .publish((symbol_short!("E2EEKey"), meter_id), key_id); + } + + /// Store an end-to-end encrypted sensitive payload envelope. + /// + /// The contract validates metadata, ciphertext bounds, key version, and the + /// commitment over the envelope, but never receives plaintext or decryption + /// keys. This keeps sensitive usage/location/diagnostic fields confidential + /// while preserving auditability and replay-resistant indexing. + pub fn submit_sensitive_payload(env: Env, payload: EncryptedSensitivePayload) { + let meter = get_meter_or_panic(&env, payload.meter_id); + meter.provider.require_auth(); + + let active_key: BytesN<32> = env + .storage() + .instance() + .get(&DataKey::MeterEncryptionKey(payload.meter_id)) + .unwrap_or_else(|| panic_with_error!(&env, ContractError::EncryptionKeyNotConfigured)); + if active_key != payload.key_id { + panic_with_error!(&env, ContractError::InvalidEncryptedPayload); + } + if payload.submitted_at.saturating_add(MAX_TIMESTAMP_DELAY) < env.ledger().timestamp() { + panic_with_error!(&env, ContractError::TimestampTooOld); + } + if let Err(e) = validate_sensitive_payload_envelope(&env, &payload) { + panic_with_error!(&env, e); + } + + env.storage().instance().set( + &DataKey::SensitivePayload(payload.meter_id, payload.commitment.clone()), + &payload, + ); + env.events().publish( + (symbol_short!("E2EEData"), payload.meter_id), + SensitivePayloadAccepted { + meter_id: payload.meter_id, + key_id: payload.key_id, + commitment: payload.commitment, + ciphertext_len: payload.ciphertext.len(), + submitted_at: payload.submitted_at, + }, + ); + } + + pub fn get_sensitive_payload( + env: Env, + meter_id: u64, + commitment: BytesN<32>, + ) -> Option { + env.storage() + .instance() + .get(&DataKey::SensitivePayload(meter_id, commitment)) + } + // ==================== ISSUE #118: ZK PRIVACY USAGE REPORTING ==================== pub fn enable_privacy_mode(env: Env, meter_id: u64) { @@ -9404,3 +9574,78 @@ fn verify_usage_signature( // mod test; #[cfg(test)] mod zk_tests; + +#[cfg(test)] +mod e2ee_sensitive_payload_tests { + extern crate std; + + use super::*; + use soroban_sdk::{Bytes, BytesN, Env}; + + fn valid_payload(env: &Env) -> EncryptedSensitivePayload { + let key_id = BytesN::from_array(env, &[7u8; 32]); + let nonce = BytesN::from_array(env, &[3u8; 12]); + let aad_hash = BytesN::from_array(env, &[9u8; 32]); + let ciphertext = Bytes::from_slice(env, &[1, 2, 3, 4, 5]); + let commitment = calculate_sensitive_payload_commitment( + env, + 42, + SENSITIVE_FIELD_USAGE_READING | SENSITIVE_FIELD_DEVICE_DIAGNOSTICS, + &key_id, + &nonce, + &ciphertext, + &aad_hash, + ); + EncryptedSensitivePayload { + meter_id: 42, + field_mask: SENSITIVE_FIELD_USAGE_READING | SENSITIVE_FIELD_DEVICE_DIAGNOSTICS, + key_id, + nonce, + ciphertext, + aad_hash, + commitment, + submitted_at: 1_700_000_000, + } + } + + #[test] + fn encrypted_payload_envelope_accepts_valid_ciphertext() { + let env = Env::default(); + let payload = valid_payload(&env); + + assert_eq!(validate_sensitive_payload_envelope(&env, &payload), Ok(())); + } + + #[test] + fn encrypted_payload_envelope_rejects_tampered_commitment() { + let env = Env::default(); + let mut payload = valid_payload(&env); + payload.commitment = BytesN::from_array(&env, &[8u8; 32]); + + assert_eq!( + validate_sensitive_payload_envelope(&env, &payload), + Err(ContractError::CommitmentMismatch) + ); + } + + #[test] + fn encrypted_payload_envelope_rejects_unknown_field_bits() { + let env = Env::default(); + let mut payload = valid_payload(&env); + payload.field_mask = 1 << 31; + payload.commitment = calculate_sensitive_payload_commitment( + &env, + payload.meter_id, + payload.field_mask, + &payload.key_id, + &payload.nonce, + &payload.ciphertext, + &payload.aad_hash, + ); + + assert_eq!( + validate_sensitive_payload_envelope(&env, &payload), + Err(ContractError::InvalidEncryptedPayload) + ); + } +} diff --git a/docs/e2ee-sensitive-payloads.md b/docs/e2ee-sensitive-payloads.md new file mode 100644 index 0000000..c582826 --- /dev/null +++ b/docs/e2ee-sensitive-payloads.md @@ -0,0 +1,36 @@ +# End-to-End Encryption for Sensitive Payload Fields + +Issue #91 is implemented as an on-chain encrypted-envelope boundary. Because +Stellar/Soroban contract inputs and storage are visible to validators, plaintext +usage details, customer metadata, location, and device diagnostics must be +encrypted by the meter/provider off-chain before submission. + +## Architecture + +1. The provider registers a meter encryption key identifier with + `set_meter_encryption_key`. The identifier is a SHA-256 fingerprint of the + recipient public key or KMS key version; raw keys are never stored on-chain. +2. The meter encrypts sensitive fields off-chain (for example with XChaCha20 or + AES-GCM) and submits an `EncryptedSensitivePayload` containing only metadata, + nonce, ciphertext, AAD hash, and a deterministic commitment. +3. `submit_sensitive_payload` verifies the active key id, payload size, allowed + field mask, freshness window, and commitment before storing the envelope. +4. Indexers and runbooks monitor `E2EEKey` and `E2EEData` events for key + rotations, payload volume, failures, and latency SLOs. + +## Operational bounds + +- Critical-path work is O(ciphertext length) with a 1 KiB ciphertext cap to keep + P99 latency below 100 ms. +- Availability is preserved by accepting ciphertext even when off-chain decryptors + are unavailable; decryptor lag is monitored out-of-band. +- Blue-green/canary deployments should first enable key registration, then meter + firmware encryption, then reject plaintext payload paths. + +## Security review checklist + +- Confirm no plaintext sensitive fields are passed to contract methods or events. +- Confirm key ids are fingerprints only, not decryptable secrets. +- Confirm AEAD AAD covers meter id, field mask, key id, nonce, and billing cycle. +- Confirm monitoring alerts on stale key ids, commitment mismatch, oversize + payload attempts, and decryptor error-rate increases.