Files
IronStorage/crates/storage/examples/refresh_compatibility_fixtures.rs
2026-08-09 20:59:49 +00:00

572 lines
17 KiB
Rust

#![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<KeyRecord>,
entries: Vec<EntryRecord>,
repositories: Vec<RepositoryRecord>,
}
#[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<String>,
ciphertext_sha256: String,
}
#[derive(Serialize)]
struct RepositoryRecord {
name: &'static str,
template: String,
head: String,
commits: Vec<String>,
}
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<dyn Error>> {
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@ironstorage.invalid>",
ALICE_PASSPHRASE,
)?;
let bob = load_or_generate_key(
&root,
&mut rng,
"bob",
"Bob Fixture <bob@ironstorage.invalid>",
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<dyn Error>> {
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<SyntheticKey, Box<dyn Error>> {
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<SyntheticKey, Box<dyn Error>> {
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<KeyRecord, Box<dyn Error>> {
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<u8> {
let mut bytes = keys
.iter()
.map(|key| key.primary_fingerprint())
.collect::<Vec<_>>()
.join("\n")
.into_bytes();
bytes.push(b'\n');
bytes
}
fn single_recipient(key: &SyntheticKey) -> Vec<u8> {
recipient_bytes(&[key])
}
fn write_recipient_file(
root: &Path,
relative: &str,
keys: &[&SyntheticKey],
) -> Result<(), Box<dyn Error>> {
write(root.join("stores").join(relative), &recipient_bytes(keys))
}
fn write_signature(
root: &Path,
relative: &str,
key: &SyntheticKey,
data: Vec<u8>,
) -> Result<(), Box<dyn Error>> {
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<EntryRecord, Box<dyn Error>> {
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<RepositoryRecord, Box<dyn Error>> {
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<RepositoryRecord, Box<dyn Error>> {
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<dyn Error>> {
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<String, Box<dyn Error>> {
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<String, Box<dyn Error>> {
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 <fixture@ironstorage.invalid> 1704067200 +0000\n");
body.push_str(
"committer IronStorage Fixture <fixture@ironstorage.invalid> 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<String, Box<dyn Error>> {
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<dyn Error>> {
write(
repository.join("refs/heads/main"),
format!("{head}\n").as_bytes(),
)
}
fn write(path: PathBuf, contents: &[u8]) -> Result<(), Box<dyn Error>> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, contents)?;
Ok(())
}