572 lines
17 KiB
Rust
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(())
|
|
}
|