OWASP C8: Data protection — secrets management, envelope encryption, key rotation, and FIPS readiness.
secure_data ensures that sensitive data is never stored in plaintext and that secrets are never accidentally logged or serialized. It provides typed secret wrappers, envelope encryption with pluggable KMS backends, and a key lifecycle management system.
[dependencies]
secure_data = "0.1.2"
# With optional features:
secure_data = { version = "0.1.2", features = ["vault"] } # HashiCorp Vault
secure_data = { version = "0.1.2", features = ["aws-kms"] } # AWS KMS
secure_data = { version = "0.1.2", features = ["fips"] } # FIPS 140-2/3 backend
secure_data = { version = "0.1.2", features = ["pq"] } # Hybrid PQ envelope wrap
secure_data = { version = "0.1.2", features = ["vault", "aws-kms"] } # BothApplication code should never hold raw String for secrets. Use typed wrappers that automatically redact in Debug, Display, and Serialize:
use secure_data::secret::SecretString;
let password = SecretString::new("my-database-password".to_string());
// Debug is redacted — safe for logging
println!("{:?}", password);
// → SecretString([REDACTED])
// Serialize is redacted — safe for JSON responses
let json = serde_json::to_string(&password).unwrap();
assert_eq!(json, "\"[REDACTED]\"");
// Explicit access when needed (e.g., passing to a database driver)
let actual: &str = password.expose_secret();
assert_eq!(actual, "my-database-password");
// Memory is zeroed on drop (via zeroize)use secure_data::secret::SecretBytes;
let key_material = SecretBytes::new(vec![0x42; 32]);
println!("{:?}", key_material);
// → SecretBytes([REDACTED] 32 bytes)
let raw: &[u8] = key_material.expose_secret();use secure_data::secret::{ApiToken, DbPassword, SigningKeyRef};
let token = ApiToken::new("example-api-token".to_string());
let db_pass = DbPassword::new("postgres-secret".to_string());
let signing = SigningKeyRef::new("keys/signing-v2".to_string());
// All behave the same: Debug/Display → [REDACTED], expose via expose_secret()
println!("{:?}", token); // → ApiToken([REDACTED])
println!("{:?}", db_pass); // → DbPassword([REDACTED])
println!("{:?}", signing); // → SigningKeyRef([REDACTED])For secrets that should only be read once (e.g., initial key material):
use secure_data::memory::ReadOnce;
let mut secret = ReadOnce::new("one-time-password".to_string());
// First read — succeeds
let value = secret.take(); // Some("one-time-password")
// Second read — empty (value was consumed)
let value = secret.take(); // None
// Debug is always safe
println!("{:?}", secret); // → <consumed>
// Memory is zeroed on dropEnvelope encryption separates the data encryption key (DEK) from the key encryption key (KEK). Your application never handles raw AEAD directly:
┌─────────────────────────────────────────┐
│ encrypt_for_storage(plaintext, alias) │
│ │
│ 1. KeyProvider generates random DEK │
│ 2. DEK encrypts plaintext (AES-256-GCM)│
│ 3. KEK wraps DEK │
│ 4. Returns EnvelopeEncrypted │
│ (ciphertext + wrapped_dek + nonce) │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ decrypt_for_use(envelope, provider) │
│ │
│ 1. KEK unwraps DEK │
│ 2. DEK decrypts ciphertext │
│ 3. Returns plaintext │
│ 4. DEK is zeroized from memory │
└─────────────────────────────────────────┘
use secure_data::envelope::{encrypt_for_storage, decrypt_for_use};
use secure_data::kms::StaticDevKeyProvider;
// StaticDevKeyProvider is for development/testing only
let provider = StaticDevKeyProvider::new();
// Encrypt
let plaintext = b"sensitive customer data";
let envelope = encrypt_for_storage(plaintext, "app-data-key", &provider)
.await
.expect("encryption must succeed");
// envelope contains:
// version: "1"
// algorithm: "AES-256-GCM"
// key_alias: "app-data-key"
// wrapped_data_key: [encrypted DEK bytes]
// nonce: [96-bit random]
// ciphertext: [AEAD ciphertext]
// Decrypt
let recovered = decrypt_for_use(&envelope, &provider)
.await
.expect("decryption must succeed");
assert_eq!(recovered, plaintext);| Field | Type | Description |
|---|---|---|
version |
String |
Envelope format version (currently "1") |
algorithm |
String |
AEAD algorithm (e.g. "AES-256-GCM", "XChaCha20-Poly1305") |
key_alias |
String |
Logical key alias |
key_version |
String |
Specific key version used |
wrapped_data_key |
Vec<u8> |
DEK wrapped by KEK |
nonce |
Vec<u8> |
96-bit random nonce (unique per encryption) |
ciphertext |
Vec<u8> |
AES-256-GCM ciphertext |
aad |
Vec<u8> |
Additional authenticated data |
The envelope is Serialize + Deserialize — store it in your database as JSON.
Track key aliases, versions, and lifecycle status:
use secure_data::keyring::{KeyRing, KeyVersionStatus};
let mut keyring = KeyRing::new();
// Register a new key alias with its initial version
keyring.add_key("customer-data-key".into(), "v1".into());
// Check the active version
let active = keyring.active_version("customer-data-key");
assert_eq!(active, Some("v1"));
// Check version status
let status = keyring.version_status("customer-data-key", "v1");
assert_eq!(status, Some(KeyVersionStatus::Active));use secure_data::keyring::{KeyRing, KeyVersionStatus};
let mut keyring = KeyRing::new();
keyring.add_key("app-key".into(), "v1".into());
// Rotate: v1 becomes DecryptOnly, v2 becomes Active
let new_version = keyring.rotate("app-key").unwrap();
// new_version is auto-generated (e.g., "v2")
assert_eq!(
keyring.version_status("app-key", "v1"),
Some(KeyVersionStatus::DecryptOnly) // can still decrypt old data
);
assert_eq!(
keyring.active_version("app-key"),
Some(new_version.as_str()) // new data encrypted with this
);
// After all data is re-encrypted, deactivate the old version
keyring.deactivate("app-key", "v1").unwrap();
assert_eq!(
keyring.version_status("app-key", "v1"),
Some(KeyVersionStatus::Deactivated)
);| Status | Encrypt | Decrypt | Description |
|---|---|---|---|
Active |
✓ | ✓ | Current key for new encryptions |
DecryptOnly |
— | ✓ | Old key, can still decrypt existing data |
Deactivated |
— | — | Fully retired, unusable |
When rotating keys, re-encrypt existing data with the new key:
use secure_data::rotation::re_encrypt;
use secure_data::envelope::{encrypt_for_storage, decrypt_for_use};
use secure_data::kms::StaticDevKeyProvider;
let provider = StaticDevKeyProvider::new();
// Original encryption with old key
let old_envelope = encrypt_for_storage(b"data", "old-key", &provider).await?;
// Re-encrypt with new key (decrypts then encrypts in one step)
let new_envelope = re_encrypt(&old_envelope, "new-key", &provider).await?;
// Verify
let recovered = decrypt_for_use(&new_envelope, &provider).await?;
assert_eq!(recovered, b"data");
assert_eq!(new_envelope.key_alias, "new-key");Parse secret references from configuration files:
use secure_data::config::SecretReference;
// HashiCorp Vault
let vault_ref = SecretReference::parse("vault://kv/prod-db#password").unwrap();
// vault_ref.provider == SecretReferenceProvider::Vault
// vault_ref.path == "kv/prod-db"
// vault_ref.field == Some("password")
// AWS KMS
let kms_ref = SecretReference::parse("kms://alias/my-key").unwrap();
// kms_ref.provider == SecretReferenceProvider::Kms
// kms_ref.path == "alias/my-key"
// Environment variable
let env_ref = SecretReference::parse("env://DATABASE_URL").unwrap();
// env_ref.provider == SecretReferenceProvider::Env
// env_ref.path == "DATABASE_URL"use secure_data::config::SecretReference;
use secure_data::resolve::resolve_secret;
// Set up environment
std::env::set_var("DB_PASSWORD", "secret-from-env");
let reference = SecretReference::parse("env://DB_PASSWORD").unwrap();
let secret = resolve_secret(&reference).await.unwrap();
assert_eq!(secret.expose_secret(), "secret-from-env");
// For vault:// and kms:// providers, ensure the respective
// features are enabled and the providers are configureduse secure_data::kms::StaticDevKeyProvider;
let provider = StaticDevKeyProvider::new();
// Fixed 32-byte key for "default" alias
// Uses XOR wrapping (NOT production-safe)
// No external dependencies — works offlineuse secure_data::providers::vault::VaultKeyProvider;
let provider = VaultKeyProvider::new(
"https://vault.example.com:8200",
"hvs.your-vault-token",
)?;
// Uses Vault Transit engine:
// Generate: POST /v1/transit/datakey/plaintext/{alias}
// Decrypt: POST /v1/transit/decrypt/{alias}use secure_data::providers::aws_kms::AwsKmsKeyProvider;
// Standard AWS credential chain (env vars, profiles, IMDS)
let provider = AwsKmsKeyProvider::new().await;
// Custom endpoint (e.g., LocalStack for testing)
let provider = AwsKmsKeyProvider::with_endpoint(
"http://localhost:4566"
).await;
// Uses AWS KMS:
// Generate: GenerateDataKey (AES_256)
// Decrypt: DecryptUse #[serde(serialize_with = "...")] to redact fields in your own structs:
use secure_data::serde::{redact, RedactedField};
use serde::Serialize;
#[derive(Serialize)]
struct UserProfile {
pub username: String,
#[serde(serialize_with = "redact")]
pub ssn: String, // → "[REDACTED]" in JSON
pub status: RedactedField, // → "[REDACTED]" always
}
let profile = UserProfile {
username: "alice".into(),
ssn: "123-45-6789".into(),
status: RedactedField,
};
let json = serde_json::to_string(&profile).unwrap();
// {"username":"alice","ssn":"[REDACTED]","status":"[REDACTED]"}use secure_data::error::DataError;
// All possible errors:
let err = DataError::KeyNotFound { alias: "unknown".into() };
let err = DataError::KeyDeactivated { alias: "app-key".into(), version: "v1".into() };
let err = DataError::EncryptionFailed { reason: "invalid key length".into() };
let err = DataError::DecryptionFailed { reason: "authentication tag mismatch".into() };
let err = DataError::InvalidNonce { expected: 12, actual: 8 };
let err = DataError::InvalidSecretReference { input: "bad://ref".into() };
let err = DataError::ProviderUnavailable { provider: "vault".into(), reason: "timeout".into() };
let err = DataError::SecretNotFound { reference: "env://MISSING".into() };The password module provides Argon2id password hashing and verification with secure defaults.
use secure_data::password::{hash_password, verify_password};
use secure_data::secret::SecretString;
let password = SecretString::new("correct-horse-battery".to_string());
// Hash — returns a PasswordHash in PHC string format ($argon2id$...)
let hash = hash_password(&password).expect("hashing should succeed");
// Persist hash.expose_hash() to your database
let phc_string = hash.expose_hash().to_string();
// Verify — constant-time comparison, returns Ok(true) or Ok(false)
assert!(verify_password(&password, &hash).expect("verify should succeed"));- Argon2id — winner of the Password Hashing Competition; memory-hard, resistant to GPU and side-channel attacks.
- Random salt — every call to
hash_password()generates a unique salt viaOsRng. - Constant-time verification —
verify_password()uses the argon2 crate's constant-time comparison. - Zeroize on drop —
PasswordHashinner value is zeroized when dropped. - Redacted output —
DebugprintsPasswordHash([REDACTED]);Serializeemits"[REDACTED]". - Empty password rejected —
hash_password()returnsPasswordError::EmptyPasswordfor empty input.
Use the PasswordHasher trait for polymorphic or testable code:
use secure_data::password::{Argon2Hasher, PasswordHasher};
use secure_data::secret::SecretString;
let hasher = Argon2Hasher::default();
let password = SecretString::new("my-password".to_string());
let hash = hasher.hash_password(&password).unwrap();
assert!(hasher.verify_password(&password, &hash).unwrap());The algorithm module enables switching encryption algorithms without changing application code — a key ESAPI principle. The algorithm tag is stored in every encrypted envelope so decryption can select the correct primitive even after the system default changes.
| Algorithm | Enum Variant | Nonce Size | Notes |
|---|---|---|---|
| AES-256-GCM | CryptoAlgorithm::Aes256Gcm |
12 bytes | Default; NIST standard |
| XChaCha20-Poly1305 | CryptoAlgorithm::XChaCha20Poly1305 |
24 bytes | Larger nonce, no nonce-reuse risk |
use secure_data::algorithm::{AlgorithmPolicy, CryptoAlgorithm};
use secure_data::envelope::{encrypt_with_policy, decrypt_for_use};
use secure_data::kms::StaticDevKeyProvider;
let provider = StaticDevKeyProvider::new();
// Choose XChaCha20-Poly1305 for new encryptions
let policy = AlgorithmPolicy::prefer(CryptoAlgorithm::XChaCha20Poly1305);
let envelope = encrypt_with_policy(b"secret", "my-key", &provider, &policy)
.await
.expect("must succeed");
assert_eq!(envelope.algorithm, "XChaCha20-Poly1305");
// Decryption is automatic — the algorithm is read from the envelope
let plaintext = decrypt_for_use(&envelope, &provider).await.unwrap();
assert_eq!(plaintext, b"secret");use secure_data::algorithm::{AlgorithmPolicy, CryptoAlgorithm};
// Require at least XChaCha20 — reject AES-256-GCM
let policy = AlgorithmPolicy::new(
CryptoAlgorithm::Aes256Gcm, // preferred
Some(CryptoAlgorithm::XChaCha20Poly1305), // minimum
);
// This will fail because AES ranks below XChaCha in policy ordering
assert!(policy.validate().is_err());encrypt_for_storage()continues to use AES-256-GCM by default.- Old envelopes (created before M25) decrypt transparently — they contain
algorithm: "AES-256-GCM". - The
decrypt_for_use()function reads the algorithm from the envelope and dispatches automatically.
For production key management with Azure Key Vault:
#[cfg(feature = "azure-kv")]
{
use secure_data::key_vault::{AzureKeyVaultProvider, MockVaultClient};
// In tests — use MockVaultClient
let provider = AzureKeyVaultProvider::new(MockVaultClient::new());
// Key material never leaves the vault — only wrap/unwrap operations.
}secure_data ships an opt-in hybrid post-quantum envelope key wrap behind --features pq. The authoritative design is docs/slo/design/pq-migration-plan.md, and the dedicated implementation guide is secure-data-pq.md.
use secure_data::algorithm::{AlgorithmPolicy, CryptoAlgorithm};
use secure_data::envelope::{decrypt_for_use, encrypt_with_policy};
use secure_data::kms::StaticDevKeyProvider;
# async fn example() -> Result<(), secure_data::error::DataError> {
let provider = StaticDevKeyProvider::new();
let policy = AlgorithmPolicy::prefer(CryptoAlgorithm::HybridX25519MlKem768);
let envelope = encrypt_with_policy(b"plaintext", "default", &provider, &policy).await?;
assert_eq!(envelope.version, "2");
assert_eq!(envelope.combiner_id, Some(0x01));
let plaintext = decrypt_for_use(&envelope, &provider).await?;
assert_eq!(plaintext, b"plaintext");
# Ok(())
# }Hybrid envelopes use wrapped_data_key = ML-KEM-768 ciphertext || X25519 share || AES-GCM-wrapped DEK. Existing AES-256-GCM and XChaCha20-Poly1305 envelopes remain v1 and continue to decrypt with or without the pq feature.
A v2 hybrid envelope (produced by an M2-or-later build with --features pq) presented to a build without --features pq returns DataError::PqFeatureRequired — never silently downgrades and never panics:
# fn example(envelope: &secure_data::envelope::EnvelopeEncrypted) {
match envelope.validate_structure() {
Err(DataError::PqFeatureRequired) => {
// Rebuild your binary with `--features pq` to decrypt this envelope.
}
Err(DataError::EnvelopeMalformed { reason }) => {
// Tampered metadata (e.g., a v1 envelope carrying a non-zero combiner_id).
}
Err(DataError::AlgorithmRejectedByPolicy { reason }) => {
// combiner_id is the fail-closed sentinel, or otherwise unrecognised.
}
Ok(()) => { /* proceed to decrypt_for_use */ }
Err(other) => { /* see DataError docs */ }
}
# }The M3 compatibility matrix and AlgorithmPolicy::min_version checks are landed: v1 classical envelopes continue to decrypt, v2 hybrid envelopes require a PQ-enabled build, and policy downgrades fail closed. The M4 FIPS posture is also landed: fips and pq can be enabled together, but the PQ leg reports pending_cmvp and never claims validation.
Three reasons (full discussion in the migration plan):
- ML-KEM-768 is FIPS 203 (2024) but no FIPS 140-3 cryptographic module covers it as of 2026-05. Hybrid keeps the classical X25519 leg in the security argument so a hypothetical future ML-KEM cryptanalysis cannot retroactively compromise everything.
- Hybrid is a strict superset: an attacker must break both X25519 and ML-KEM-768 to recover the wrap key.
- Production deployments at AWS, Cloudflare, and Google in 2024–2025 followed this pattern. Adopting it puts SunLit on the same migration trajectory as those ecosystems.
A build with both --features fips and --features pq enabled is not FIPS-validated for the post-quantum portion of the pipeline. The interaction has three honest properties consumers must know:
| Combination | What's validated | What's not |
|---|---|---|
fips only (no pq) |
AEAD via FIPS 140-2/3-track aws-lc-rs (when validated for the platform) |
(PQ not requested; not relevant) |
pq only (no fips) |
Nothing claims FIPS validation; pure-Rust ml-kem path |
(FIPS not requested; not relevant) |
fips + pq |
AEAD validated by the same FIPS module as fips-only |
The hybrid PQ KEM is validation-pending CMVP |
No CMVP cert covers ML-KEM-768 as of 2026-05 (verified 2026-05-05 in the research dossier; AWS-LC FIPS 3.0 has been on the CMVP "modules in process" list since 2024-12-10 with no public ETA). A --features fips,pq build therefore reports pq_fips_status: "pending_cmvp" in Debug output of any envelope it produces — the runtime metadata field exists so downstream auditors can scan production binaries for the honest label.
Hard rule: SunLit documentation, README, CHANGELOG, rustdoc, and dev-guide content never make a validation claim about the PQ path. The CI pipeline includes a grep-based lint (scripts/lint-fips-pq-claims.sh) that fails the build if any forbidden phrasing slips in. See the script for the exact forbidden patterns.
A future runbook adds a pq-aws-lc feature (or similar) that selects a CMVP-validated implementation. Promotion criteria:
- A CMVP cert exists for ML-KEM-768 in a Rust-callable cryptographic module.
- The validated module's version, OS support, and platform constraints are documented.
- The migration plan is updated to reference the validated path explicitly.
- The
pq_fips_statusruntime metadata field reports"validated"for the validated combination.
Until those four conditions are met, --features fips,pq honestly reports "pending_cmvp". Honest > aspirational.
let envelope = encrypt_with_policy(&policy, ...).await?;
println!("{:?}", envelope);
// EnvelopeEncrypted { version: "1", algorithm: "AES-256-GCM", ..., pq_fips_status: None }
//
// On a `--features fips,pq` build:
// EnvelopeEncrypted { version: "2", algorithm: "X25519+ML-KEM-768/HKDF-SHA-256", ...,
// pq_fips_status: Some("pending_cmvp") }Auditors / SBOM consumers can grep production binaries for the literal string pending_cmvp to verify the honest label is in place. The CI lint enforces it on the source side; the runtime field enforces it on the binary side.
use secure_data::envelope::{encrypt_for_storage, decrypt_for_use};
use secure_data::secret::SecretString;
use secure_data::keyring::{KeyRing, KeyVersionStatus};
use secure_data::kms::StaticDevKeyProvider;
use secure_data::config::SecretReference;
use std::sync::Arc;
// Application setup
let provider = Arc::new(StaticDevKeyProvider::new());
let mut keyring = KeyRing::new();
keyring.add_key("user-data-key".into(), "v1".into());
// In a request handler — storing sensitive data
async fn store_user_data(
data: &[u8],
provider: &StaticDevKeyProvider,
) -> Result<String, Box<dyn std::error::Error>> {
// Encrypt before storing
let envelope = encrypt_for_storage(data, "user-data-key", provider).await?;
// Serialize envelope to JSON for database storage
let json = serde_json::to_string(&envelope)?;
// Store json in your database
Ok(json)
}
// Later — retrieving sensitive data
async fn load_user_data(
envelope_json: &str,
provider: &StaticDevKeyProvider,
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let envelope = serde_json::from_str(envelope_json)?;
let plaintext = decrypt_for_use(&envelope, provider).await?;
Ok(plaintext)
}
// Configuration with secret references
fn load_config() -> Result<(), Box<dyn std::error::Error>> {
let db_ref = SecretReference::parse("env://DATABASE_URL")?;
let api_ref = SecretReference::parse("vault://kv/api-keys#stripe")?;
// Resolve at startup, store as SecretString
Ok(())
}| Feature | Adds | Use When |
|---|---|---|
vault |
VaultKeyProvider, reqwest |
Using HashiCorp Vault Transit |
aws-kms |
AwsKmsKeyProvider, aws-sdk-kms |
Using AWS KMS |
fips |
aws-lc-rs AEAD backend |
FIPS 140-2/3 compliance required |
password |
Argon2Hasher, argon2 |
Password hashing with Argon2id |
pq |
ml-kem, x25519-dalek, hkdf, sha2 |
Hybrid X25519 + ML-KEM-768 v2 envelope key wrap |
All features are off by default. Enable only what you need:
cargo build -p secure_data --features vault,aws-kms| Type | Module | Description |
|---|---|---|
SecretString |
secret |
Redacted string wrapper |
SecretBytes |
secret |
Redacted bytes wrapper |
ApiToken |
secret |
API token wrapper |
DbPassword |
secret |
Database password wrapper |
SigningKeyRef |
secret |
Signing key reference wrapper |
ReadOnce<T> |
memory |
Single-use value wrapper |
EnvelopeEncrypted |
envelope |
Envelope encryption output |
encrypt_for_storage() |
envelope |
Encrypt plaintext → envelope |
decrypt_for_use() |
envelope |
Decrypt envelope → plaintext |
KeyRing |
keyring |
Key alias/version registry |
KeyVersionStatus |
keyring |
Active / DecryptOnly / Deactivated |
KeyVersionEntry |
keyring |
Version metadata |
KeyProvider |
kms |
Sealed KMS trait |
StaticDevKeyProvider |
kms |
Dev/test-only key provider |
VaultKeyProvider |
providers::vault |
HashiCorp Vault Transit (feature vault) |
AwsKmsKeyProvider |
providers::aws_kms |
AWS KMS (feature aws-kms) |
SecretReference |
config |
Parsed secret URI |
SecretReferenceProvider |
config |
Vault / Kms / Env |
resolve_secret() |
resolve |
Runtime secret resolution |
RotationPlan |
rotation |
Key rotation plan |
re_encrypt() |
rotation |
Re-encrypt with new key |
redact() |
serde |
Serde redaction serializer |
RedactedField |
serde |
Always-redacted marker type |
DataError |
error |
Data protection error enum |
PasswordHash |
password |
Redacted password hash (PHC format, feature password) |
PasswordHasher |
password |
Password hashing trait (feature password) |
Argon2Hasher |
password |
Argon2id hasher (feature password) |
hash_password() |
password |
Hash a password with Argon2id (feature password) |
verify_password() |
password |
Verify a password against a hash (feature password) |
PasswordError |
password |
Password hashing error enum (feature password) |