Support GnuPG AEAD password entries
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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
|
||||
|
||||
3
crates/storage/tests/fixtures/compatibility/gnupg-aead/expected/paypal.txt
vendored
Normal file
3
crates/storage/tests/fixtures/compatibility/gnupg-aead/expected/paypal.txt
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
synthetic-aead-password
|
||||
login: alice@example.test
|
||||
url: https://paypal.example.test
|
||||
12
crates/storage/tests/fixtures/compatibility/gnupg-aead/fixture.toml
vendored
Normal file
12
crates/storage/tests/fixtures/compatibility/gnupg-aead/fixture.toml
vendored
Normal 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"
|
||||
1
crates/storage/tests/fixtures/compatibility/gnupg-aead/store/.gpg-id
vendored
Normal file
1
crates/storage/tests/fixtures/compatibility/gnupg-aead/store/.gpg-id
vendored
Normal file
@@ -0,0 +1 @@
|
||||
7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30
|
||||
BIN
crates/storage/tests/fixtures/compatibility/gnupg-aead/store/paypal.gpg
vendored
Normal file
BIN
crates/storage/tests/fixtures/compatibility/gnupg-aead/store/paypal.gpg
vendored
Normal file
Binary file not shown.
@@ -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"))?;
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user