Support GnuPG AEAD password entries

This commit is contained in:
Hermes Agent
2026-08-10 08:57:57 +00:00
parent d66d9b0f08
commit 977eb236da
10 changed files with 319 additions and 13 deletions

View File

@@ -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<String, Vec<u8>>,
requests: Vec<String>,
accepted: Vec<String>,
rejected: Vec<String>,
}
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)?;