#![forbid(unsafe_code)] mod support; use std::{collections::BTreeMap, io::Cursor, path::Path}; use ironstorage::{ crypto::{ CryptoError, DetachedSignatureBytes, KeyInfo, KeyStore, SecretProvider, SecretProviderError, }, read::{ShowResult, VaultReader}, repository::{EncryptedEntry, Repository, SecretBytes}, }; use pgp::{ 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}; #[derive(Default)] struct FixtureSecrets { values: BTreeMap>, requests: Vec, accepted: Vec, rejected: Vec, } impl FixtureSecrets { fn all(fixture: &FixtureSet) -> Self { Self { values: fixture .generated .keys .iter() .map(|key| { ( key.primary_fingerprint.clone(), key.passphrase.as_bytes().to_vec(), ) }) .collect(), requests: Vec::new(), accepted: Vec::new(), rejected: Vec::new(), } } fn one(fingerprint: &str, passphrase: impl AsRef<[u8]>) -> Self { Self { values: BTreeMap::from([(fingerprint.to_owned(), passphrase.as_ref().to_vec())]), requests: Vec::new(), accepted: Vec::new(), rejected: Vec::new(), } } } impl SecretProvider for FixtureSecrets { fn secret_for(&mut self, key: &KeyInfo) -> Result { let fingerprint = key.fingerprint().as_str().to_owned(); self.requests.push(fingerprint.clone()); self.values .get(&fingerprint) .cloned() .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; impl SecretProvider for CancelledProvider { fn secret_for(&mut self, _key: &KeyInfo) -> Result { Err(SecretProviderError::Cancelled) } } #[test] fn imports_armored_and_binary_public_and_protected_secret_keys() -> TestResult { let fixture = FixtureSet::load()?; let alice = fixture.key("alice")?; let bob = fixture.key("bob")?; let mut keys = KeyStore::new(); keys.import(&fixture.read(&alice.public_armor)?)?; keys.import(&fixture.read(&alice.secret_binary)?)?; keys.import(&fixture.read(&bob.public_binary)?)?; keys.import(&fixture.read(&bob.secret_armor)?)?; assert_eq!(keys.len(), 2); let infos = keys.infos().collect::>(); assert!(infos.iter().all(KeyInfo::has_secret)); assert!(infos.iter().all(KeyInfo::can_encrypt)); assert!(infos.iter().all(KeyInfo::can_sign)); assert!(format!("{keys:?}").contains("key_count: 2")); assert!(!format!("{keys:?}").contains(&alice.passphrase)); let loaded = KeyStore::load(fixture.path("keys"))?; assert_eq!(loaded.len(), 2, "duplicate armor/binary exports are merged"); Ok(()) } #[test] fn resolves_pass_identities_and_recipient_file_rules() -> TestResult { let fixture = FixtureSet::load()?; let keys = KeyStore::load(fixture.path("keys"))?; let alice = fixture.key("alice")?; let info = keys .infos() .find(|info| info.fingerprint().as_str() == alice.primary_fingerprint) .expect("Alice info"); for identity in [ alice.primary_fingerprint.as_str(), &alice.primary_fingerprint[24..], &alice.primary_fingerprint[32..], info.key_id(), alice.user_id.as_str(), alice.encryption_subkey_fingerprint.as_str(), &alice.encryption_subkey_fingerprint[24..], ] { assert_eq!( keys.resolve(identity)?.fingerprint().as_str(), alice.primary_fingerprint ); } let recipients = keys.resolve_recipients( format!( " {} # primary recipient\n\n{} # duplicate\n", alice.primary_fingerprint, alice.user_id ) .as_bytes(), )?; assert_eq!(recipients.len(), 1); assert!(matches!( keys.resolve("missing@example.invalid"), Err(CryptoError::MissingIdentity { .. }) )); assert!(matches!( keys.resolve_recipients(b" # comments only\n"), Err(CryptoError::MissingRecipients) )); assert!(matches!( keys.resolve_recipients(&[0xff]), Err(CryptoError::InvalidRecipientFile) )); Ok(()) } #[test] fn reports_exact_user_id_ambiguity() -> TestResult { let fixture = FixtureSet::load()?; let alice = fixture.key("alice")?; let mut keys = KeyStore::new(); keys.import(&fixture.read(&alice.public_binary)?)?; keys.import(&duplicate_user_id_key(&alice.user_id)?)?; assert!(matches!( keys.resolve(&alice.user_id), Err(CryptoError::AmbiguousIdentity { .. }) )); Ok(()) } #[test] fn decrypts_every_gnupg_audited_pass_fixture() -> TestResult { let fixture = FixtureSet::load()?; let keys = KeyStore::load(fixture.path("keys"))?; let mut provider = FixtureSecrets::all(&fixture); for entry in &fixture.generated.entries { let ciphertext = fixture.read(Path::new("stores").join(&entry.store).join(&entry.path))?; let expected = fixture.read( Path::new("expected") .join(&entry.store) .join(&entry.plaintext), )?; let plaintext = keys.decrypt(&EncryptedEntry::new(ciphertext), &mut provider)?; assert_eq!( plaintext.expose(), expected, "fixture {}/{}", entry.store, entry.path ); } assert!(!provider.requests.is_empty()); 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()?; let keys = KeyStore::load(fixture.path("keys"))?; let alice = fixture.key("alice")?; let bob = fixture.key("bob")?; let recipients = keys.resolve_recipients( format!( "{}\n{}\n", alice.primary_fingerprint, bob.primary_fingerprint ) .as_bytes(), )?; let expected = b"generated-by-ironstorage\nlogin: fixture\n"; let ciphertext = keys.encrypt(SecretBytes::new(expected.to_vec()), &recipients)?; let parsed = Message::from_bytes(Cursor::new(ciphertext.as_bytes()))?; 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)?; assert_eq!(plaintext.expose(), expected); assert_eq!(provider.requests.last(), Some(&key.primary_fingerprint)); } let independent = fixture.parse_secret_key(alice)?; assert_eq!( decrypt_independently(ciphertext.as_bytes(), &independent, &alice.passphrase)?, expected ); assert!(matches!( keys.encrypt(SecretBytes::new(Vec::new()), &[]), Err(CryptoError::MissingRecipients) )); Ok(()) } #[test] fn protected_key_failures_are_typed_and_redacted() -> TestResult { let fixture = FixtureSet::load()?; let alice = fixture.key("alice")?; let entry = fixture .generated .entries .iter() .find(|entry| entry.recipients == [alice.primary_fingerprint.clone()]) .expect("Alice fixture"); let ciphertext = EncryptedEntry::new( fixture.read(Path::new("stores").join(&entry.store).join(&entry.path))?, ); let keys = KeyStore::load(fixture.path(&alice.secret_armor))?; let mut wrong = FixtureSecrets::one(&alice.primary_fingerprint, b"very-secret-wrong-value"); let error = keys.decrypt(&ciphertext, &mut wrong).unwrap_err(); assert_eq!(error, CryptoError::DecryptionFailed); assert!(!format!("{error:?}").contains("very-secret-wrong-value")); assert!(!error.to_string().contains("very-secret-wrong-value")); let public_only = KeyStore::load(fixture.path(&alice.public_armor))?; let mut unavailable = FixtureSecrets::default(); assert!(matches!( public_only.decrypt(&ciphertext, &mut unavailable), Err(CryptoError::MissingSecretKey) )); assert!(unavailable.requests.is_empty()); let mut cancelled = FixtureSecrets::default(); let error = keys.decrypt(&ciphertext, &mut cancelled).unwrap_err(); assert!(matches!( error, CryptoError::SecretProvider { reason: SecretProviderError::Unavailable, .. } )); assert!(matches!( keys.decrypt(&ciphertext, &mut CancelledProvider), Err(CryptoError::SecretProvider { reason: SecretProviderError::Cancelled, .. }) )); Ok(()) } #[test] fn signs_and_verifies_gpg_id_with_an_explicit_trust_set() -> TestResult { let fixture = FixtureSet::load()?; let keys = KeyStore::load(fixture.path("keys"))?; let alice = fixture.key("alice")?; let bob = fixture.key("bob")?; let alice_handle = keys.resolve(&alice.primary_fingerprint)?; let bob_handle = keys.resolve(&bob.primary_fingerprint)?; let recipient_file = fixture.read("stores/basic/.gpg-id")?; let checked_in = DetachedSignatureBytes::new(fixture.read("stores/basic/.gpg-id.sig")?); assert_eq!( keys.verify( &recipient_file, &checked_in, std::slice::from_ref(&alice_handle) )?, alice_handle ); assert_eq!( keys.verify(&recipient_file, &checked_in, &[bob_handle]), Err(CryptoError::InvalidSignature) ); assert_eq!( keys.verify( b"tampered\n", &checked_in, std::slice::from_ref(&alice_handle) ), Err(CryptoError::InvalidSignature) ); assert_eq!( keys.verify( &recipient_file, &DetachedSignatureBytes::new(vec![0xff]), std::slice::from_ref(&alice_handle), ), Err(CryptoError::CorruptSignature) ); let mut provider = FixtureSecrets::one(&alice.primary_fingerprint, &alice.passphrase); let generated = keys.sign(&recipient_file, &alice_handle, &mut provider)?; assert_eq!( keys.verify( &recipient_file, &generated, std::slice::from_ref(&alice_handle) )?, alice_handle ); let mut wrong = FixtureSecrets::one(&alice.primary_fingerprint, b"wrong"); assert_eq!( keys.sign(&recipient_file, &alice_handle, &mut wrong), Err(CryptoError::SigningFailed) ); Ok(()) } #[test] fn rejects_corrupt_keys_messages_and_signatures() -> TestResult { let fixture = FixtureSet::load()?; let keys = KeyStore::load(fixture.path("keys"))?; let mut provider = FixtureSecrets::all(&fixture); let mut empty = KeyStore::new(); assert_eq!( empty.import(b"not an OpenPGP key"), Err(CryptoError::CorruptKeyMaterial) ); assert!(empty.is_empty()); assert!(matches!( keys.decrypt(&EncryptedEntry::new(vec![0xff, 0x00]), &mut provider), Err(CryptoError::CorruptMessage) )); Ok(()) } fn duplicate_user_id_key(user_id: &str) -> TestResult> { let created_at = Timestamp::from_secs(1_704_067_201); let subkey = SubkeyParamsBuilder::default() .key_type(KeyType::ECDH(ECCCurve::Curve25519Legacy)) .can_encrypt(EncryptionCaps::All) .created_at(created_at) .build()?; let mut rng = ChaCha20Rng::from_seed([0x55; 32]); let secret = SecretKeyParamsBuilder::default() .key_type(KeyType::Ed25519Legacy) .can_certify(true) .can_sign(true) .created_at(created_at) .primary_user_id(user_id.to_owned()) .preferred_symmetric_algorithms(smallvec![SymmetricKeyAlgorithm::AES256]) .preferred_hash_algorithms(smallvec![HashAlgorithm::Sha256]) .preferred_compression_algorithms(smallvec![]) .subkey(subkey) .build()? .generate(&mut rng)?; let mut bytes = Vec::new(); secret.to_public_key().to_writer(&mut bytes)?; Ok(bytes) } fn decrypt_independently( ciphertext: &[u8], secret: &pgp::composed::SignedSecretKey, passphrase: &str, ) -> TestResult> { let message = Message::from_bytes(Cursor::new(ciphertext))?; let mut decrypted = message.decrypt(&Password::from(passphrase), secret)?; Ok(decrypted.as_data_vec()?) }