diff --git a/CHANGELOG.md b/CHANGELOG.md index 5654ca8e..ce700078 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- IPC writes of X.509 identities decode their declared text encoding before + storage, so protected and unprotected archives round-trip without corruption. + - Imported X.509 identities accept self-issued certificates signed by a distinct issuer with the same name and preserve supplementary Unicode in certificate subjects for both leaf and issuer certificates when exporting PKCS#12, diff --git a/secretspec/src/secrets.rs b/secretspec/src/secrets.rs index 05aeafa6..f9fce3d0 100644 --- a/secretspec/src/secrets.rs +++ b/secretspec/src/secrets.rs @@ -7201,7 +7201,9 @@ impl Secrets { ) -> Result { with_ipc_audit_purpose(purpose, || { self.require_ipc_surface(AuditAction::Set, name)?; - self.store_secret_with_input(name, |_| Ok(SecretBytes::from_utf8(value)), false) + // IPC carries text, so typed binary values use their declared + // encoding just as they do in `set_text` and inline reads. + self.store_secret_with_input(name, |_| Ok(SecretBytes::from_utf8(value)), true) }) } diff --git a/secretspec/src/typed_tests.rs b/secretspec/src/typed_tests.rs index d1a0f3b7..60577334 100644 --- a/secretspec/src/typed_tests.rs +++ b/secretspec/src/typed_tests.rs @@ -1491,6 +1491,67 @@ TEXT = { description = "encoded input", type = "x509_identity", as_path = true } } } +#[cfg(feature = "cli")] +fn assert_ipc_identity_text_write_round_trip(body: &str, bytes: &[u8]) { + use crate::secrets::IpcAuditPurpose; + + let encoded = BASE64.encode(bytes); + let (dir, spec) = dotenv_spec(body, &format!("ID={encoded}\n")); + let purpose = || IpcAuditPurpose { + consumer: "typed-test".into(), + operation: "set".into(), + host: None, + path: None, + }; + + // Text input may carry a trailing newline, but the stored representation + // must remain canonical and must never gain a second layer of encoding. + spec.store_named_for_ipc("ID", format!("{encoded}\r\n"), purpose()) + .unwrap(); + let response = spec.resolve().unwrap(); + assert!(response.is_ok()); + let value = response.secrets["ID"].value.as_deref().unwrap(); + assert_eq!(value, encoded); + assert_eq!(BASE64.decode(value.as_bytes()).unwrap(), bytes); + + // Invalid encoded text must fail before overwriting the valid archive, + // including when a password binding defers archive validation to reads. + let stored = fs::read(dir.path().join(".env")).unwrap(); + let error = spec + .store_named_for_ipc("ID", "invalid%".to_string(), purpose()) + .err() + .unwrap(); + assert!(matches!(error, SecretSpecError::DecodeFailed { .. })); + assert_eq!(fs::read(dir.path().join(".env")).unwrap(), stored); +} + +#[cfg(feature = "cli")] +#[test] +fn ipc_identity_text_writes_round_trip_unprotected_archives() { + let _env = scrub_resolution_env(); + assert_ipc_identity_text_write_round_trip( + r#" +[profiles.default] +ID = { description = "identity", type = "x509_identity" } +"#, + &generated_identity(), + ); +} + +#[cfg(feature = "cli")] +#[test] +fn ipc_identity_text_writes_round_trip_protected_archives() { + let _env = scrub_resolution_env(); + assert_ipc_identity_text_write_round_trip( + r#" +[profiles.default] +ID = { description = "identity", type = "x509_identity", credentials = { password = "PW" } } +PW = { description = "password", default = "fixture-password" } +"#, + include_bytes!("../tests/fixtures/x509/p256.pfx"), + ); +} + #[cfg(feature = "cli")] #[test] fn ipc_derived_identity_files_are_leased_and_persistent_generation_is_read_only() {