diff --git a/crates/storage/src/crypto.rs b/crates/storage/src/crypto.rs index 160d79a..a381647 100644 --- a/crates/storage/src/crypto.rs +++ b/crates/storage/src/crypto.rs @@ -11,8 +11,9 @@ use std::{ use cap_std::{ambient_authority, fs::Dir}; use pgp::{ composed::{ - ArmorOptions, Deserializable, DetachedSignature, Esk, Message, MessageBuilder, - PublicOrSecret, SignedPublicKey, SignedPublicSubKey, SignedSecretKey, SubpacketConfig, + ArmorOptions, DecryptionOptions, Deserializable, DetachedSignature, Esk, Message, + MessageBuilder, PublicOrSecret, SignedPublicKey, SignedPublicSubKey, SignedSecretKey, + SubpacketConfig, TheRing, }, crypto::{hash::HashAlgorithm, sym::SymmetricKeyAlgorithm}, packet::{SignatureType, Subpacket, SubpacketData}, @@ -460,7 +461,13 @@ impl KeyStore { let password = Password::from(supplied.expose()); let message = Message::from_bytes(Cursor::new(ciphertext.as_bytes())) .map_err(|_| CryptoError::CorruptMessage)?; - if let Ok(mut decrypted) = message.decrypt(&password, secret) + let ring = TheRing { + secret_keys: vec![secret], + key_passwords: vec![&password], + decrypt_options: DecryptionOptions::new().enable_gnupg_aead(), + ..Default::default() + }; + if let Ok((mut decrypted, _)) = message.decrypt_the_ring(ring, true) && let Ok(plaintext) = decrypted.as_data_vec() { if let Some(info) = key.as_ref() { diff --git a/crates/storage/tests/crypto_compatibility.rs b/crates/storage/tests/crypto_compatibility.rs index 88a2d5e..52130f1 100644 --- a/crates/storage/tests/crypto_compatibility.rs +++ b/crates/storage/tests/crypto_compatibility.rs @@ -8,15 +8,22 @@ use ironstorage::{ crypto::{ CryptoError, DetachedSignatureBytes, KeyInfo, KeyStore, SecretProvider, SecretProviderError, }, - repository::{EncryptedEntry, SecretBytes}, + read::{ShowResult, VaultReader}, + repository::{EncryptedEntry, Repository, SecretBytes}, }; use pgp::{ - composed::{EncryptionCaps, KeyType, Message, SecretKeyParamsBuilder, SubkeyParamsBuilder}, - crypto::{ecc_curve::ECCCurve, hash::HashAlgorithm, sym::SymmetricKeyAlgorithm}, + composed::{ + Edata, EncryptionCaps, Esk, KeyType, Message, SecretKeyParamsBuilder, SubkeyParamsBuilder, + }, + crypto::{ + aead::AeadAlgorithm, ecc_curve::ECCCurve, hash::HashAlgorithm, sym::SymmetricKeyAlgorithm, + }, + packet::{ProtectedDataConfig, SymEncryptedProtectedDataConfig}, ser::Serialize as _, types::{Password, Timestamp}, }; use rand_chacha::{ChaCha20Rng, rand_core::SeedableRng}; +use sha2::{Digest as _, Sha256}; use smallvec::smallvec; use support::compatibility::{FixtureSet, TestResult}; @@ -24,6 +31,8 @@ use support::compatibility::{FixtureSet, TestResult}; struct FixtureSecrets { values: BTreeMap>, requests: Vec, + accepted: Vec, + rejected: Vec, } impl FixtureSecrets { @@ -41,6 +50,8 @@ impl FixtureSecrets { }) .collect(), requests: Vec::new(), + accepted: Vec::new(), + rejected: Vec::new(), } } @@ -48,6 +59,8 @@ impl FixtureSecrets { Self { values: BTreeMap::from([(fingerprint.to_owned(), passphrase.as_ref().to_vec())]), requests: Vec::new(), + accepted: Vec::new(), + rejected: Vec::new(), } } } @@ -62,6 +75,15 @@ impl SecretProvider for FixtureSecrets { .map(SecretBytes::new) .ok_or(SecretProviderError::Unavailable) } + + fn secret_accepted(&mut self, key: &KeyInfo) -> Result<(), SecretProviderError> { + self.accepted.push(key.fingerprint().as_str().to_owned()); + Ok(()) + } + + fn secret_rejected(&mut self, key: &KeyInfo) { + self.rejected.push(key.fingerprint().as_str().to_owned()); + } } struct CancelledProvider; @@ -186,6 +208,127 @@ fn decrypts_every_gnupg_audited_pass_fixture() -> TestResult { Ok(()) } +#[test] +fn decrypts_gnupg_packet_20_aes256_ocb_through_the_vault_reader() -> TestResult { + let fixture = FixtureSet::load()?; + let aead = &fixture.gnupg_aead; + let alice = fixture.key("alice")?; + assert_eq!(aead.format, 1); + assert_eq!(aead.producer, "GnuPG 2.4.8"); + assert!(aead.command_profile.contains("--force-ocb")); + assert_eq!(aead.packet_tag, 20); + assert_eq!(aead.cipher, "AES256"); + assert_eq!(aead.aead_mode, "OCB"); + assert_eq!( + aead.recipient_primary_fingerprint, + alice.primary_fingerprint + ); + assert_eq!( + aead.recipient_encryption_subkey_fingerprint, + alice.encryption_subkey_fingerprint + ); + + let ciphertext = fixture.read(&aead.entry)?; + assert_eq!( + hex::encode(Sha256::digest(&ciphertext)), + aead.ciphertext_sha256 + ); + let message = Message::from_bytes(Cursor::new(&ciphertext))?; + let Message::Encrypted { esk, edata, .. } = &message else { + panic!("GnuPG AEAD fixture must be encrypted"); + }; + let [Esk::PublicKeyEncryptedSessionKey(pkesk)] = esk.as_slice() else { + panic!("GnuPG AEAD fixture must have one public-key recipient"); + }; + let secret = fixture.parse_secret_key(alice)?; + assert!(pkesk.match_identity(&secret.secret_subkeys[0].key)); + assert!(!pkesk.match_identity(&secret.primary_key)); + let Edata::GnupgAeadData { reader } = edata else { + panic!("GnuPG fixture must use packet type 20"); + }; + let ProtectedDataConfig::GnupgAead(config) = reader.config() else { + panic!("packet type 20 must carry GnuPG AEAD configuration"); + }; + assert_eq!(config.sym_alg, SymmetricKeyAlgorithm::AES256); + assert_eq!(config.aead, AeadAlgorithm::Ocb); + + let repository = Repository::open(fixture.path(format!("gnupg-aead/{}", aead.store)))?; + let keys = KeyStore::load(fixture.path("keys"))?; + let reader = VaultReader::new(&repository, &keys); + let mut provider = FixtureSecrets::one(&alice.primary_fingerprint, &alice.passphrase); + let ShowResult::Entry(plaintext) = reader.show(Some("paypal"), &mut provider)? else { + panic!("paypal must resolve as an entry"); + }; + assert_eq!(plaintext.expose(), fixture.read(&aead.plaintext)?); + assert_eq!( + provider.requests, + std::slice::from_ref(&alice.primary_fingerprint) + ); + assert_eq!( + provider.accepted, + std::slice::from_ref(&alice.primary_fingerprint) + ); + assert!(provider.rejected.is_empty()); + Ok(()) +} + +#[test] +fn rejects_invalid_gnupg_packet_20_inputs_without_accepting_secrets() -> TestResult { + let fixture = FixtureSet::load()?; + let aead = &fixture.gnupg_aead; + let alice = fixture.key("alice")?; + let ciphertext = fixture.read(&aead.entry)?; + let keys = KeyStore::load(fixture.path(&alice.secret_armor))?; + + let mut wrong = FixtureSecrets::one(&alice.primary_fingerprint, b"wrong-aead-passphrase"); + assert!(matches!( + keys.decrypt(&EncryptedEntry::new(ciphertext.clone()), &mut wrong), + Err(CryptoError::DecryptionFailed) + )); + assert!(wrong.accepted.is_empty()); + assert_eq!( + wrong.rejected, + std::slice::from_ref(&alice.primary_fingerprint) + ); + + let mut tampered = ciphertext.clone(); + *tampered.last_mut().expect("non-empty AEAD fixture") ^= 0x01; + let mut correct = FixtureSecrets::one(&alice.primary_fingerprint, &alice.passphrase); + assert!(matches!( + keys.decrypt(&EncryptedEntry::new(tampered), &mut correct), + Err(CryptoError::DecryptionFailed) + )); + assert!(correct.accepted.is_empty()); + assert_eq!( + correct.rejected, + std::slice::from_ref(&alice.primary_fingerprint) + ); + + let public_only = KeyStore::load(fixture.path(&alice.public_armor))?; + let mut unavailable = FixtureSecrets::default(); + assert!(matches!( + public_only.decrypt(&EncryptedEntry::new(ciphertext.clone()), &mut unavailable), + Err(CryptoError::MissingSecretKey) + )); + assert!(unavailable.requests.is_empty()); + + let mut unsupported = ciphertext; + let config = unsupported + .windows(3) + .position(|window| window == [1, 9, 2]) + .expect("packet-20 version, AES-256, and OCB configuration"); + unsupported[config + 2] = 1; + let mut unused = FixtureSecrets::one(&alice.primary_fingerprint, &alice.passphrase); + assert!(matches!( + keys.decrypt(&EncryptedEntry::new(unsupported), &mut unused), + Err(CryptoError::CorruptMessage) + )); + assert!(unused.requests.is_empty()); + assert!(unused.accepted.is_empty()); + assert!(unused.rejected.is_empty()); + Ok(()) +} + #[test] fn encrypts_for_one_or_many_recipients_without_compression() -> TestResult { let fixture = FixtureSet::load()?; @@ -203,7 +346,16 @@ fn encrypts_for_one_or_many_recipients_without_compression() -> TestResult { let ciphertext = keys.encrypt(SecretBytes::new(expected.to_vec()), &recipients)?; let parsed = Message::from_bytes(Cursor::new(ciphertext.as_bytes()))?; - assert!(parsed.is_encrypted()); + let Message::Encrypted { edata, .. } = &parsed else { + panic!("IronStorage output must be encrypted"); + }; + let Edata::SymEncryptedProtectedData { reader } = edata else { + panic!("IronStorage output must remain packet type 18"); + }; + assert_eq!( + reader.config(), + &ProtectedDataConfig::Seipd(SymEncryptedProtectedDataConfig::V1) + ); for key in [alice, bob] { let mut provider = FixtureSecrets::one(&key.primary_fingerprint, &key.passphrase); let plaintext = keys.decrypt(&ciphertext, &mut provider)?; diff --git a/crates/storage/tests/fixtures/compatibility/README.md b/crates/storage/tests/fixtures/compatibility/README.md index 9b0b024..003c97d 100644 --- a/crates/storage/tests/fixtures/compatibility/README.md +++ b/crates/storage/tests/fixtures/compatibility/README.md @@ -22,6 +22,14 @@ secrets. The generator reuses the checked-in identities and produces: - valid loose-object Git repository templates, including an automatic-commit history and nested-repository boundaries. +`gnupg-aead/` is a separate, non-generated compatibility case. Its synthetic +Alice entry was encrypted by GnuPG 2.4.8 with AES-256 and `--force-ocb`, yielding +GnuPG AEAD packet type 20 addressed to Alice's encryption subkey. The directory +contains the exact producer profile and ciphertext digest, a normal +password-store tree, and the expected plaintext. It deliberately lives outside +the reproducible generator's `stores/`, `expected/`, and `repositories/` +directories so a refresh cannot silently replace genuine GnuPG producer output. + Rust tests parse keys, verify signatures, decrypt every entry, compare exact plaintext bytes, validate ciphertext digests, validate Git objects and refs, and materialize isolated stores and repositories. They never invoke `pass`, @@ -43,6 +51,11 @@ then make that rotation intentionally non-reproducible. Review all binary changes and the generated fingerprints, digests, and Git object IDs before committing them. +When rotating the checked-in synthetic identities, regenerate the packet-20 +case explicitly with the producer profile in `gnupg-aead/fixture.toml`, update +both recorded fingerprints and the digest, and independently inspect it with +GnuPG's packet listing before review. + An optional developer interoperability audit may import the generated keys into a disposable GnuPG home, decrypt the `.gpg` entries, and point an upstream `pass` 1.7.4 checkout at a materialized store. That audit is never a diff --git a/crates/storage/tests/fixtures/compatibility/gnupg-aead/expected/paypal.txt b/crates/storage/tests/fixtures/compatibility/gnupg-aead/expected/paypal.txt new file mode 100644 index 0000000..099e1fa --- /dev/null +++ b/crates/storage/tests/fixtures/compatibility/gnupg-aead/expected/paypal.txt @@ -0,0 +1,3 @@ +synthetic-aead-password +login: alice@example.test +url: https://paypal.example.test diff --git a/crates/storage/tests/fixtures/compatibility/gnupg-aead/fixture.toml b/crates/storage/tests/fixtures/compatibility/gnupg-aead/fixture.toml new file mode 100644 index 0000000..8184bdb --- /dev/null +++ b/crates/storage/tests/fixtures/compatibility/gnupg-aead/fixture.toml @@ -0,0 +1,12 @@ +format = 1 +producer = "GnuPG 2.4.8" +command_profile = "--batch --trust-model always --compress-algo none --cipher-algo AES256 --force-ocb --set-filename '' --encrypt" +packet_tag = 20 +cipher = "AES256" +aead_mode = "OCB" +recipient_primary_fingerprint = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30" +recipient_encryption_subkey_fingerprint = "10208D0B84AD13D2157795D5EB55885EC93C0A26" +store = "store" +entry = "gnupg-aead/store/paypal.gpg" +plaintext = "gnupg-aead/expected/paypal.txt" +ciphertext_sha256 = "4dc0eae06afa822739f41a296cd61a9bd1af91057c2ab1d61e4441874250eaf7" diff --git a/crates/storage/tests/fixtures/compatibility/gnupg-aead/store/.gpg-id b/crates/storage/tests/fixtures/compatibility/gnupg-aead/store/.gpg-id new file mode 100644 index 0000000..682ed00 --- /dev/null +++ b/crates/storage/tests/fixtures/compatibility/gnupg-aead/store/.gpg-id @@ -0,0 +1 @@ +7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30 diff --git a/crates/storage/tests/fixtures/compatibility/gnupg-aead/store/paypal.gpg b/crates/storage/tests/fixtures/compatibility/gnupg-aead/store/paypal.gpg new file mode 100644 index 0000000..70e2ae3 Binary files /dev/null and b/crates/storage/tests/fixtures/compatibility/gnupg-aead/store/paypal.gpg differ diff --git a/crates/storage/tests/secret_store.rs b/crates/storage/tests/secret_store.rs index e1f9799..3d916ad 100644 --- a/crates/storage/tests/secret_store.rs +++ b/crates/storage/tests/secret_store.rs @@ -504,6 +504,69 @@ fn missing_openpgp_passphrases_are_prompted_verified_and_only_then_persisted() - Ok(()) } +#[test] +fn gnupg_aead_passphrases_are_persisted_only_after_authenticated_decryption() -> TestResult { + let fixture = FixtureSet::load()?; + let key = fixture.key("alice")?; + let keys = KeyStore::load(fixture.path(&key.secret_armor))?; + let ciphertext = fixture.read(&fixture.gnupg_aead.entry)?; + let expected = fixture.read(&fixture.gnupg_aead.plaintext)?; + let reference = SecretReference::openpgp_passphrase(&key.primary_fingerprint)?; + + let prompt = MemoryPrompt::responding(Ok(key.passphrase.as_bytes())); + let mut provisioned = + store(MemoryBackend::default()).with_openpgp_passphrase_prompt(prompt.clone()); + provisioned.unlock()?; + assert_eq!( + keys.decrypt(&EncryptedEntry::new(ciphertext.clone()), &mut provisioned)? + .expose(), + expected + ); + assert_eq!( + provisioned.retrieve(&reference)?.expose(), + key.passphrase.as_bytes() + ); + assert_eq!( + prompt.requested(), + std::slice::from_ref(&key.primary_fingerprint) + ); + + let wrong_prompt = MemoryPrompt::responding(Ok(b"incorrect AEAD passphrase")); + let mut wrong = + store(MemoryBackend::default()).with_openpgp_passphrase_prompt(wrong_prompt.clone()); + wrong.unlock()?; + assert!(matches!( + keys.decrypt(&EncryptedEntry::new(ciphertext.clone()), &mut wrong), + Err(CryptoError::DecryptionFailed) + )); + assert!(matches!( + wrong.retrieve(&reference), + Err(SecretStoreError::Missing) + )); + assert_eq!( + wrong_prompt.requested(), + std::slice::from_ref(&key.primary_fingerprint) + ); + + let mut tampered_ciphertext = ciphertext; + *tampered_ciphertext + .last_mut() + .expect("non-empty AEAD fixture") ^= 0x01; + let tampered_prompt = MemoryPrompt::responding(Ok(key.passphrase.as_bytes())); + let mut tampered = + store(MemoryBackend::default()).with_openpgp_passphrase_prompt(tampered_prompt); + tampered.unlock()?; + assert!(matches!( + keys.decrypt(&EncryptedEntry::new(tampered_ciphertext), &mut tampered), + Err(CryptoError::DecryptionFailed) + )); + assert!(matches!( + tampered.retrieve(&reference), + Err(SecretStoreError::Missing) + )); + Ok(()) +} + fn fs_config(temporary: &tempfile::TempDir) -> TestResult { std::fs::create_dir_all(temporary.path().join("keys"))?; std::fs::create_dir_all(temporary.path().join("vault"))?; diff --git a/crates/storage/tests/support/compatibility.rs b/crates/storage/tests/support/compatibility.rs index 2395d05..b265d03 100644 --- a/crates/storage/tests/support/compatibility.rs +++ b/crates/storage/tests/support/compatibility.rs @@ -99,11 +99,28 @@ pub struct RepositoryRecord { pub commits: Vec, } +#[derive(Debug, Deserialize)] +pub struct GnuPgAeadFixture { + pub format: u8, + pub producer: String, + pub command_profile: String, + pub packet_tag: u8, + pub cipher: String, + pub aead_mode: String, + pub recipient_primary_fingerprint: String, + pub recipient_encryption_subkey_fingerprint: String, + pub store: String, + pub entry: String, + pub plaintext: String, + pub ciphertext_sha256: String, +} + pub struct FixtureSet { root: PathBuf, pub upstream: UpstreamManifest, pub behavior: BehaviorManifest, pub generated: GeneratedManifest, + pub gnupg_aead: GnuPgAeadFixture, pub layouts: toml::Value, } @@ -113,12 +130,14 @@ impl FixtureSet { let upstream = parse_toml(root.join("upstream.toml"))?; let behavior = parse_toml(root.join("behavior.toml"))?; let generated = parse_toml(root.join("generated.toml"))?; + let gnupg_aead = parse_toml(root.join("gnupg-aead/fixture.toml"))?; let layouts = parse_toml(root.join("layouts.toml"))?; Ok(Self { root, upstream, behavior, generated, + gnupg_aead, layouts, }) } diff --git a/docs/cryptography.md b/docs/cryptography.md index 8dcea29..48facb9 100644 --- a/docs/cryptography.md +++ b/docs/cryptography.md @@ -38,6 +38,8 @@ encryption-capable primary key. Output uses AES-256 in a version 1 symmetrically-encrypted integrity-protected data packet and deliberately does not add a compression packet, matching upstream `pass --compress-algo=none`. Every resolved recipient receives a public-key encrypted session-key packet. +IronStorage continues to produce this packet-type-18 SEIPDv1/MDC profile so +existing output remains unchanged. Decryption first examines those recipient packets. A secret is requested only for imported protected keys that can match the message; unavailable identities @@ -45,6 +47,37 @@ are skipped so any one recipient of a multi-recipient entry can decrypt it. Cancellation stops immediately. Wrong secrets, malformed messages, and a lack of matching secret keys remain distinct failures. +For compatibility with password stores written by modern GnuPG, decryption +also accepts GnuPG's packet-type-20 OCB AEAD extension; the checked-in +GnuPG 2.4.8 compatibility case uses AES-256. +This is an explicit read-compatibility profile: it does not enable historical +unauthenticated encrypted-data packets, accept unsupported packet-20 modes, or +change the format IronStorage writes. AEAD authentication must succeed before +the provider is told that an interactively supplied passphrase was accepted; +wrong passphrases and tampered ciphertext therefore cannot be persisted. + +### Upstream `pass` format boundary + +The format boundary was re-audited against the current upstream tools on +2026-08-10. [`pass` 1.7.4 delegates reads and writes directly to +GnuPG](https://git.zx2c4.com/password-store/plain/src/password-store.sh): reads +use `gpg -d`, while writes use `gpg -e` with `--compress-algo=none`; `pass` +does not select or parse an encrypted-data packet itself. The current +[Homebrew `pass` formula](https://formulae.brew.sh/formula/pass) packages that +same release with GnuPG 2.5.21. GnuPG's +[OpenPGP options](https://www.gnupg.org/documentation/manuals/gnupg/OpenPGP-Options.html) +state that public-key encryption selects CFB+MDC or OCB from recipient key +preferences, which explains why an ordinary current `pass` installation can +write packet type 20 without a `pass` option requesting it. + +Default `pass` does not opt into unauthenticated legacy decryption. GnuPG made +missing-MDC messages a hard failure in 2.2.8 and requires the explicit, +dangerous `--ignore-mdc-error` override to recover plaintext from them. The +[GnuPG 2.2.8 security release](https://lists.gnupg.org/pipermail/gnupg-users/2018-June/060644.html) +warns against using that override unconditionally. IronStorage therefore keeps +legacy packet-type-9 decryption disabled: its default behavior matches default +`pass` while preserving the authenticated-decryption guarantee. + ## Secret lifetime and recipient signatures Decrypted data and provider-returned unlock secrets use `SecretBytes`. Its @@ -65,12 +98,15 @@ from another imported key is rejected. ## Compatibility evidence `crates/storage/tests/fixtures/compatibility` contains protected public and -secret exports, single- and multi-recipient `.gpg` entries, and signed -recipient files. The fixture audit established that GnuPG decrypts the packet -profile used by the generator. Production tests then exercise that same -uncompressed `MessageBuilder` profile through `KeyStore::encrypt`, independently -parse and decrypt its output, decrypt every checked-in GnuPG-audited entry, and -verify both checked-in and newly generated recipient signatures. +secret exports, single- and multi-recipient `.gpg` entries, signed recipient +files, and a GnuPG 2.4.8-produced packet-type-20 AES-256/OCB entry. The fixture +audit established that GnuPG decrypts the packet profile used by the generator. +Production tests then exercise that same uncompressed `MessageBuilder` profile +through `KeyStore::encrypt`, independently parse and decrypt its output, decrypt +every checked-in GnuPG-audited entry through the storage API, and verify both +checked-in and newly generated recipient signatures. Packet-type-20 tests also +cover the exact encryption subkey, wrong passphrases, authentication failure, +missing secret keys, unsupported modes, and deferred secret persistence. Tests never use a real user keyring and application runtime never executes an external cryptographic tool.