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

@@ -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() {

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)?;

View File

@@ -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

View File

@@ -0,0 +1,3 @@
synthetic-aead-password
login: alice@example.test
url: https://paypal.example.test

View File

@@ -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"

View File

@@ -0,0 +1 @@
7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30

View File

@@ -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"))?;

View File

@@ -99,11 +99,28 @@ pub struct RepositoryRecord {
pub commits: Vec<String>,
}
#[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,
})
}

View File

@@ -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.