mirror of
https://github.com/Dicklesworthstone/coding_agent_session_search.git
synced 2026-09-22 11:59:25 +08:00
feat(pages): validate encrypted-archive KDF params and key slots on decrypt
Hardens the encrypted-pages format so a tampered or unsupported archive is rejected with a typed error instead of being trusted. - key_management/encrypt: on decrypt, require `kdf_defaults` to equal the supported Argon2 parameters (reject `UnsupportedMetadata` otherwise), reject duplicate key-slot ids, and enforce the per-slot-type KDF (a `Password` slot must be `Argon2id`). - pages/errors: typed error taxonomy for the encrypted path — payload that cannot be authenticated/decoded is reported as corrupt/tampered, and metadata naming a format feature this build cannot consume is a distinct typed error. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+310
-112
@@ -25,6 +25,8 @@ use std::io::{BufReader, BufWriter, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use zeroize::{Zeroize, ZeroizeOnDrop};
|
||||
|
||||
use super::errors::DecryptError;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("{0}")]
|
||||
struct AeadSourceError(aes_gcm::Error);
|
||||
@@ -132,7 +134,7 @@ pub struct KeySlot {
|
||||
}
|
||||
|
||||
/// Argon2 parameters for config.json
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Argon2Params {
|
||||
pub memory_kb: u32,
|
||||
@@ -176,46 +178,85 @@ pub struct EncryptionConfig {
|
||||
|
||||
pub(crate) fn validate_supported_payload_format(config: &EncryptionConfig) -> Result<()> {
|
||||
if config.version != SCHEMA_VERSION {
|
||||
bail!(
|
||||
"Unsupported archive schema version {}; expected {}",
|
||||
config.version,
|
||||
SCHEMA_VERSION
|
||||
);
|
||||
return Err(DecryptError::UnsupportedVersion(config.version).into());
|
||||
}
|
||||
|
||||
if config.compression != "deflate" {
|
||||
bail!(
|
||||
"Unsupported archive compression '{}'. The current encrypted pages format supports only deflate.",
|
||||
config.compression
|
||||
);
|
||||
return Err(DecryptError::UnsupportedMetadata("compression".to_string()).into());
|
||||
}
|
||||
|
||||
if config.payload.chunk_size == 0 {
|
||||
bail!("Invalid archive chunk_size 0: must be > 0");
|
||||
return Err(invalid_archive_format(
|
||||
"payload chunk_size must be greater than zero",
|
||||
));
|
||||
}
|
||||
|
||||
if config.payload.chunk_size > MAX_CHUNK_SIZE {
|
||||
bail!(
|
||||
"Invalid archive chunk_size {}: must be <= {} bytes",
|
||||
config.payload.chunk_size,
|
||||
MAX_CHUNK_SIZE
|
||||
);
|
||||
return Err(DecryptError::UnsupportedMetadata("payload.chunk_size".to_string()).into());
|
||||
}
|
||||
|
||||
if config.payload.chunk_count != config.payload.files.len() {
|
||||
bail!(
|
||||
"Invalid archive payload metadata: chunk_count {} does not match file list length {}",
|
||||
return Err(invalid_archive_format(format!(
|
||||
"payload chunk_count {} does not match file list length {}",
|
||||
config.payload.chunk_count,
|
||||
config.payload.files.len()
|
||||
);
|
||||
)));
|
||||
}
|
||||
|
||||
if config.payload.chunk_count > u32::MAX as usize {
|
||||
bail!(
|
||||
"Invalid archive payload metadata: chunk_count {} exceeds maximum {}",
|
||||
config.payload.chunk_count,
|
||||
u32::MAX
|
||||
);
|
||||
return Err(DecryptError::UnsupportedMetadata("payload.chunk_count".to_string()).into());
|
||||
}
|
||||
|
||||
require_metadata_len("export_id", &config.export_id, 16)?;
|
||||
require_metadata_len("base_nonce", &config.base_nonce, 12)?;
|
||||
|
||||
let supported_argon2_params = Argon2Params::default();
|
||||
if config.kdf_defaults != supported_argon2_params {
|
||||
return Err(DecryptError::UnsupportedMetadata("kdf_defaults".to_string()).into());
|
||||
}
|
||||
|
||||
let mut slot_ids = std::collections::BTreeSet::new();
|
||||
for slot in &config.key_slots {
|
||||
if !slot_ids.insert(slot.id) {
|
||||
return Err(invalid_archive_format(format!(
|
||||
"duplicate key slot id {}",
|
||||
slot.id
|
||||
)));
|
||||
}
|
||||
let expected_kdf = match slot.slot_type {
|
||||
SlotType::Password => KdfAlgorithm::Argon2id,
|
||||
SlotType::Recovery => KdfAlgorithm::HkdfSha256,
|
||||
};
|
||||
if slot.kdf != expected_kdf {
|
||||
return Err(DecryptError::UnsupportedMetadata("key_slots.kdf".to_string()).into());
|
||||
}
|
||||
match slot.slot_type {
|
||||
SlotType::Password => match &slot.argon2_params {
|
||||
Some(params) if params == &supported_argon2_params => {}
|
||||
Some(_) => {
|
||||
return Err(DecryptError::UnsupportedMetadata(
|
||||
"key_slots.argon2_params".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
None => {
|
||||
return Err(invalid_archive_format(
|
||||
"password key slot is missing argon2_params",
|
||||
));
|
||||
}
|
||||
},
|
||||
SlotType::Recovery if slot.argon2_params.is_some() => {
|
||||
return Err(invalid_archive_format(
|
||||
"recovery key slot must not contain argon2_params",
|
||||
));
|
||||
}
|
||||
SlotType::Recovery => {}
|
||||
}
|
||||
if decode_metadata_field("key_slots.salt", &slot.salt)?.is_empty() {
|
||||
return Err(invalid_archive_format("key slot salt must not be empty"));
|
||||
}
|
||||
require_metadata_len("key_slots.wrapped_dek", &slot.wrapped_dek, 48)?;
|
||||
require_metadata_len("key_slots.nonce", &slot.nonce, 12)?;
|
||||
}
|
||||
|
||||
for (index, file) in config.payload.files.iter().enumerate() {
|
||||
@@ -270,9 +311,30 @@ fn decimal_digit_count(mut value: usize) -> usize {
|
||||
}
|
||||
|
||||
fn invalid_payload_file_entry(index: usize, actual: &str) -> anyhow::Error {
|
||||
anyhow::anyhow!(
|
||||
"Invalid archive payload metadata: payload file entry {index} is '{actual}'; expected 'payload/chunk-{index:05}.bin'"
|
||||
)
|
||||
invalid_archive_format(format!(
|
||||
"payload file entry {index} is '{actual}'; expected 'payload/chunk-{index:05}.bin'"
|
||||
))
|
||||
}
|
||||
|
||||
fn invalid_archive_format(detail: impl Into<String>) -> anyhow::Error {
|
||||
DecryptError::InvalidFormat(detail.into()).into()
|
||||
}
|
||||
|
||||
fn decode_metadata_field(field: &str, encoded: &str) -> Result<Vec<u8>> {
|
||||
BASE64_STANDARD
|
||||
.decode(encoded)
|
||||
.map_err(|error| invalid_archive_format(format!("{field} is not valid base64: {error}")))
|
||||
}
|
||||
|
||||
fn require_metadata_len(field: &str, encoded: &str, expected: usize) -> Result<()> {
|
||||
let decoded = decode_metadata_field(field, encoded)?;
|
||||
if decoded.len() != expected {
|
||||
return Err(invalid_archive_format(format!(
|
||||
"{field} has {} bytes; expected {expected}",
|
||||
decoded.len()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Encryption engine for pages export
|
||||
@@ -918,19 +980,28 @@ impl DecryptionEngine {
|
||||
/// Unlock with password
|
||||
pub fn unlock_with_password(config: EncryptionConfig, password: &str) -> Result<Self> {
|
||||
validate_supported_payload_format(&config)?;
|
||||
if password.is_empty() {
|
||||
return Err(DecryptError::EmptyPassword.into());
|
||||
}
|
||||
|
||||
let mut password_slot_found = false;
|
||||
for slot in &config.key_slots {
|
||||
if slot.slot_type != SlotType::Password {
|
||||
continue;
|
||||
}
|
||||
password_slot_found = true;
|
||||
|
||||
let salt = BASE64_STANDARD.decode(&slot.salt)?;
|
||||
let wrapped_dek = BASE64_STANDARD.decode(&slot.wrapped_dek)?;
|
||||
let nonce = BASE64_STANDARD.decode(&slot.nonce)?;
|
||||
let salt = decode_metadata_field("key_slots.salt", &slot.salt)?;
|
||||
let wrapped_dek = decode_metadata_field("key_slots.wrapped_dek", &slot.wrapped_dek)?;
|
||||
let nonce = decode_metadata_field("key_slots.nonce", &slot.nonce)?;
|
||||
|
||||
let kek = derive_kek_argon2id(password, &salt)?;
|
||||
let kek = derive_kek_argon2id(password, &salt).map_err(|error| {
|
||||
invalid_archive_format(format!(
|
||||
"password key-derivation metadata is invalid: {error}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let export_id = BASE64_STANDARD.decode(&config.export_id)?;
|
||||
let export_id = decode_metadata_field("export_id", &config.export_id)?;
|
||||
if let Ok(dek) = unwrap_key(&kek, &wrapped_dek, &nonce, &export_id, slot.id) {
|
||||
return Ok(Self {
|
||||
dek: SecretKey::from_bytes(dek),
|
||||
@@ -939,7 +1010,11 @@ impl DecryptionEngine {
|
||||
}
|
||||
}
|
||||
|
||||
bail!("Invalid password or no matching key slot")
|
||||
if password_slot_found {
|
||||
Err(DecryptError::AuthenticationFailed.into())
|
||||
} else {
|
||||
Err(DecryptError::NoMatchingKeySlot.into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Unlock with recovery secret
|
||||
@@ -951,13 +1026,17 @@ impl DecryptionEngine {
|
||||
continue;
|
||||
}
|
||||
|
||||
let salt = BASE64_STANDARD.decode(&slot.salt)?;
|
||||
let wrapped_dek = BASE64_STANDARD.decode(&slot.wrapped_dek)?;
|
||||
let nonce = BASE64_STANDARD.decode(&slot.nonce)?;
|
||||
let salt = decode_metadata_field("key_slots.salt", &slot.salt)?;
|
||||
let wrapped_dek = decode_metadata_field("key_slots.wrapped_dek", &slot.wrapped_dek)?;
|
||||
let nonce = decode_metadata_field("key_slots.nonce", &slot.nonce)?;
|
||||
|
||||
let kek = derive_kek_hkdf(secret, &salt)?;
|
||||
let kek = derive_kek_hkdf(secret, &salt).map_err(|error| {
|
||||
invalid_archive_format(format!(
|
||||
"recovery key-derivation metadata is invalid: {error}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let export_id = BASE64_STANDARD.decode(&config.export_id)?;
|
||||
let export_id = decode_metadata_field("export_id", &config.export_id)?;
|
||||
if let Ok(dek) = unwrap_key(&kek, &wrapped_dek, &nonce, &export_id, slot.id) {
|
||||
return Ok(Self {
|
||||
dek: SecretKey::from_bytes(dek),
|
||||
@@ -966,7 +1045,7 @@ impl DecryptionEngine {
|
||||
}
|
||||
}
|
||||
|
||||
bail!("Invalid recovery secret or no matching key slot")
|
||||
Err(DecryptError::NoMatchingKeySlot.into())
|
||||
}
|
||||
|
||||
/// Decrypt all chunks to output file
|
||||
@@ -982,15 +1061,13 @@ impl DecryptionEngine {
|
||||
|
||||
let cipher = Aes256Gcm::new_from_slice(self.dek.as_bytes()).expect("Invalid key length");
|
||||
|
||||
let base_nonce = BASE64_STANDARD.decode(&self.config.base_nonce)?;
|
||||
let export_id = BASE64_STANDARD.decode(&self.config.export_id)?;
|
||||
let base_nonce = decode_metadata_field("base_nonce", &self.config.base_nonce)?;
|
||||
let export_id = decode_metadata_field("export_id", &self.config.export_id)?;
|
||||
|
||||
// Validate chunk count doesn't exceed u32 to prevent nonce truncation
|
||||
if self.config.payload.files.len() > u32::MAX as usize {
|
||||
bail!(
|
||||
"Invalid config: chunk count {} exceeds maximum {}",
|
||||
self.config.payload.files.len(),
|
||||
u32::MAX
|
||||
return Err(
|
||||
DecryptError::UnsupportedMetadata("payload.chunk_count".to_string()).into(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1002,17 +1079,32 @@ impl DecryptionEngine {
|
||||
|
||||
// Prevent directory traversal
|
||||
if chunk_file.contains("..") || Path::new(chunk_file).is_absolute() {
|
||||
bail!("Invalid chunk path: potential directory traversal");
|
||||
return Err(invalid_archive_format(format!(
|
||||
"payload file entry {chunk_index} is not a canonical relative chunk path"
|
||||
)));
|
||||
}
|
||||
|
||||
let chunk_path = encrypted_dir.join(chunk_file);
|
||||
let ciphertext = std::fs::read(&chunk_path)?;
|
||||
let ciphertext = std::fs::read(&chunk_path).map_err(|error| {
|
||||
let taxonomy = DecryptError::CorruptPayload(format!(
|
||||
"chunk {chunk_index} is missing or unreadable: {error}"
|
||||
));
|
||||
anyhow::Error::new(error).context(taxonomy)
|
||||
})?;
|
||||
|
||||
// Derive nonce
|
||||
let nonce = derive_chunk_nonce(base_nonce.as_slice().try_into()?, chunk_index as u32);
|
||||
let base_nonce: &[u8; 12] = base_nonce
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| invalid_archive_format("base_nonce must contain exactly 12 bytes"))?;
|
||||
let nonce = derive_chunk_nonce(base_nonce, chunk_index as u32);
|
||||
|
||||
// Build AAD
|
||||
let aad = build_chunk_aad(export_id.as_slice().try_into()?, chunk_index as u32);
|
||||
let export_id: &[u8; 16] = export_id
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| invalid_archive_format("export_id must contain exactly 16 bytes"))?;
|
||||
let aad = build_chunk_aad(export_id, chunk_index as u32);
|
||||
|
||||
// Decrypt
|
||||
let compressed = cipher
|
||||
@@ -1023,31 +1115,23 @@ impl DecryptionEngine {
|
||||
aad: &aad,
|
||||
},
|
||||
)
|
||||
.map_err(|err| {
|
||||
// [coding_agent_session_search-b64fe] Chain the underlying
|
||||
// aead error so operators can distinguish "decryption
|
||||
// failed at chunk N because the AES-GCM tag did not
|
||||
// verify" (corrupt ciphertext / wrong DEK / tampered
|
||||
// AAD) from a downstream decompression / writer
|
||||
// failure that surfaces with a different error chain.
|
||||
// The aead crate's Display impl deliberately stays
|
||||
// opaque about whether MAC vs auth-tag verification
|
||||
// failed (timing-attack hardening), so we still don't
|
||||
// leak that — but the source error type IS preserved
|
||||
// in the chain for debug-mode inspection.
|
||||
let context = format!(
|
||||
"Decryption failed for chunk {} ({} bytes ciphertext): {}",
|
||||
chunk_index,
|
||||
ciphertext.len(),
|
||||
err
|
||||
);
|
||||
anyhow::Error::new(AeadSourceError(err)).context(context)
|
||||
.map_err(|error| {
|
||||
let taxonomy = DecryptError::CorruptPayload(format!(
|
||||
"chunk {chunk_index} authentication failed for {} ciphertext bytes: {error}",
|
||||
ciphertext.len()
|
||||
));
|
||||
anyhow::Error::new(AeadSourceError(error)).context(taxonomy)
|
||||
})?;
|
||||
|
||||
// Decompress
|
||||
let mut decoder = DeflateDecoder::new(&compressed[..]);
|
||||
let mut plaintext = Vec::new();
|
||||
decoder.read_to_end(&mut plaintext)?;
|
||||
decoder.read_to_end(&mut plaintext).map_err(|error| {
|
||||
let taxonomy = DecryptError::CorruptPayload(format!(
|
||||
"chunk {chunk_index} deflate stream is invalid: {error}"
|
||||
));
|
||||
anyhow::Error::new(error).context(taxonomy)
|
||||
})?;
|
||||
|
||||
writer.write_all(&plaintext)?;
|
||||
}
|
||||
@@ -1853,54 +1937,121 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_rejects_unsupported_payload_compression_before_unlock() {
|
||||
fn test_decrypt_rejects_unsupported_payload_compression_before_unlock() -> Result<()> {
|
||||
let (_temp_dir, _output_dir, mut config) = encrypt_test_file();
|
||||
config.compression = "zstd".to_string();
|
||||
|
||||
let err = match DecryptionEngine::unlock_with_password(config, "password") {
|
||||
Ok(_) => panic!("unsupported compression must fail before unlock"),
|
||||
Err(err) => err,
|
||||
};
|
||||
|
||||
let rendered = err.to_string();
|
||||
assert!(
|
||||
rendered.contains("supports only deflate") && rendered.contains("zstd"),
|
||||
"unexpected unsupported-compression error: {err:#}"
|
||||
let err = DecryptionEngine::unlock_with_password(config, "password")
|
||||
.err()
|
||||
.context("unsupported compression must fail before unlock")?;
|
||||
anyhow::ensure!(
|
||||
matches!(
|
||||
err.downcast_ref::<DecryptError>(),
|
||||
Some(DecryptError::UnsupportedMetadata(field)) if field == "compression"
|
||||
),
|
||||
"unexpected unsupported-metadata taxonomy: {err:#}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_rejects_unsupported_schema_version_before_unlock() {
|
||||
fn test_decrypt_rejects_unsupported_schema_version_before_unlock() -> Result<()> {
|
||||
let (_temp_dir, _output_dir, mut config) = encrypt_test_file();
|
||||
config.version = 1;
|
||||
|
||||
let err = match DecryptionEngine::unlock_with_password(config, "password") {
|
||||
Ok(_) => panic!("unsupported schema version must fail before unlock"),
|
||||
Err(err) => err,
|
||||
};
|
||||
|
||||
let rendered = err.to_string();
|
||||
assert!(
|
||||
rendered.contains("schema version") && rendered.contains("expected 2"),
|
||||
"unexpected unsupported-version error: {err:#}"
|
||||
let err = DecryptionEngine::unlock_with_password(config, "password")
|
||||
.err()
|
||||
.context("unsupported schema version must fail before unlock")?;
|
||||
anyhow::ensure!(
|
||||
matches!(
|
||||
err.downcast_ref::<DecryptError>(),
|
||||
Some(DecryptError::UnsupportedVersion(1))
|
||||
),
|
||||
"unexpected unsupported-version taxonomy: {err:#}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_rejects_mismatched_chunk_count_before_unlock() {
|
||||
fn test_decrypt_rejects_mismatched_chunk_count_before_unlock() -> Result<()> {
|
||||
let (_temp_dir, _output_dir, mut config) = encrypt_test_file();
|
||||
config.payload.chunk_count += 1;
|
||||
|
||||
let err = match DecryptionEngine::unlock_with_password(config, "password") {
|
||||
Ok(_) => panic!("mismatched chunk count must fail before unlock"),
|
||||
Err(err) => err,
|
||||
};
|
||||
|
||||
let rendered = err.to_string();
|
||||
assert!(
|
||||
rendered.contains("chunk_count") && rendered.contains("file list length"),
|
||||
"unexpected mismatched-chunk-count error: {err:#}"
|
||||
let err = DecryptionEngine::unlock_with_password(config, "password")
|
||||
.err()
|
||||
.context("mismatched chunk count must fail before unlock")?;
|
||||
anyhow::ensure!(
|
||||
matches!(
|
||||
err.downcast_ref::<DecryptError>(),
|
||||
Some(DecryptError::InvalidFormat(detail))
|
||||
if detail.contains("chunk_count") && detail.contains("file list length")
|
||||
),
|
||||
"unexpected invalid-metadata taxonomy: {err:#}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_distinguishes_empty_and_wrong_passwords() -> Result<()> {
|
||||
let (_temp_dir, _output_dir, config) = encrypt_test_file();
|
||||
let empty = DecryptionEngine::unlock_with_password(config.clone(), "")
|
||||
.err()
|
||||
.context("empty password must fail")?;
|
||||
anyhow::ensure!(
|
||||
matches!(
|
||||
empty.downcast_ref::<DecryptError>(),
|
||||
Some(DecryptError::EmptyPassword)
|
||||
),
|
||||
"unexpected empty-password taxonomy: {empty:#}"
|
||||
);
|
||||
|
||||
let wrong = DecryptionEngine::unlock_with_password(config, "wrong-password")
|
||||
.err()
|
||||
.context("wrong password must fail")?;
|
||||
anyhow::ensure!(
|
||||
matches!(
|
||||
wrong.downcast_ref::<DecryptError>(),
|
||||
Some(DecryptError::AuthenticationFailed)
|
||||
),
|
||||
"unexpected wrong-password taxonomy: {wrong:#}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_rejects_unsupported_argon2_metadata_before_authentication() -> Result<()> {
|
||||
let (_temp_dir, _output_dir, mut config) = encrypt_test_file();
|
||||
config.kdf_defaults.iterations += 1;
|
||||
|
||||
let err = DecryptionEngine::unlock_with_password(config, "password")
|
||||
.err()
|
||||
.context("unsupported default KDF parameters must fail before authentication")?;
|
||||
anyhow::ensure!(
|
||||
matches!(
|
||||
err.downcast_ref::<DecryptError>(),
|
||||
Some(DecryptError::UnsupportedMetadata(field)) if field == "kdf_defaults"
|
||||
),
|
||||
"unexpected unsupported-KDF taxonomy: {err:#}"
|
||||
);
|
||||
|
||||
let (_temp_dir, _output_dir, mut config) = encrypt_test_file();
|
||||
config.key_slots[0]
|
||||
.argon2_params
|
||||
.as_mut()
|
||||
.context("password fixture should include Argon2 parameters")?
|
||||
.memory_kb += 1;
|
||||
let err = DecryptionEngine::unlock_with_password(config, "password")
|
||||
.err()
|
||||
.context("unsupported slot KDF parameters must fail before authentication")?;
|
||||
anyhow::ensure!(
|
||||
matches!(
|
||||
err.downcast_ref::<DecryptError>(),
|
||||
Some(DecryptError::UnsupportedMetadata(field))
|
||||
if field == "key_slots.argon2_params"
|
||||
),
|
||||
"unexpected unsupported-slot-KDF taxonomy: {err:#}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1916,18 +2067,21 @@ mod tests {
|
||||
let err = validate_supported_payload_format(&config)
|
||||
.err()
|
||||
.context("unexpected payload file name must fail validation")?;
|
||||
let rendered = err.to_string();
|
||||
if !rendered.contains("payload file entry 0")
|
||||
|| !rendered.contains("payload/chunk-00000.bin")
|
||||
{
|
||||
bail!("unexpected payload-file-name error: {err:#}");
|
||||
}
|
||||
anyhow::ensure!(
|
||||
matches!(
|
||||
err.downcast_ref::<DecryptError>(),
|
||||
Some(DecryptError::InvalidFormat(detail))
|
||||
if detail.contains("payload file entry 0")
|
||||
&& detail.contains("payload/chunk-00000.bin")
|
||||
),
|
||||
"unexpected payload-file-name taxonomy: {err:#}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tampered_chunk_fails() {
|
||||
fn test_tampered_chunk_fails_with_corrupt_payload_taxonomy() -> Result<()> {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let input_path = temp_dir.path().join("input.txt");
|
||||
let output_dir = temp_dir.path().join("encrypted");
|
||||
@@ -1950,11 +2104,51 @@ mod tests {
|
||||
|
||||
// Decryption should fail due to auth tag mismatch
|
||||
let decryptor = DecryptionEngine::unlock_with_password(config, "password").unwrap();
|
||||
assert!(
|
||||
decryptor
|
||||
.decrypt_to_file(&output_dir, &decrypted_path, |_, _| {})
|
||||
.is_err()
|
||||
let err = decryptor
|
||||
.decrypt_to_file(&output_dir, &decrypted_path, |_, _| {})
|
||||
.err()
|
||||
.context("tampered ciphertext must fail authentication")?;
|
||||
anyhow::ensure!(
|
||||
matches!(
|
||||
err.downcast_ref::<DecryptError>(),
|
||||
Some(DecryptError::CorruptPayload(detail))
|
||||
if detail.contains("chunk 0") && detail.contains("authentication failed")
|
||||
),
|
||||
"unexpected corrupt-payload taxonomy: {err:#}"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
err.downcast_ref::<AeadSourceError>().is_some(),
|
||||
"tampered ciphertext must preserve the AEAD source error: {err:#}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_missing_chunk_fails_with_corrupt_payload_taxonomy() -> Result<()> {
|
||||
let (_temp_dir, output_dir, config) = encrypt_test_file();
|
||||
let chunk_path = output_dir.join("payload/chunk-00000.bin");
|
||||
let parked_chunk_path = output_dir.join("payload/chunk-00000.bin.parked");
|
||||
std::fs::rename(&chunk_path, &parked_chunk_path)?;
|
||||
|
||||
let decrypted_path = output_dir.join("decrypted.txt");
|
||||
let decryptor = DecryptionEngine::unlock_with_password(config, "password")?;
|
||||
let err = decryptor
|
||||
.decrypt_to_file(&output_dir, &decrypted_path, |_, _| {})
|
||||
.err()
|
||||
.context("missing encrypted chunk must fail")?;
|
||||
anyhow::ensure!(
|
||||
matches!(
|
||||
err.downcast_ref::<DecryptError>(),
|
||||
Some(DecryptError::CorruptPayload(detail))
|
||||
if detail.contains("chunk 0") && detail.contains("missing or unreadable")
|
||||
),
|
||||
"unexpected missing-payload taxonomy: {err:#}"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
err.downcast_ref::<std::io::Error>().is_some(),
|
||||
"missing chunk must preserve the I/O source error: {err:#}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1991,8 +2185,12 @@ mod tests {
|
||||
.decrypt_to_file(&output_dir, &decrypted_path, |_, _| {})
|
||||
.expect_err("tampered later chunk must fail");
|
||||
assert!(
|
||||
err.to_string().contains("Decryption failed for chunk 1"),
|
||||
"unexpected decrypt error: {err:#}"
|
||||
matches!(
|
||||
err.downcast_ref::<DecryptError>(),
|
||||
Some(DecryptError::CorruptPayload(detail))
|
||||
if detail.contains("chunk 1") && detail.contains("authentication failed")
|
||||
),
|
||||
"unexpected corrupt-payload taxonomy: {err:#}"
|
||||
);
|
||||
assert_file_bytes(&decrypted_path, existing_output);
|
||||
|
||||
|
||||
@@ -31,9 +31,15 @@ pub enum DecryptError {
|
||||
/// Archive integrity check failed (tampering detected).
|
||||
#[error("The archive appears to be corrupted or tampered with.")]
|
||||
IntegrityCheckFailed,
|
||||
/// Encrypted payload bytes cannot be authenticated or decoded.
|
||||
#[error("The encrypted archive payload is corrupted or tampered with.")]
|
||||
CorruptPayload(String),
|
||||
/// Archive version not supported.
|
||||
#[error("This archive requires a newer version of the software (version {0}).")]
|
||||
UnsupportedVersion(u8),
|
||||
/// Archive metadata names a format feature this build cannot consume.
|
||||
#[error("This archive uses unsupported encryption metadata ({0}).")]
|
||||
UnsupportedMetadata(String),
|
||||
/// No matching key slot found.
|
||||
#[error("No matching key slot found for the provided credentials.")]
|
||||
NoMatchingKeySlot,
|
||||
@@ -56,9 +62,15 @@ impl DecryptError {
|
||||
Self::IntegrityCheckFailed => {
|
||||
"The archive appears to be corrupted. Try downloading it again."
|
||||
}
|
||||
Self::CorruptPayload(_) => {
|
||||
"The encrypted payload is incomplete or corrupted. Try downloading it again."
|
||||
}
|
||||
Self::UnsupportedVersion(_) => {
|
||||
"This archive was created with a newer version. Please update CASS."
|
||||
}
|
||||
Self::UnsupportedMetadata(_) => {
|
||||
"Update CASS or regenerate the archive with a supported export format."
|
||||
}
|
||||
Self::NoMatchingKeySlot => {
|
||||
"The credentials you provided don't match any key slot in this archive."
|
||||
}
|
||||
@@ -77,7 +89,11 @@ impl DecryptError {
|
||||
Self::EmptyPassword => "Empty password provided".to_string(),
|
||||
Self::InvalidFormat(detail) => format!("Invalid format: {}", detail),
|
||||
Self::IntegrityCheckFailed => "Integrity check failed".to_string(),
|
||||
Self::CorruptPayload(detail) => format!("Corrupt encrypted payload: {detail}"),
|
||||
Self::UnsupportedVersion(v) => format!("Unsupported version: {}", v),
|
||||
Self::UnsupportedMetadata(field) => {
|
||||
format!("Unsupported encryption metadata: {field}")
|
||||
}
|
||||
Self::NoMatchingKeySlot => "No matching key slot".to_string(),
|
||||
Self::CryptoError(e) => format!("Crypto error: {}", e),
|
||||
}
|
||||
@@ -313,6 +329,8 @@ impl ErrorCode for DecryptError {
|
||||
Self::UnsupportedVersion(_) => "E1005",
|
||||
Self::NoMatchingKeySlot => "E1006",
|
||||
Self::CryptoError(_) => "E1007",
|
||||
Self::UnsupportedMetadata(_) => "E1008",
|
||||
Self::CorruptPayload(_) => "E1009",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -377,7 +395,15 @@ mod tests {
|
||||
"not a valid archive",
|
||||
),
|
||||
(DecryptError::IntegrityCheckFailed, "corrupted"),
|
||||
(
|
||||
DecryptError::CorruptPayload("chunk authentication failed".into()),
|
||||
"payload is corrupted",
|
||||
),
|
||||
(DecryptError::UnsupportedVersion(99), "newer version"),
|
||||
(
|
||||
DecryptError::UnsupportedMetadata("compression".into()),
|
||||
"unsupported encryption metadata",
|
||||
),
|
||||
];
|
||||
|
||||
for (error, expected_substring) in errors {
|
||||
@@ -408,10 +434,18 @@ mod tests {
|
||||
DecryptError::IntegrityCheckFailed,
|
||||
"The archive appears to be corrupted or tampered with.",
|
||||
),
|
||||
(
|
||||
DecryptError::CorruptPayload("chunk authentication failed".into()),
|
||||
"The encrypted archive payload is corrupted or tampered with.",
|
||||
),
|
||||
(
|
||||
DecryptError::UnsupportedVersion(99),
|
||||
"This archive requires a newer version of the software (version 99).",
|
||||
),
|
||||
(
|
||||
DecryptError::UnsupportedMetadata("compression".into()),
|
||||
"This archive uses unsupported encryption metadata (compression).",
|
||||
),
|
||||
(
|
||||
DecryptError::NoMatchingKeySlot,
|
||||
"No matching key slot found for the provided credentials.",
|
||||
@@ -435,7 +469,9 @@ mod tests {
|
||||
DecryptError::EmptyPassword,
|
||||
DecryptError::InvalidFormat("header mismatch".into()),
|
||||
DecryptError::IntegrityCheckFailed,
|
||||
DecryptError::CorruptPayload("cipher authentication".into()),
|
||||
DecryptError::UnsupportedVersion(2),
|
||||
DecryptError::UnsupportedMetadata("compression".into()),
|
||||
DecryptError::CryptoError("GCM tag mismatch".into()),
|
||||
];
|
||||
|
||||
@@ -462,7 +498,9 @@ mod tests {
|
||||
DecryptError::EmptyPassword,
|
||||
DecryptError::InvalidFormat("test".into()),
|
||||
DecryptError::IntegrityCheckFailed,
|
||||
DecryptError::CorruptPayload("chunk authentication failed".into()),
|
||||
DecryptError::UnsupportedVersion(2),
|
||||
DecryptError::UnsupportedMetadata("compression".into()),
|
||||
DecryptError::NoMatchingKeySlot,
|
||||
DecryptError::CryptoError("test".into()),
|
||||
];
|
||||
@@ -556,7 +594,9 @@ mod tests {
|
||||
DecryptError::EmptyPassword,
|
||||
DecryptError::InvalidFormat("".into()),
|
||||
DecryptError::IntegrityCheckFailed,
|
||||
DecryptError::CorruptPayload("chunk authentication failed".into()),
|
||||
DecryptError::UnsupportedVersion(0),
|
||||
DecryptError::UnsupportedMetadata("compression".into()),
|
||||
DecryptError::NoMatchingKeySlot,
|
||||
DecryptError::CryptoError("".into()),
|
||||
];
|
||||
|
||||
@@ -1468,6 +1468,7 @@ mod tests {
|
||||
};
|
||||
use crate::pages::bundle::BundleBuilder;
|
||||
use crate::pages::encrypt::{DecryptionEngine, EncryptionEngine, MAX_CHUNK_SIZE, PayloadMeta};
|
||||
use crate::pages::errors::DecryptError;
|
||||
use crate::pages::verify::verify_bundle;
|
||||
use std::cell::Cell;
|
||||
use tempfile::TempDir;
|
||||
@@ -1555,11 +1556,13 @@ mod tests {
|
||||
write_json_pretty(&site_dir.join("config.json"), &config).unwrap();
|
||||
}
|
||||
|
||||
fn assert_unsupported_payload_format_error(err: anyhow::Error, compression: &str) {
|
||||
let rendered = err.to_string();
|
||||
fn assert_unsupported_payload_format_error(err: anyhow::Error, _compression: &str) {
|
||||
assert!(
|
||||
rendered.contains("supports only deflate") && rendered.contains(compression),
|
||||
"unexpected unsupported-format error: {err:#}"
|
||||
matches!(
|
||||
err.downcast_ref::<DecryptError>(),
|
||||
Some(DecryptError::UnsupportedMetadata(field)) if field == "compression"
|
||||
),
|
||||
"unexpected unsupported-metadata taxonomy: {err:#}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user