Support GnuPG AEAD password entries
This commit is contained in:
@@ -11,8 +11,9 @@ use std::{
|
|||||||
use cap_std::{ambient_authority, fs::Dir};
|
use cap_std::{ambient_authority, fs::Dir};
|
||||||
use pgp::{
|
use pgp::{
|
||||||
composed::{
|
composed::{
|
||||||
ArmorOptions, Deserializable, DetachedSignature, Esk, Message, MessageBuilder,
|
ArmorOptions, DecryptionOptions, Deserializable, DetachedSignature, Esk, Message,
|
||||||
PublicOrSecret, SignedPublicKey, SignedPublicSubKey, SignedSecretKey, SubpacketConfig,
|
MessageBuilder, PublicOrSecret, SignedPublicKey, SignedPublicSubKey, SignedSecretKey,
|
||||||
|
SubpacketConfig, TheRing,
|
||||||
},
|
},
|
||||||
crypto::{hash::HashAlgorithm, sym::SymmetricKeyAlgorithm},
|
crypto::{hash::HashAlgorithm, sym::SymmetricKeyAlgorithm},
|
||||||
packet::{SignatureType, Subpacket, SubpacketData},
|
packet::{SignatureType, Subpacket, SubpacketData},
|
||||||
@@ -460,7 +461,13 @@ impl KeyStore {
|
|||||||
let password = Password::from(supplied.expose());
|
let password = Password::from(supplied.expose());
|
||||||
let message = Message::from_bytes(Cursor::new(ciphertext.as_bytes()))
|
let message = Message::from_bytes(Cursor::new(ciphertext.as_bytes()))
|
||||||
.map_err(|_| CryptoError::CorruptMessage)?;
|
.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()
|
&& let Ok(plaintext) = decrypted.as_data_vec()
|
||||||
{
|
{
|
||||||
if let Some(info) = key.as_ref() {
|
if let Some(info) = key.as_ref() {
|
||||||
|
|||||||
@@ -8,15 +8,22 @@ use ironstorage::{
|
|||||||
crypto::{
|
crypto::{
|
||||||
CryptoError, DetachedSignatureBytes, KeyInfo, KeyStore, SecretProvider, SecretProviderError,
|
CryptoError, DetachedSignatureBytes, KeyInfo, KeyStore, SecretProvider, SecretProviderError,
|
||||||
},
|
},
|
||||||
repository::{EncryptedEntry, SecretBytes},
|
read::{ShowResult, VaultReader},
|
||||||
|
repository::{EncryptedEntry, Repository, SecretBytes},
|
||||||
};
|
};
|
||||||
use pgp::{
|
use pgp::{
|
||||||
composed::{EncryptionCaps, KeyType, Message, SecretKeyParamsBuilder, SubkeyParamsBuilder},
|
composed::{
|
||||||
crypto::{ecc_curve::ECCCurve, hash::HashAlgorithm, sym::SymmetricKeyAlgorithm},
|
Edata, EncryptionCaps, Esk, KeyType, Message, SecretKeyParamsBuilder, SubkeyParamsBuilder,
|
||||||
|
},
|
||||||
|
crypto::{
|
||||||
|
aead::AeadAlgorithm, ecc_curve::ECCCurve, hash::HashAlgorithm, sym::SymmetricKeyAlgorithm,
|
||||||
|
},
|
||||||
|
packet::{ProtectedDataConfig, SymEncryptedProtectedDataConfig},
|
||||||
ser::Serialize as _,
|
ser::Serialize as _,
|
||||||
types::{Password, Timestamp},
|
types::{Password, Timestamp},
|
||||||
};
|
};
|
||||||
use rand_chacha::{ChaCha20Rng, rand_core::SeedableRng};
|
use rand_chacha::{ChaCha20Rng, rand_core::SeedableRng};
|
||||||
|
use sha2::{Digest as _, Sha256};
|
||||||
use smallvec::smallvec;
|
use smallvec::smallvec;
|
||||||
use support::compatibility::{FixtureSet, TestResult};
|
use support::compatibility::{FixtureSet, TestResult};
|
||||||
|
|
||||||
@@ -24,6 +31,8 @@ use support::compatibility::{FixtureSet, TestResult};
|
|||||||
struct FixtureSecrets {
|
struct FixtureSecrets {
|
||||||
values: BTreeMap<String, Vec<u8>>,
|
values: BTreeMap<String, Vec<u8>>,
|
||||||
requests: Vec<String>,
|
requests: Vec<String>,
|
||||||
|
accepted: Vec<String>,
|
||||||
|
rejected: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FixtureSecrets {
|
impl FixtureSecrets {
|
||||||
@@ -41,6 +50,8 @@ impl FixtureSecrets {
|
|||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
requests: Vec::new(),
|
requests: Vec::new(),
|
||||||
|
accepted: Vec::new(),
|
||||||
|
rejected: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +59,8 @@ impl FixtureSecrets {
|
|||||||
Self {
|
Self {
|
||||||
values: BTreeMap::from([(fingerprint.to_owned(), passphrase.as_ref().to_vec())]),
|
values: BTreeMap::from([(fingerprint.to_owned(), passphrase.as_ref().to_vec())]),
|
||||||
requests: Vec::new(),
|
requests: Vec::new(),
|
||||||
|
accepted: Vec::new(),
|
||||||
|
rejected: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,6 +75,15 @@ impl SecretProvider for FixtureSecrets {
|
|||||||
.map(SecretBytes::new)
|
.map(SecretBytes::new)
|
||||||
.ok_or(SecretProviderError::Unavailable)
|
.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;
|
struct CancelledProvider;
|
||||||
@@ -186,6 +208,127 @@ fn decrypts_every_gnupg_audited_pass_fixture() -> TestResult {
|
|||||||
Ok(())
|
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]
|
#[test]
|
||||||
fn encrypts_for_one_or_many_recipients_without_compression() -> TestResult {
|
fn encrypts_for_one_or_many_recipients_without_compression() -> TestResult {
|
||||||
let fixture = FixtureSet::load()?;
|
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 ciphertext = keys.encrypt(SecretBytes::new(expected.to_vec()), &recipients)?;
|
||||||
|
|
||||||
let parsed = Message::from_bytes(Cursor::new(ciphertext.as_bytes()))?;
|
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] {
|
for key in [alice, bob] {
|
||||||
let mut provider = FixtureSecrets::one(&key.primary_fingerprint, &key.passphrase);
|
let mut provider = FixtureSecrets::one(&key.primary_fingerprint, &key.passphrase);
|
||||||
let plaintext = keys.decrypt(&ciphertext, &mut provider)?;
|
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
|
- valid loose-object Git repository templates, including an automatic-commit
|
||||||
history and nested-repository boundaries.
|
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
|
Rust tests parse keys, verify signatures, decrypt every entry, compare exact
|
||||||
plaintext bytes, validate ciphertext digests, validate Git objects and refs,
|
plaintext bytes, validate ciphertext digests, validate Git objects and refs,
|
||||||
and materialize isolated stores and repositories. They never invoke `pass`,
|
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
|
changes and the generated fingerprints, digests, and Git object IDs before
|
||||||
committing them.
|
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
|
An optional developer interoperability audit may import the generated keys
|
||||||
into a disposable GnuPG home, decrypt the `.gpg` entries, and point an
|
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
|
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(())
|
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 {
|
fn fs_config(temporary: &tempfile::TempDir) -> TestResult {
|
||||||
std::fs::create_dir_all(temporary.path().join("keys"))?;
|
std::fs::create_dir_all(temporary.path().join("keys"))?;
|
||||||
std::fs::create_dir_all(temporary.path().join("vault"))?;
|
std::fs::create_dir_all(temporary.path().join("vault"))?;
|
||||||
|
|||||||
@@ -99,11 +99,28 @@ pub struct RepositoryRecord {
|
|||||||
pub commits: Vec<String>,
|
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 {
|
pub struct FixtureSet {
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
pub upstream: UpstreamManifest,
|
pub upstream: UpstreamManifest,
|
||||||
pub behavior: BehaviorManifest,
|
pub behavior: BehaviorManifest,
|
||||||
pub generated: GeneratedManifest,
|
pub generated: GeneratedManifest,
|
||||||
|
pub gnupg_aead: GnuPgAeadFixture,
|
||||||
pub layouts: toml::Value,
|
pub layouts: toml::Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,12 +130,14 @@ impl FixtureSet {
|
|||||||
let upstream = parse_toml(root.join("upstream.toml"))?;
|
let upstream = parse_toml(root.join("upstream.toml"))?;
|
||||||
let behavior = parse_toml(root.join("behavior.toml"))?;
|
let behavior = parse_toml(root.join("behavior.toml"))?;
|
||||||
let generated = parse_toml(root.join("generated.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"))?;
|
let layouts = parse_toml(root.join("layouts.toml"))?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
root,
|
root,
|
||||||
upstream,
|
upstream,
|
||||||
behavior,
|
behavior,
|
||||||
generated,
|
generated,
|
||||||
|
gnupg_aead,
|
||||||
layouts,
|
layouts,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
symmetrically-encrypted integrity-protected data packet and deliberately does
|
||||||
not add a compression packet, matching upstream `pass --compress-algo=none`.
|
not add a compression packet, matching upstream `pass --compress-algo=none`.
|
||||||
Every resolved recipient receives a public-key encrypted session-key packet.
|
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
|
Decryption first examines those recipient packets. A secret is requested only
|
||||||
for imported protected keys that can match the message; unavailable identities
|
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
|
Cancellation stops immediately. Wrong secrets, malformed messages, and a lack
|
||||||
of matching secret keys remain distinct failures.
|
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
|
## Secret lifetime and recipient signatures
|
||||||
|
|
||||||
Decrypted data and provider-returned unlock secrets use `SecretBytes`. Its
|
Decrypted data and provider-returned unlock secrets use `SecretBytes`. Its
|
||||||
@@ -65,12 +98,15 @@ from another imported key is rejected.
|
|||||||
## Compatibility evidence
|
## Compatibility evidence
|
||||||
|
|
||||||
`crates/storage/tests/fixtures/compatibility` contains protected public and
|
`crates/storage/tests/fixtures/compatibility` contains protected public and
|
||||||
secret exports, single- and multi-recipient `.gpg` entries, and signed
|
secret exports, single- and multi-recipient `.gpg` entries, signed recipient
|
||||||
recipient files. The fixture audit established that GnuPG decrypts the packet
|
files, and a GnuPG 2.4.8-produced packet-type-20 AES-256/OCB entry. The fixture
|
||||||
profile used by the generator. Production tests then exercise that same
|
audit established that GnuPG decrypts the packet profile used by the generator.
|
||||||
uncompressed `MessageBuilder` profile through `KeyStore::encrypt`, independently
|
Production tests then exercise that same uncompressed `MessageBuilder` profile
|
||||||
parse and decrypt its output, decrypt every checked-in GnuPG-audited entry, and
|
through `KeyStore::encrypt`, independently parse and decrypt its output, decrypt
|
||||||
verify both checked-in and newly generated recipient signatures.
|
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
|
Tests never use a real user keyring and application runtime never executes an
|
||||||
external cryptographic tool.
|
external cryptographic tool.
|
||||||
|
|||||||
Reference in New Issue
Block a user