#![forbid(unsafe_code)] use std::{ collections::BTreeMap, error::Error, fs, io::Write, path::{Path, PathBuf}, }; use flate2::{Compression, write::ZlibEncoder}; use pgp::{ composed::{ ArmorOptions, Deserializable, DetachedSignature, EncryptionCaps, KeyType, MessageBuilder, SecretKeyParamsBuilder, SignedSecretKey, SubkeyParamsBuilder, SubpacketConfig, }, crypto::{ecc_curve::ECCCurve, hash::HashAlgorithm, sym::SymmetricKeyAlgorithm}, packet::{Subpacket, SubpacketData}, ser::Serialize as _, types::{KeyDetails as _, Password, Timestamp}, }; use rand_chacha::{ChaCha20Rng, rand_core::SeedableRng}; use serde::Serialize; use sha1::{Digest as _, Sha1}; use sha2::Sha256; use smallvec::smallvec; const FIXTURE_EPOCH: u32 = 1_704_067_200; const ALICE_PASSPHRASE: &str = "fixture-alice-passphrase"; const BOB_PASSPHRASE: &str = "fixture-bob-passphrase"; #[derive(Serialize)] struct GeneratedManifest { format: u8, generated_by: &'static str, fixture_seed: &'static str, keys: Vec, entries: Vec, repositories: Vec, } #[derive(Serialize)] struct KeyRecord { name: &'static str, user_id: &'static str, primary_fingerprint: String, encryption_subkey_fingerprint: String, passphrase: &'static str, public_armor: String, public_binary: String, secret_armor: String, secret_binary: String, } #[derive(Serialize)] struct EntryRecord { store: &'static str, path: &'static str, plaintext: &'static str, recipients: Vec, ciphertext_sha256: String, } #[derive(Serialize)] struct RepositoryRecord { name: &'static str, template: String, head: String, commits: Vec, } struct SyntheticKey { name: &'static str, user_id: &'static str, passphrase: &'static str, secret: SignedSecretKey, } impl SyntheticKey { fn primary_fingerprint(&self) -> String { format!("{:X}", self.secret.primary_key.fingerprint()) } fn encryption_fingerprint(&self) -> String { format!("{:X}", self.secret.secret_subkeys[0].fingerprint()) } } fn main() -> Result<(), Box> { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/compatibility"); prepare_output(&root)?; let mut rng = ChaCha20Rng::from_seed([0x49; 32]); let alice = load_or_generate_key( &root, &mut rng, "alice", "Alice Fixture ", ALICE_PASSPHRASE, )?; let bob = load_or_generate_key( &root, &mut rng, "bob", "Bob Fixture ", BOB_PASSPHRASE, )?; let keys = vec![write_key(&root, &alice)?, write_key(&root, &bob)?]; let mut entries = Vec::new(); write_recipient_file(&root, "basic/.gpg-id", &[&alice])?; write_signature(&root, "basic/.gpg-id.sig", &alice, single_recipient(&alice))?; write_recipient_file(&root, "basic/team/.gpg-id", &[&bob])?; write_signature( &root, "basic/team/.gpg-id.sig", &bob, single_recipient(&bob), )?; write_recipient_file(&root, "basic/shared/.gpg-id", &[&alice, &bob])?; entries.push(write_entry( &root, &mut rng, "basic", "email/personal.gpg", "email/personal.txt", b"correct horse fixture\nlogin: alice@example.test\nurl: https://example.test\n", &[&alice], )?); entries.push(write_entry( &root, &mut rng, "basic", "unicode/咖啡.gpg", "unicode/咖啡.txt", "pässwörd-猫\nlogin: 用户@example.test\nnotes: café\n".as_bytes(), &[&alice], )?); entries.push(write_entry( &root, &mut rng, "basic", "otp/totp.gpg", "otp/totp.txt", b"fixture-password\notpauth://totp/IronStorage:alice%40example.test?secret=JBSWY3DPEHPK3PXP&issuer=IronStorage&algorithm=SHA1&digits=6&period=30\n", &[&alice], )?); entries.push(write_entry( &root, &mut rng, "basic", "otp/hotp.gpg", "otp/hotp.txt", b"otpauth://hotp/IronStorage:counter?secret=JBSWY3DPEHPK3PXP&issuer=IronStorage&counter=0&digits=8\n", &[&alice], )?); entries.push(write_entry( &root, &mut rng, "basic", "team/service.gpg", "team/service.txt", b"team-fixture-password\nowner: bob\n", &[&bob], )?); entries.push(write_entry( &root, &mut rng, "basic", "shared/multiple.gpg", "shared/multiple.txt", b"shared-fixture-password\nrecipients: alice,bob\n", &[&alice, &bob], )?); write_recipient_file(&root, "nested/outer/.gpg-id", &[&alice])?; write_recipient_file(&root, "nested/outer/inner/.gpg-id", &[&bob])?; entries.push(write_entry( &root, &mut rng, "nested", "outer/root-entry.gpg", "outer/root-entry.txt", b"outer repository entry\n", &[&alice], )?); entries.push(write_entry( &root, &mut rng, "nested", "outer/inner/nested-entry.gpg", "outer/inner/nested-entry.txt", b"inner repository entry\n", &[&bob], )?); let repositories = vec![ write_basic_repository(&root)?, write_nested_repository(&root, "outer", "nested/outer", "root-entry.gpg")?, write_nested_repository(&root, "inner", "nested/outer/inner", "nested-entry.gpg")?, ]; let manifest = GeneratedManifest { format: 1, generated_by: "cargo run -p ironstorage --example refresh_compatibility_fixtures", fixture_seed: "49 repeated 32 times (hex); checked-in keys are reused on refresh", keys, entries, repositories, }; write( root.join("generated.toml"), toml::to_string_pretty(&manifest)?.as_bytes(), )?; println!("refreshed {}", root.display()); Ok(()) } fn prepare_output(root: &Path) -> Result<(), Box> { fs::create_dir_all(root)?; fs::create_dir_all(root.join("keys"))?; for generated in ["stores", "expected", "repositories"] { let path = root.join(generated); if path.exists() { fs::remove_dir_all(&path)?; } fs::create_dir_all(path)?; } Ok(()) } fn load_or_generate_key( root: &Path, rng: &mut ChaCha20Rng, name: &'static str, user_id: &'static str, passphrase: &'static str, ) -> Result> { let path = root.join(format!("keys/{name}-secret.asc")); if path.is_file() { let (secret, _) = SignedSecretKey::from_armor_single(fs::File::open(path)?)?; secret.verify_bindings()?; return Ok(SyntheticKey { name, user_id, passphrase, secret, }); } generate_key(rng, name, user_id, passphrase) } fn generate_key( rng: &mut ChaCha20Rng, name: &'static str, user_id: &'static str, passphrase: &'static str, ) -> Result> { let created_at = Timestamp::from_secs(FIXTURE_EPOCH); let subkey = SubkeyParamsBuilder::default() .key_type(KeyType::ECDH(ECCCurve::Curve25519Legacy)) .can_encrypt(EncryptionCaps::All) .created_at(created_at) .passphrase(Some(passphrase.to_owned())) .build()?; 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()) .passphrase(Some(passphrase.to_owned())) .preferred_symmetric_algorithms(smallvec![ SymmetricKeyAlgorithm::AES256, SymmetricKeyAlgorithm::AES128, ]) .preferred_hash_algorithms(smallvec![HashAlgorithm::Sha256]) .preferred_compression_algorithms(smallvec![]) .subkey(subkey) .build()? .generate(rng)?; secret.verify_bindings()?; Ok(SyntheticKey { name, user_id, passphrase, secret, }) } fn write_key(root: &Path, key: &SyntheticKey) -> Result> { let public = key.secret.to_public_key(); let public_armor_path = format!("keys/{}-public.asc", key.name); let public_binary_path = format!("keys/{}-public.pgp", key.name); let secret_armor_path = format!("keys/{}-secret.asc", key.name); let secret_binary_path = format!("keys/{}-secret.pgp", key.name); write( root.join(&public_armor_path), public .to_armored_string(ArmorOptions::default())? .as_bytes(), )?; let mut public_binary = Vec::new(); public.to_writer(&mut public_binary)?; write(root.join(&public_binary_path), &public_binary)?; write( root.join(&secret_armor_path), key.secret .to_armored_string(ArmorOptions::default())? .as_bytes(), )?; let mut secret_binary = Vec::new(); key.secret.to_writer(&mut secret_binary)?; write(root.join(&secret_binary_path), &secret_binary)?; Ok(KeyRecord { name: key.name, user_id: key.user_id, primary_fingerprint: key.primary_fingerprint(), encryption_subkey_fingerprint: key.encryption_fingerprint(), passphrase: key.passphrase, public_armor: public_armor_path, public_binary: public_binary_path, secret_armor: secret_armor_path, secret_binary: secret_binary_path, }) } fn recipient_bytes(keys: &[&SyntheticKey]) -> Vec { let mut bytes = keys .iter() .map(|key| key.primary_fingerprint()) .collect::>() .join("\n") .into_bytes(); bytes.push(b'\n'); bytes } fn single_recipient(key: &SyntheticKey) -> Vec { recipient_bytes(&[key]) } fn write_recipient_file( root: &Path, relative: &str, keys: &[&SyntheticKey], ) -> Result<(), Box> { write(root.join("stores").join(relative), &recipient_bytes(keys)) } fn write_signature( root: &Path, relative: &str, key: &SyntheticKey, data: Vec, ) -> Result<(), Box> { let hashed = vec![ Subpacket::regular(SubpacketData::IssuerFingerprint( key.secret.primary_key.fingerprint(), ))?, Subpacket::regular(SubpacketData::SignatureCreationTime(Timestamp::from_secs( FIXTURE_EPOCH, )))?, ]; let unhashed = vec![Subpacket::regular(SubpacketData::IssuerKeyId( key.secret.primary_key.legacy_key_id(), ))?]; let signature = DetachedSignature::sign_binary_data_with_subpackets( ChaCha20Rng::from_seed([0x53; 32]), &key.secret.primary_key, &Password::from(key.passphrase), HashAlgorithm::Sha256, data.as_slice(), SubpacketConfig::UserDefined { hashed, unhashed }, )?; let mut bytes = Vec::new(); signature.to_writer(&mut bytes)?; write(root.join("stores").join(relative), &bytes) } fn write_entry( root: &Path, rng: &mut ChaCha20Rng, store: &'static str, path: &'static str, plaintext_path: &'static str, plaintext: &[u8], recipients: &[&SyntheticKey], ) -> Result> { let mut message = MessageBuilder::from_bytes("", plaintext.to_vec()) .seipd_v1(&mut *rng, SymmetricKeyAlgorithm::AES256); for recipient in recipients { let public = recipient.secret.to_public_key(); message.encrypt_to_key(&mut *rng, &public.public_subkeys[0])?; } let ciphertext = message.to_vec(&mut *rng)?; write(root.join("stores").join(store).join(path), &ciphertext)?; write( root.join("expected").join(store).join(plaintext_path), plaintext, )?; Ok(EntryRecord { store, path, plaintext: plaintext_path, recipients: recipients .iter() .map(|key| key.primary_fingerprint()) .collect(), ciphertext_sha256: hex::encode(Sha256::digest(&ciphertext)), }) } fn write_basic_repository(root: &Path) -> Result> { let repository = root.join("repositories/basic.git"); prepare_git_repository(&repository)?; let recipient = fs::read(root.join("stores/basic/.gpg-id"))?; let encrypted = fs::read(root.join("stores/basic/email/personal.gpg"))?; let recipient_blob = write_git_object(&repository, "blob", &recipient)?; let initial_tree = write_git_tree( &repository, &[("100644", ".gpg-id", recipient_blob.clone())], )?; let initial = write_git_commit( &repository, &initial_tree, None, "Set GPG id to Alice Fixture.", )?; let encrypted_blob = write_git_object(&repository, "blob", &encrypted)?; let email_tree = write_git_tree(&repository, &[("100644", "personal.gpg", encrypted_blob)])?; let final_tree = write_git_tree( &repository, &[ ("100644", ".gpg-id", recipient_blob), ("40000", "email", email_tree), ], )?; let head = write_git_commit( &repository, &final_tree, Some(&initial), "Add given password for email/personal to store.", )?; write_ref(&repository, &head)?; Ok(RepositoryRecord { name: "basic", template: "repositories/basic.git".to_owned(), head: head.clone(), commits: vec![initial, head], }) } fn write_nested_repository( root: &Path, name: &'static str, store: &str, entry: &str, ) -> Result> { let template = format!("repositories/{name}.git"); let repository = root.join(&template); prepare_git_repository(&repository)?; let recipient = fs::read(root.join("stores").join(store).join(".gpg-id"))?; let encrypted = fs::read(root.join("stores").join(store).join(entry))?; let recipient_blob = write_git_object(&repository, "blob", &recipient)?; let entry_blob = write_git_object(&repository, "blob", &encrypted)?; let tree = write_git_tree( &repository, &[ ("100644", ".gpg-id", recipient_blob), ("100644", entry, entry_blob), ], )?; let head = write_git_commit( &repository, &tree, None, &format!("Add current contents of {name} password store."), )?; write_ref(&repository, &head)?; Ok(RepositoryRecord { name, template, head: head.clone(), commits: vec![head], }) } fn prepare_git_repository(repository: &Path) -> Result<(), Box> { fs::create_dir_all(repository.join("objects"))?; fs::create_dir_all(repository.join("refs/heads"))?; write(repository.join("HEAD"), b"ref: refs/heads/main\n")?; write( repository.join("config"), b"[core]\n\trepositoryformatversion = 0\n\tfilemode = true\n\tbare = true\n[pass]\n\tsigncommits = false\n", )?; Ok(()) } fn write_git_tree( repository: &Path, entries: &[(&str, &str, String)], ) -> Result> { let mut sorted = BTreeMap::new(); for (mode, name, id) in entries { sorted.insert(name.as_bytes(), (*mode, id)); } let mut body = Vec::new(); for (name, (mode, id)) in sorted { body.extend_from_slice(mode.as_bytes()); body.push(b' '); body.extend_from_slice(name); body.push(0); body.extend_from_slice(&hex::decode(id)?); } write_git_object(repository, "tree", &body) } fn write_git_commit( repository: &Path, tree: &str, parent: Option<&str>, message: &str, ) -> Result> { let mut body = format!("tree {tree}\n"); if let Some(parent) = parent { body.push_str(&format!("parent {parent}\n")); } body.push_str("author IronStorage Fixture 1704067200 +0000\n"); body.push_str( "committer IronStorage Fixture 1704067200 +0000\n\n", ); body.push_str(message); body.push('\n'); write_git_object(repository, "commit", body.as_bytes()) } fn write_git_object(repository: &Path, kind: &str, body: &[u8]) -> Result> { let mut canonical = format!("{kind} {}\0", body.len()).into_bytes(); canonical.extend_from_slice(body); let id = hex::encode(Sha1::digest(&canonical)); let path = repository.join("objects").join(&id[..2]).join(&id[2..]); let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); encoder.write_all(&canonical)?; write(path, &encoder.finish()?)?; Ok(id) } fn write_ref(repository: &Path, head: &str) -> Result<(), Box> { write( repository.join("refs/heads/main"), format!("{head}\n").as_bytes(), ) } fn write(path: PathBuf, contents: &[u8]) -> Result<(), Box> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } fs::write(path, contents)?; Ok(()) }