Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
247 changes: 246 additions & 1 deletion contracts/utility_contracts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -551,14 +551,45 @@ 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
pub timestamp: u64, // Report timestamp
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 {
Expand Down Expand Up @@ -1054,6 +1085,8 @@ pub enum DataKey {
WithdrawalRequestCount(Address),
ZKEnabledMeters,
ZKVerificationKey(u64),
MeterEncryptionKey(u64),
SensitivePayload(u64, BytesN<32>),
// Issue #259
ReputationScore(Address),
// Issue #256
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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> {
Expand Down Expand Up @@ -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<EncryptedSensitivePayload> {
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) {
Expand Down Expand Up @@ -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)
);
}
}
36 changes: 36 additions & 0 deletions docs/e2ee-sensitive-payloads.md
Original file line number Diff line number Diff line change
@@ -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.
Loading