535 lines
18 KiB
Rust
535 lines
18 KiB
Rust
#![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<String, Vec<u8>>,
|
|
requests: Vec<String>,
|
|
accepted: Vec<String>,
|
|
rejected: Vec<String>,
|
|
}
|
|
|
|
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<SecretBytes, SecretProviderError> {
|
|
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<SecretBytes, SecretProviderError> {
|
|
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::<Vec<_>>();
|
|
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<Vec<u8>> {
|
|
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<Vec<u8>> {
|
|
let message = Message::from_bytes(Cursor::new(ciphertext))?;
|
|
let mut decrypted = message.decrypt(&Password::from(passphrase), secret)?;
|
|
Ok(decrypted.as_data_vec()?)
|
|
}
|