use secure_data::algorithm::{AlgorithmPolicy, CryptoAlgorithm};
use secure_data::envelope::{decrypt_for_use, encrypt_for_storage, EnvelopeEncrypted};
use secure_data::error::DataError;
use secure_data::kms::StaticDevKeyProvider;
use secure_data::pq;
#[tokio::test]
async fn classical_envelope_round_trips_unchanged() {
let provider = StaticDevKeyProvider::new();
let plaintext = b"pq-m1 round-trip should be byte-identical";
let envelope = encrypt_for_storage(plaintext, "default", &provider)
.await
.expect("encrypt must succeed");
let recovered = decrypt_for_use(&envelope, &provider)
.await
.expect("decrypt must succeed");
assert_eq!(recovered, plaintext);
assert_eq!(envelope.algorithm, "AES-256-GCM");
assert_eq!(
envelope.combiner_id, None,
"classical envelope must have combiner_id == None"
);
}
#[tokio::test]
async fn xchacha_envelope_round_trips_unchanged() {
let provider = StaticDevKeyProvider::new();
let plaintext = b"xchacha is also unchanged";
let policy = AlgorithmPolicy::prefer(CryptoAlgorithm::XChaCha20Poly1305);
let envelope =
secure_data::envelope::encrypt_with_policy(plaintext, "default", &provider, &policy)
.await
.expect("encrypt must succeed");
let recovered = decrypt_for_use(&envelope, &provider)
.await
.expect("decrypt must succeed");
assert_eq!(recovered, plaintext);
assert_eq!(envelope.algorithm, "XChaCha20-Poly1305");
assert_eq!(envelope.combiner_id, None);
}
#[test]
fn pre_m1_envelope_without_combiner_id_field_deserializes_with_none() {
let pre_m1_json = serde_json::json!({
"version": "1",
"algorithm": "AES-256-GCM",
"key_alias": "default",
"key_version": "1",
"wrapped_data_key": [1, 2, 3, 4],
"nonce": [5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5],
"ciphertext": [9, 10, 11, 12],
"aad": [13, 14, 15, 16],
});
let envelope: EnvelopeEncrypted = serde_json::from_value(pre_m1_json)
.expect("pre-M1 envelopes must deserialize cleanly via serde default");
assert_eq!(envelope.combiner_id, None);
assert_eq!(envelope.algorithm, "AES-256-GCM");
}
#[tokio::test]
#[cfg(not(feature = "pq"))]
async fn hybrid_kem_request_returns_pq_unavailable_in_m1() {
let provider = StaticDevKeyProvider::new();
let policy = AlgorithmPolicy::prefer(CryptoAlgorithm::HybridX25519MlKem768);
let result = secure_data::envelope::encrypt_with_policy(
b"never reaches the wire",
"default",
&provider,
&policy,
)
.await;
match result {
Err(DataError::PqUnavailable) => {}
other => panic!(
"expected DataError::PqUnavailable on hybrid request in M1, got: {:?}",
other
),
}
}
#[tokio::test]
async fn classical_envelope_with_non_zero_combiner_is_rejected() {
let provider = StaticDevKeyProvider::new();
let plaintext = b"ignored -- we never reach decrypt";
let mut envelope = encrypt_for_storage(plaintext, "default", &provider)
.await
.expect("encrypt must succeed");
envelope.combiner_id = Some(0x42);
let validate_result = envelope.validate_structure();
assert!(
validate_result.is_err(),
"validate_structure must reject a classical envelope carrying combiner_id"
);
match validate_result {
Err(DataError::EnvelopeMalformed { reason }) => {
assert!(
reason.contains("combiner_id"),
"EnvelopeMalformed reason must name combiner_id"
);
}
other => panic!("expected EnvelopeMalformed, got: {:?}", other),
}
let decrypt_result = decrypt_for_use(&envelope, &provider).await;
assert!(
matches!(decrypt_result, Err(DataError::EnvelopeMalformed { .. })),
"decrypt_for_use must reject the malformed envelope before any AEAD work"
);
}
#[tokio::test]
async fn classical_envelope_with_zero_combiner_id_is_accepted() {
let provider = StaticDevKeyProvider::new();
let plaintext = b"zero combiner is the legacy default";
let mut envelope = encrypt_for_storage(plaintext, "default", &provider)
.await
.expect("encrypt must succeed");
envelope.combiner_id = Some(0);
let validate_result = envelope.validate_structure();
assert!(
validate_result.is_ok(),
"Some(0) is acceptable as a synonym for None per validate_structure"
);
let recovered = decrypt_for_use(&envelope, &provider)
.await
.expect("decrypt must succeed for Some(0) combiner_id");
assert_eq!(recovered, plaintext);
}
#[test]
fn pq_sizes_module_is_publicly_available_without_pq_feature() {
assert_eq!(pq::sizes::ML_KEM_768_CIPHERTEXT_LEN, 1088);
assert_eq!(pq::sizes::ML_KEM_768_PUBLIC_KEY_LEN, 1184);
assert_eq!(pq::sizes::ML_KEM_768_SHARED_SECRET_LEN, 32);
assert_eq!(pq::sizes::X25519_SHARE_LEN, 32);
assert_eq!(pq::sizes::X25519_SHARED_SECRET_LEN, 32);
assert_eq!(pq::sizes::HKDF_SHA256_OUTPUT_LEN, 32);
assert_eq!(pq::sizes::AES_256_GCM_NONCE_LEN, 12);
}
#[test]
fn combiner_id_constants_match_migration_plan() {
assert_eq!(pq::COMBINER_ID_X25519_ML_KEM_768, 0x01);
assert_eq!(pq::COMBINER_ID_FAIL_CLOSED, 0xFF);
assert!(pq::is_recognised_combiner(
pq::COMBINER_ID_X25519_ML_KEM_768
));
assert!(!pq::is_recognised_combiner(pq::COMBINER_ID_FAIL_CLOSED));
assert!(!pq::is_recognised_combiner(0x42));
}
#[test]
fn hybrid_algorithm_round_trips_via_envelope_string() {
let alg = CryptoAlgorithm::HybridX25519MlKem768;
assert_eq!(alg.as_str(), "X25519+ML-KEM-768/HKDF-SHA-256");
assert!(alg.is_post_quantum());
assert_eq!(alg.nonce_len(), 12, "hybrid uses AES-GCM 12-byte nonce");
let parsed =
CryptoAlgorithm::from_envelope_str("X25519+ML-KEM-768/HKDF-SHA-256").expect("must parse");
assert_eq!(parsed, alg);
}
#[test]
fn classical_algorithms_are_not_post_quantum() {
assert!(!CryptoAlgorithm::Aes256Gcm.is_post_quantum());
assert!(!CryptoAlgorithm::XChaCha20Poly1305.is_post_quantum());
}
#[test]
fn new_data_error_variants_format_intelligibly() {
let pq_unavailable = DataError::PqUnavailable;
assert!(pq_unavailable.to_string().contains("post-quantum"));
assert!(pq_unavailable.to_string().contains("pq"));
let pq_required = DataError::PqFeatureRequired;
assert!(pq_required.to_string().contains("pq"));
let policy_reject = DataError::AlgorithmRejectedByPolicy {
reason: "minimum version=2 required".to_string(),
};
assert!(policy_reject.to_string().contains("policy"));
assert!(policy_reject.to_string().contains("version=2"));
let malformed = DataError::EnvelopeMalformed {
reason: "combiner_id present on classical envelope".to_string(),
};
assert!(malformed.to_string().contains("malformed"));
assert!(malformed.to_string().contains("combiner_id"));
}
#[test]
fn migration_plan_doc_exists_and_is_non_trivial() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(std::path::Path::parent)
.expect("workspace root resolves")
.join("docs/slo/design/pq-migration-plan.md");
assert!(
path.exists(),
"migration plan must exist at docs/slo/design/pq-migration-plan.md"
);
let body = std::fs::read_to_string(&path).expect("readable");
assert!(
body.len() > 1500,
"migration plan must be substantive (>1500 chars), got {} chars",
body.len()
);
assert!(
body.contains("ML-KEM-768"),
"must reference ML-KEM-768 (the locked KEM choice)"
);
assert!(
body.contains("HKDF-SHA-256") || body.contains("HKDF/SHA-256"),
"must reference the HKDF-SHA-256 combiner choice"
);
assert!(
body.contains("FIPS"),
"must address FIPS-track posture (research called this out as monitor-only as of 2026-05)"
);
assert!(
body.contains("84e6ae18") || body.contains("ml-kem"),
"must cite the locked dep (`ml-kem` v0.3.0) or otherwise pin the source"
);
}