1148 lines
42 KiB
Rust
1148 lines
42 KiB
Rust
pub mod discovery;
|
|
|
|
use std::fs;
|
|
use std::io::{BufRead, BufReader, Cursor, Read, Write};
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Command, Stdio};
|
|
|
|
use pgp::composed::{
|
|
ArmorOptions, CleartextSignedMessage, Deserializable, DetachedSignature, EncryptionCaps,
|
|
KeyType, Message, MessageBuilder, SecretKeyParamsBuilder, SignedPublicKey, SignedSecretKey,
|
|
SubkeyParamsBuilder,
|
|
};
|
|
use pgp::crypto::{ecc_curve::ECCCurve, hash::HashAlgorithm, sym::SymmetricKeyAlgorithm};
|
|
use pgp::types::{KeyDetails, Password};
|
|
use serde::Serialize as SerdeSerialize;
|
|
|
|
const MAX_DECRYPTED_MESSAGE_BYTES: usize = 64 * 1024 * 1024;
|
|
use smallvec::smallvec;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ImportedKey {
|
|
pub fingerprint: String,
|
|
pub key_id: String,
|
|
pub user_id: String,
|
|
pub email: String,
|
|
pub public_armor: String,
|
|
pub secret_armor: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, SerdeSerialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct DecryptResult {
|
|
pub plaintext: String,
|
|
pub signed: bool,
|
|
pub verified: bool,
|
|
pub signer_fingerprint: Option<String>,
|
|
}
|
|
|
|
pub fn generate_key(name: &str, email: &str, passphrase: &str) -> Result<ImportedKey, String> {
|
|
let user_id = if name.trim().is_empty() {
|
|
email.trim().to_string()
|
|
} else {
|
|
format!("{} <{}>", name.trim(), email.trim())
|
|
};
|
|
|
|
let passphrase = (!passphrase.is_empty()).then(|| passphrase.to_string());
|
|
let subkey = SubkeyParamsBuilder::default()
|
|
.key_type(KeyType::ECDH(ECCCurve::Curve25519Legacy))
|
|
.can_encrypt(EncryptionCaps::All)
|
|
.passphrase(passphrase.clone())
|
|
.build()
|
|
.map_err(|e| format!("Failed to configure PGP encryption subkey: {e}"))?;
|
|
|
|
let params = SecretKeyParamsBuilder::default()
|
|
.key_type(KeyType::Ed25519Legacy)
|
|
.can_certify(true)
|
|
.can_sign(true)
|
|
.primary_user_id(user_id)
|
|
.passphrase(passphrase)
|
|
.preferred_symmetric_algorithms(smallvec![
|
|
SymmetricKeyAlgorithm::AES256,
|
|
SymmetricKeyAlgorithm::AES128,
|
|
])
|
|
.preferred_hash_algorithms(smallvec![HashAlgorithm::Sha256])
|
|
.preferred_compression_algorithms(smallvec![])
|
|
.subkeys(vec![subkey])
|
|
.build()
|
|
.map_err(|e| format!("Failed to configure OpenPGP key: {e}"))?;
|
|
|
|
let mut rng = rand08::thread_rng();
|
|
let secret = params
|
|
.generate(&mut rng)
|
|
.map_err(|e| format!("Failed to generate OpenPGP key: {e}"))?;
|
|
secret
|
|
.verify_bindings()
|
|
.map_err(|e| format!("Generated OpenPGP key failed self-check: {e}"))?;
|
|
material_from_secret(secret)
|
|
}
|
|
|
|
pub fn import_key(data: &[u8]) -> Result<ImportedKey, String> {
|
|
let armored = data.starts_with(b"-----BEGIN PGP");
|
|
if armored {
|
|
let text = std::str::from_utf8(data)
|
|
.map_err(|_| "Armored OpenPGP key is not valid UTF-8".to_string())?;
|
|
if let Ok((secret, _)) = SignedSecretKey::from_string(text) {
|
|
secret
|
|
.verify_bindings()
|
|
.map_err(|e| format!("Invalid OpenPGP secret-key bindings: {e}"))?;
|
|
return material_from_secret(secret);
|
|
}
|
|
let (public, _) = SignedPublicKey::from_string(text)
|
|
.map_err(|e| format!("Input is not a supported OpenPGP public or secret key: {e}"))?;
|
|
public
|
|
.verify_bindings()
|
|
.map_err(|e| format!("Invalid OpenPGP public-key bindings: {e}"))?;
|
|
return material_from_public(public);
|
|
}
|
|
|
|
if let Ok((secret, _)) = SignedSecretKey::from_reader_single(Cursor::new(data)) {
|
|
secret
|
|
.verify_bindings()
|
|
.map_err(|e| format!("Invalid OpenPGP secret-key bindings: {e}"))?;
|
|
return material_from_secret(secret);
|
|
}
|
|
let (public, _) = SignedPublicKey::from_reader_single(Cursor::new(data))
|
|
.map_err(|e| format!("Input is not a supported OpenPGP public or secret key: {e}"))?;
|
|
public
|
|
.verify_bindings()
|
|
.map_err(|e| format!("Invalid OpenPGP public-key bindings: {e}"))?;
|
|
material_from_public(public)
|
|
}
|
|
|
|
fn material_from_secret(secret: SignedSecretKey) -> Result<ImportedKey, String> {
|
|
let public = secret.to_public_key();
|
|
let mut material = material_from_public(public)?;
|
|
material.secret_armor = Some(
|
|
secret
|
|
.to_armored_string(ArmorOptions::default())
|
|
.map_err(|e| format!("Failed to armor secret key: {e}"))?,
|
|
);
|
|
Ok(material)
|
|
}
|
|
|
|
fn material_from_public(public: SignedPublicKey) -> Result<ImportedKey, String> {
|
|
let fingerprint = normalize_fingerprint(&format!("{}", public.primary_key.fingerprint()));
|
|
let key_id = fingerprint
|
|
.chars()
|
|
.rev()
|
|
.take(16)
|
|
.collect::<String>()
|
|
.chars()
|
|
.rev()
|
|
.collect::<String>();
|
|
let user_id = public
|
|
.details
|
|
.users
|
|
.first()
|
|
.map(|user| String::from_utf8_lossy(user.id.id()).trim().to_string())
|
|
.unwrap_or_default();
|
|
let email = extract_email(&user_id);
|
|
let public_armor = public
|
|
.to_armored_string(ArmorOptions::default())
|
|
.map_err(|e| format!("Failed to armor public key: {e}"))?;
|
|
Ok(ImportedKey {
|
|
fingerprint,
|
|
key_id,
|
|
user_id,
|
|
email,
|
|
public_armor,
|
|
secret_armor: None,
|
|
})
|
|
}
|
|
|
|
pub fn sign_detached(data: &[u8], secret_armor: &str, passphrase: &str) -> Result<String, String> {
|
|
let (secret, _) = SignedSecretKey::from_string(secret_armor)
|
|
.map_err(|e| format!("Failed to parse OpenPGP secret key: {e}"))?;
|
|
let password: Password = passphrase.into();
|
|
let signature = DetachedSignature::sign_binary_data(
|
|
rand08::thread_rng(),
|
|
&secret.primary_key,
|
|
&password,
|
|
HashAlgorithm::Sha256,
|
|
data,
|
|
)
|
|
.map_err(|e| format!("OpenPGP signing failed: {e}"))?;
|
|
signature
|
|
.to_armored_string(ArmorOptions::default())
|
|
.map_err(|e| format!("Failed to armor OpenPGP signature: {e}"))
|
|
}
|
|
|
|
pub fn test_secret_key_passphrase(secret_armor: &str, passphrase: &str) -> Result<(), String> {
|
|
sign_detached(
|
|
b"RustBox OpenPGP unlock self-test",
|
|
secret_armor,
|
|
passphrase,
|
|
)
|
|
.map(|_| ())
|
|
}
|
|
|
|
pub fn verify_detached(
|
|
data: &[u8],
|
|
signature_armor: &str,
|
|
public_armor: &str,
|
|
) -> Result<(), String> {
|
|
let (signature, _) = DetachedSignature::from_string(signature_armor)
|
|
.map_err(|e| format!("Failed to parse OpenPGP signature: {e}"))?;
|
|
let (public, _) = SignedPublicKey::from_string(public_armor)
|
|
.map_err(|e| format!("Failed to parse OpenPGP public key: {e}"))?;
|
|
signature
|
|
.verify(&public, data)
|
|
.map_err(|e| format!("OpenPGP signature verification failed: {e}"))
|
|
}
|
|
|
|
pub fn encrypt_bytes(data: &[u8], public_armors: &[String]) -> Result<String, String> {
|
|
if public_armors.is_empty() {
|
|
return Err("At least one OpenPGP recipient key is required".to_string());
|
|
}
|
|
let keys = parse_public_keys(public_armors)?;
|
|
let mut rng = rand08::thread_rng();
|
|
let mut builder = MessageBuilder::from_bytes("message.eml", data.to_vec())
|
|
.seipd_v1(&mut rng, SymmetricKeyAlgorithm::AES256);
|
|
for key in &keys {
|
|
if let Some(subkey) = key.public_subkeys.first() {
|
|
builder
|
|
.encrypt_to_key(&mut rng, subkey)
|
|
.map_err(|e| format!("Failed to add OpenPGP recipient: {e}"))?;
|
|
} else {
|
|
builder
|
|
.encrypt_to_key(&mut rng, &key.primary_key)
|
|
.map_err(|e| format!("Failed to add OpenPGP recipient: {e}"))?;
|
|
}
|
|
}
|
|
builder
|
|
.to_armored_string(&mut rng, ArmorOptions::default())
|
|
.map_err(|e| format!("OpenPGP encryption failed: {e}"))
|
|
}
|
|
|
|
pub fn inline_sign(text: &str, secret_armor: &str, passphrase: &str) -> Result<String, String> {
|
|
let (secret, _) = SignedSecretKey::from_string(secret_armor)
|
|
.map_err(|e| format!("Failed to parse OpenPGP secret key: {e}"))?;
|
|
let password: Password = passphrase.into();
|
|
CleartextSignedMessage::sign(rand08::thread_rng(), text, &secret.primary_key, &password)
|
|
.and_then(|signed| signed.to_armored_string(ArmorOptions::default()))
|
|
.map_err(|e| format!("OpenPGP inline signing failed: {e}"))
|
|
}
|
|
|
|
pub fn inline_encrypt(
|
|
text: &str,
|
|
public_armors: &[String],
|
|
signer: Option<(&str, &str)>,
|
|
) -> Result<String, String> {
|
|
if public_armors.is_empty() {
|
|
return Err("At least one OpenPGP recipient key is required".to_string());
|
|
}
|
|
let keys = parse_public_keys(public_armors)?;
|
|
let signing_secret = if let Some((secret_armor, _)) = signer {
|
|
let (secret, _) = SignedSecretKey::from_string(secret_armor)
|
|
.map_err(|e| format!("Failed to parse OpenPGP secret key: {e}"))?;
|
|
Some(secret)
|
|
} else {
|
|
None
|
|
};
|
|
let mut rng = rand08::thread_rng();
|
|
let mut builder = MessageBuilder::from_bytes("message.txt", text.as_bytes().to_vec())
|
|
.seipd_v1(&mut rng, SymmetricKeyAlgorithm::AES256);
|
|
if let (Some(secret), Some((_, passphrase))) = (signing_secret.as_ref(), signer) {
|
|
let password: Password = passphrase.into();
|
|
builder.sign(&secret.primary_key, password, HashAlgorithm::Sha256);
|
|
}
|
|
for key in &keys {
|
|
if let Some(subkey) = key.public_subkeys.first() {
|
|
builder
|
|
.encrypt_to_key(&mut rng, subkey)
|
|
.map_err(|e| format!("Failed to add OpenPGP recipient: {e}"))?;
|
|
} else {
|
|
builder
|
|
.encrypt_to_key(&mut rng, &key.primary_key)
|
|
.map_err(|e| format!("Failed to add OpenPGP recipient: {e}"))?;
|
|
}
|
|
}
|
|
builder
|
|
.to_armored_string(&mut rng, ArmorOptions::default())
|
|
.map_err(|e| format!("OpenPGP inline encryption failed: {e}"))
|
|
}
|
|
|
|
pub fn decrypt_inline(
|
|
armored: &str,
|
|
secret_armor: &str,
|
|
passphrase: &str,
|
|
public_armors: &[String],
|
|
) -> Result<DecryptResult, String> {
|
|
let (secret, _) = SignedSecretKey::from_string(secret_armor)
|
|
.map_err(|e| format!("Failed to parse OpenPGP secret key: {e}"))?;
|
|
let (message, _) = Message::from_string(armored)
|
|
.map_err(|e| format!("Failed to parse OpenPGP message: {e}"))?;
|
|
let password: Password = passphrase.into();
|
|
let mut decrypted = message
|
|
.decrypt(&password, &secret)
|
|
.map_err(|e| format!("OpenPGP decryption failed: {e}"))?;
|
|
let signed = decrypted.is_signed() || decrypted.is_one_pass_signed();
|
|
let plaintext_bytes = read_message_limited(&mut decrypted)?;
|
|
let plaintext = String::from_utf8(plaintext_bytes)
|
|
.map_err(|_| "Decrypted OpenPGP payload is not valid UTF-8/MIME text".to_string())?;
|
|
|
|
let mut verified = false;
|
|
let mut signer_fingerprint = None;
|
|
if signed {
|
|
for armor in public_armors {
|
|
if let Ok((public, _)) = SignedPublicKey::from_string(armor)
|
|
&& decrypted.verify(&public).is_ok()
|
|
{
|
|
verified = true;
|
|
signer_fingerprint = Some(normalize_fingerprint(&format!(
|
|
"{}",
|
|
public.primary_key.fingerprint()
|
|
)));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(DecryptResult {
|
|
plaintext,
|
|
signed,
|
|
verified,
|
|
signer_fingerprint,
|
|
})
|
|
}
|
|
|
|
pub fn verify_inline(armored: &str, public_armor: &str) -> Result<String, String> {
|
|
let (public, _) = SignedPublicKey::from_string(public_armor)
|
|
.map_err(|e| format!("Failed to parse OpenPGP public key: {e}"))?;
|
|
if armored.contains("-----BEGIN PGP SIGNED MESSAGE-----") {
|
|
let (message, _) = CleartextSignedMessage::from_string(armored)
|
|
.map_err(|e| format!("Failed to parse OpenPGP cleartext signature: {e}"))?;
|
|
message
|
|
.verify(&public)
|
|
.map_err(|e| format!("OpenPGP signature verification failed: {e}"))?;
|
|
return Ok(message.signed_text());
|
|
}
|
|
let (mut message, _) = Message::from_string(armored)
|
|
.map_err(|e| format!("Failed to parse OpenPGP message: {e}"))?;
|
|
let text = String::from_utf8(read_message_limited(&mut message)?)
|
|
.map_err(|_| "OpenPGP signed payload is not valid UTF-8 text".to_string())?;
|
|
message
|
|
.verify(&public)
|
|
.map_err(|e| format!("OpenPGP signature verification failed: {e}"))?;
|
|
Ok(text)
|
|
}
|
|
|
|
fn read_message_limited(message: &mut Message<'_>) -> Result<Vec<u8>, String> {
|
|
let mut output = Vec::new();
|
|
let limit = MAX_DECRYPTED_MESSAGE_BYTES.saturating_add(1) as u64;
|
|
(&mut *message)
|
|
.take(limit)
|
|
.read_to_end(&mut output)
|
|
.map_err(|e| format!("Failed to read OpenPGP payload: {e}"))?;
|
|
if output.len() > MAX_DECRYPTED_MESSAGE_BYTES {
|
|
return Err("OpenPGP payload exceeds the safe processing limit".to_string());
|
|
}
|
|
Ok(output)
|
|
}
|
|
|
|
fn parse_public_keys(armors: &[String]) -> Result<Vec<SignedPublicKey>, String> {
|
|
armors
|
|
.iter()
|
|
.map(|armor| {
|
|
SignedPublicKey::from_string(armor)
|
|
.map(|(key, _)| key)
|
|
.map_err(|e| format!("Failed to parse OpenPGP public key: {e}"))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
pub fn private_key_dir(data_dir: &str, user_hash: &str) -> PathBuf {
|
|
Path::new(data_dir)
|
|
.join(user_hash)
|
|
.join("pgp")
|
|
.join("private")
|
|
}
|
|
|
|
pub fn store_secret_key(
|
|
data_dir: &str,
|
|
user_hash: &str,
|
|
fingerprint: &str,
|
|
armor: &str,
|
|
) -> Result<String, String> {
|
|
let dir = private_key_dir(data_dir, user_hash);
|
|
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create PGP key directory: {e}"))?;
|
|
set_dir_permissions(&dir)?;
|
|
|
|
if fingerprint.is_empty()
|
|
|| !fingerprint
|
|
.chars()
|
|
.all(|c| c.is_ascii_hexdigit() || c.is_ascii_whitespace() || c == ':')
|
|
{
|
|
return Err("Invalid OpenPGP fingerprint".to_string());
|
|
}
|
|
let safe = normalize_fingerprint(fingerprint);
|
|
if safe.is_empty() {
|
|
return Err("Invalid OpenPGP fingerprint".to_string());
|
|
}
|
|
let final_path = dir.join(format!("{safe}.asc"));
|
|
let temp_path = dir.join(format!(".{safe}.{}.part", uuid::Uuid::new_v4()));
|
|
write_private_file(&temp_path, armor.as_bytes())?;
|
|
fs::rename(&temp_path, &final_path).map_err(|e| {
|
|
let _ = fs::remove_file(&temp_path);
|
|
format!("Failed to install OpenPGP private key: {e}")
|
|
})?;
|
|
Ok(final_path.to_string_lossy().into_owned())
|
|
}
|
|
|
|
pub fn read_secret_key(path: &str) -> Result<String, String> {
|
|
fs::read_to_string(path).map_err(|e| format!("Failed to read OpenPGP private key: {e}"))
|
|
}
|
|
|
|
pub fn delete_secret_key(path: &str) -> Result<(), String> {
|
|
match fs::remove_file(path) {
|
|
Ok(()) => Ok(()),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
|
Err(e) => Err(format!("Failed to delete OpenPGP private key: {e}")),
|
|
}
|
|
}
|
|
|
|
pub fn change_secret_key_passphrase(
|
|
data_dir: &str,
|
|
user_hash: &str,
|
|
fingerprint: &str,
|
|
secret_path: &str,
|
|
old_passphrase: &str,
|
|
new_passphrase: &str,
|
|
gpg_binary: &str,
|
|
) -> Result<(), String> {
|
|
if old_passphrase.len() > 1024 || new_passphrase.len() > 1024 {
|
|
return Err("OpenPGP passphrase is too long".to_string());
|
|
}
|
|
let safe = normalize_fingerprint(fingerprint);
|
|
if safe.is_empty() || !safe.chars().all(|c| c.is_ascii_hexdigit()) {
|
|
return Err("Invalid OpenPGP fingerprint".to_string());
|
|
}
|
|
|
|
let current = read_secret_key(secret_path)?;
|
|
test_secret_key_passphrase(¤t, old_passphrase)
|
|
.map_err(|_| "Current OpenPGP passphrase is incorrect".to_string())?;
|
|
|
|
let private_dir = private_key_dir(data_dir, user_hash);
|
|
fs::create_dir_all(&private_dir)
|
|
.map_err(|e| format!("Failed to create OpenPGP key directory: {e}"))?;
|
|
set_dir_permissions(&private_dir)?;
|
|
let work_dir = private_dir.join(format!(".rewrap-{}", uuid::Uuid::new_v4()));
|
|
fs::create_dir(&work_dir)
|
|
.map_err(|e| format!("Failed to create OpenPGP rewrap directory: {e}"))?;
|
|
set_dir_permissions(&work_dir)?;
|
|
|
|
let result = (|| {
|
|
let import = Command::new(gpg_binary)
|
|
.arg("--homedir")
|
|
.arg(&work_dir)
|
|
.args(["--no-options", "--batch", "--yes", "--import"])
|
|
.arg(secret_path)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::piped())
|
|
.output()
|
|
.map_err(|e| format!("OpenPGP passphrase change requires GnuPG ({gpg_binary}): {e}"))?;
|
|
if !import.status.success() {
|
|
return Err("GnuPG could not import the private key for passphrase change".to_string());
|
|
}
|
|
|
|
let mut child = Command::new(gpg_binary)
|
|
.arg("--homedir")
|
|
.arg(&work_dir)
|
|
.args([
|
|
"--no-options",
|
|
"--batch",
|
|
"--yes",
|
|
"--pinentry-mode",
|
|
"loopback",
|
|
"--status-fd",
|
|
"2",
|
|
"--command-fd",
|
|
"0",
|
|
"--passphrase-repeat",
|
|
"0",
|
|
"--passwd",
|
|
])
|
|
.arg(&safe)
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.map_err(|e| format!("Failed to start GnuPG passphrase change: {e}"))?;
|
|
let mut stdin = child
|
|
.stdin
|
|
.take()
|
|
.ok_or_else(|| "Failed to open GnuPG command input".to_string())?;
|
|
let stderr = child
|
|
.stderr
|
|
.take()
|
|
.ok_or_else(|| "Failed to open GnuPG status output".to_string())?;
|
|
let mut hidden_prompts = 0usize;
|
|
let mut bad_passphrase = false;
|
|
for line in BufReader::new(stderr).lines() {
|
|
let line = line.map_err(|e| format!("Failed to read GnuPG status: {e}"))?;
|
|
if line.contains("[GNUPG:] BAD_PASSPHRASE") {
|
|
bad_passphrase = true;
|
|
} else if line.contains("[GNUPG:] GET_HIDDEN") {
|
|
let response = if hidden_prompts == 0 && !old_passphrase.is_empty() {
|
|
old_passphrase
|
|
} else {
|
|
new_passphrase
|
|
};
|
|
stdin
|
|
.write_all(response.as_bytes())
|
|
.map_err(|e| format!("Failed to answer GnuPG passphrase prompt: {e}"))?;
|
|
stdin
|
|
.write_all(b"\n")
|
|
.map_err(|e| format!("Failed to answer GnuPG passphrase prompt: {e}"))?;
|
|
stdin
|
|
.flush()
|
|
.map_err(|e| format!("Failed to flush GnuPG passphrase prompt: {e}"))?;
|
|
hidden_prompts += 1;
|
|
} else if line.contains("[GNUPG:] GET_BOOL") {
|
|
stdin
|
|
.write_all(b"y\n")
|
|
.map_err(|e| format!("Failed to answer GnuPG confirmation: {e}"))?;
|
|
stdin
|
|
.flush()
|
|
.map_err(|e| format!("Failed to flush GnuPG confirmation: {e}"))?;
|
|
} else if line.contains("[GNUPG:] GET_LINE") {
|
|
stdin
|
|
.write_all(b"\n")
|
|
.map_err(|e| format!("Failed to answer GnuPG prompt: {e}"))?;
|
|
stdin
|
|
.flush()
|
|
.map_err(|e| format!("Failed to flush GnuPG prompt: {e}"))?;
|
|
}
|
|
}
|
|
drop(stdin);
|
|
let status = child
|
|
.wait()
|
|
.map_err(|e| format!("Failed to wait for GnuPG passphrase change: {e}"))?;
|
|
if bad_passphrase || !status.success() {
|
|
return Err("GnuPG rejected the OpenPGP passphrase change".to_string());
|
|
}
|
|
|
|
let mut export = Command::new(gpg_binary)
|
|
.arg("--homedir")
|
|
.arg(&work_dir)
|
|
.args([
|
|
"--no-options",
|
|
"--batch",
|
|
"--yes",
|
|
"--pinentry-mode",
|
|
"loopback",
|
|
"--passphrase-fd",
|
|
"0",
|
|
"--armor",
|
|
"--export-secret-keys",
|
|
])
|
|
.arg(&safe)
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::null())
|
|
.spawn()
|
|
.map_err(|e| format!("Failed to start GnuPG private-key export: {e}"))?;
|
|
if let Some(mut input) = export.stdin.take() {
|
|
input
|
|
.write_all(new_passphrase.as_bytes())
|
|
.map_err(|e| format!("Failed to provide GnuPG export passphrase: {e}"))?;
|
|
input
|
|
.write_all(b"\n")
|
|
.map_err(|e| format!("Failed to provide GnuPG export passphrase: {e}"))?;
|
|
}
|
|
let output = export
|
|
.wait_with_output()
|
|
.map_err(|e| format!("Failed to export rewrapped OpenPGP key: {e}"))?;
|
|
if !output.status.success() || output.stdout.is_empty() {
|
|
return Err("GnuPG did not return the rewrapped OpenPGP private key".to_string());
|
|
}
|
|
let armor = String::from_utf8(output.stdout)
|
|
.map_err(|_| "GnuPG returned non-UTF-8 armored private-key data".to_string())?;
|
|
let imported = import_key(armor.as_bytes())?;
|
|
if normalize_fingerprint(&imported.fingerprint) != safe {
|
|
return Err("Rewrapped OpenPGP key fingerprint changed unexpectedly".to_string());
|
|
}
|
|
let secret = imported
|
|
.secret_armor
|
|
.ok_or_else(|| "Rewrapped key contains no private material".to_string())?;
|
|
test_secret_key_passphrase(&secret, new_passphrase).map_err(|_| {
|
|
"Rewrapped OpenPGP key could not be unlocked with the new passphrase".to_string()
|
|
})?;
|
|
store_secret_key(data_dir, user_hash, &safe, &secret)?;
|
|
Ok(())
|
|
})();
|
|
|
|
let _ = fs::remove_dir_all(&work_dir);
|
|
result
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn set_dir_permissions(path: &Path) -> Result<(), String> {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
fs::set_permissions(path, fs::Permissions::from_mode(0o700))
|
|
.map_err(|e| format!("Failed to protect PGP key directory: {e}"))
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn set_dir_permissions(_path: &Path) -> Result<(), String> {
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn write_private_file(path: &Path, data: &[u8]) -> Result<(), String> {
|
|
use std::io::Write;
|
|
use std::os::unix::fs::OpenOptionsExt;
|
|
let mut file = fs::OpenOptions::new()
|
|
.create_new(true)
|
|
.write(true)
|
|
.mode(0o600)
|
|
.open(path)
|
|
.map_err(|e| format!("Failed to create OpenPGP private key: {e}"))?;
|
|
file.write_all(data)
|
|
.map_err(|e| format!("Failed to write OpenPGP private key: {e}"))?;
|
|
file.sync_all()
|
|
.map_err(|e| format!("Failed to sync OpenPGP private key: {e}"))
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn write_private_file(path: &Path, data: &[u8]) -> Result<(), String> {
|
|
fs::write(path, data).map_err(|e| format!("Failed to write OpenPGP private key: {e}"))
|
|
}
|
|
|
|
fn extract_email(user_id: &str) -> String {
|
|
if let Some(start) = user_id.rfind('<')
|
|
&& let Some(end_rel) = user_id[start + 1..].find('>')
|
|
{
|
|
let value = user_id[start + 1..start + 1 + end_rel].trim();
|
|
if value.contains('@') {
|
|
return value.to_ascii_lowercase();
|
|
}
|
|
}
|
|
user_id
|
|
.split_whitespace()
|
|
.map(|part| part.trim_matches(|c: char| matches!(c, '<' | '>' | '(' | ')' | ',' | ';')))
|
|
.find(|part| part.contains('@'))
|
|
.unwrap_or("")
|
|
.to_ascii_lowercase()
|
|
}
|
|
|
|
pub fn normalize_fingerprint(value: &str) -> String {
|
|
value
|
|
.chars()
|
|
.filter(|c| c.is_ascii_hexdigit())
|
|
.flat_map(char::to_uppercase)
|
|
.collect()
|
|
}
|
|
|
|
#[derive(Debug, Clone, SerdeSerialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ProcessedAttachment {
|
|
pub filename: Option<String>,
|
|
pub content_type: String,
|
|
pub size: usize,
|
|
pub data_base64: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, SerdeSerialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ProcessedMessage {
|
|
pub pgp: bool,
|
|
pub encrypted: bool,
|
|
pub decrypted: bool,
|
|
pub signed: bool,
|
|
pub verified: bool,
|
|
pub signer_fingerprint: Option<String>,
|
|
pub signer_email: Option<String>,
|
|
pub signer_trust: Option<String>,
|
|
pub text: Option<String>,
|
|
pub html: Option<String>,
|
|
pub attachments: Vec<ProcessedAttachment>,
|
|
}
|
|
|
|
pub fn looks_like_openpgp(raw: &[u8]) -> bool {
|
|
let lower = String::from_utf8_lossy(raw).to_ascii_lowercase();
|
|
lower.contains("multipart/encrypted") && lower.contains("application/pgp-encrypted")
|
|
|| lower.contains("multipart/signed") && lower.contains("application/pgp-signature")
|
|
|| lower.contains("-----begin pgp message-----")
|
|
|| lower.contains("-----begin pgp signed message-----")
|
|
}
|
|
|
|
pub fn process_message(
|
|
raw: &[u8],
|
|
secret_armors: &[String],
|
|
passphrase: &str,
|
|
public_keys: &[(String, String)],
|
|
) -> Result<ProcessedMessage, String> {
|
|
let (headers, body) = split_headers_body(raw);
|
|
let content_type = header_value(headers, "content-type").unwrap_or_default();
|
|
let content_type_lower = content_type.to_ascii_lowercase();
|
|
|
|
if content_type_lower.starts_with("multipart/encrypted")
|
|
&& content_type_lower.contains("application/pgp-encrypted")
|
|
{
|
|
let boundary = mime_boundary(&content_type)
|
|
.ok_or_else(|| "OpenPGP/MIME encrypted message has no boundary".to_string())?;
|
|
let parts = split_multipart(body, &boundary)?;
|
|
if parts.len() < 2 {
|
|
return Err(
|
|
"OpenPGP/MIME encrypted message is missing its encrypted payload".to_string(),
|
|
);
|
|
}
|
|
let encrypted_bytes = decoded_part_contents(&parts[1]);
|
|
let encrypted_text = String::from_utf8(encrypted_bytes)
|
|
.map_err(|_| "OpenPGP encrypted payload is not valid ASCII armor".to_string())?;
|
|
let public_armors: Vec<String> =
|
|
public_keys.iter().map(|(_, armor)| armor.clone()).collect();
|
|
let mut last_error = "No private OpenPGP key could decrypt this message".to_string();
|
|
for secret in secret_armors {
|
|
match decrypt_inline(&encrypted_text, secret, passphrase, &public_armors) {
|
|
Ok(decrypted) => {
|
|
let nested = decrypted.plaintext.as_bytes();
|
|
let mut processed = if is_multipart_signed(nested) {
|
|
process_multipart_signed(nested, public_keys)?
|
|
} else {
|
|
let mut clear = parse_clear_mime(nested)?;
|
|
clear.signed = decrypted.signed;
|
|
clear.verified = decrypted.verified;
|
|
clear.signer_fingerprint = decrypted.signer_fingerprint;
|
|
clear
|
|
};
|
|
processed.pgp = true;
|
|
processed.encrypted = true;
|
|
processed.decrypted = true;
|
|
return Ok(processed);
|
|
}
|
|
Err(error) => last_error = error,
|
|
}
|
|
}
|
|
return Err(last_error);
|
|
}
|
|
|
|
if is_multipart_signed(raw) {
|
|
return process_multipart_signed(raw, public_keys);
|
|
}
|
|
|
|
let raw_text = String::from_utf8_lossy(raw);
|
|
if let Some(block) = extract_armored_block(&raw_text, "PGP MESSAGE") {
|
|
let public_armors: Vec<String> =
|
|
public_keys.iter().map(|(_, armor)| armor.clone()).collect();
|
|
let mut last_error = "No private OpenPGP key could decrypt this inline message".to_string();
|
|
for secret in secret_armors {
|
|
match decrypt_inline(block, secret, passphrase, &public_armors) {
|
|
Ok(result) => {
|
|
return Ok(ProcessedMessage {
|
|
pgp: true,
|
|
encrypted: true,
|
|
decrypted: true,
|
|
signed: result.signed,
|
|
verified: result.verified,
|
|
signer_fingerprint: result.signer_fingerprint,
|
|
signer_email: None,
|
|
signer_trust: None,
|
|
text: Some(result.plaintext),
|
|
html: None,
|
|
attachments: vec![],
|
|
});
|
|
}
|
|
Err(error) => last_error = error,
|
|
}
|
|
}
|
|
return Err(last_error);
|
|
}
|
|
|
|
if let Some(block) = extract_armored_block(&raw_text, "PGP SIGNED MESSAGE") {
|
|
for (fingerprint, public) in public_keys {
|
|
if let Ok(text) = verify_inline(block, public) {
|
|
return Ok(ProcessedMessage {
|
|
pgp: true,
|
|
encrypted: false,
|
|
decrypted: false,
|
|
signed: true,
|
|
verified: true,
|
|
signer_fingerprint: Some(fingerprint.clone()),
|
|
signer_email: None,
|
|
signer_trust: None,
|
|
text: Some(text),
|
|
html: None,
|
|
attachments: vec![],
|
|
});
|
|
}
|
|
}
|
|
return Ok(ProcessedMessage {
|
|
pgp: true,
|
|
encrypted: false,
|
|
decrypted: false,
|
|
signed: true,
|
|
verified: false,
|
|
signer_fingerprint: None,
|
|
signer_email: None,
|
|
signer_trust: None,
|
|
text: Some(block.to_string()),
|
|
html: None,
|
|
attachments: vec![],
|
|
});
|
|
}
|
|
|
|
Ok(ProcessedMessage {
|
|
pgp: false,
|
|
encrypted: false,
|
|
decrypted: false,
|
|
signed: false,
|
|
verified: false,
|
|
signer_fingerprint: None,
|
|
signer_email: None,
|
|
signer_trust: None,
|
|
text: None,
|
|
html: None,
|
|
attachments: vec![],
|
|
})
|
|
}
|
|
|
|
fn is_multipart_signed(raw: &[u8]) -> bool {
|
|
let (headers, _) = split_headers_body(raw);
|
|
header_value(headers, "content-type")
|
|
.map(|value| {
|
|
let lower = value.to_ascii_lowercase();
|
|
lower.starts_with("multipart/signed") && lower.contains("application/pgp-signature")
|
|
})
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn process_multipart_signed(
|
|
raw: &[u8],
|
|
public_keys: &[(String, String)],
|
|
) -> Result<ProcessedMessage, String> {
|
|
let (headers, body) = split_headers_body(raw);
|
|
let content_type = header_value(headers, "content-type").unwrap_or_default();
|
|
let boundary = mime_boundary(&content_type)
|
|
.ok_or_else(|| "OpenPGP/MIME signed message has no boundary".to_string())?;
|
|
let parts = split_multipart(body, &boundary)?;
|
|
if parts.len() < 2 {
|
|
return Err("OpenPGP/MIME signature part is missing".to_string());
|
|
}
|
|
let signature = String::from_utf8(decoded_part_contents(&parts[1]))
|
|
.map_err(|_| "OpenPGP signature is not valid ASCII armor".to_string())?;
|
|
|
|
let mut verified = false;
|
|
let mut signer = None;
|
|
for (fingerprint, public) in public_keys {
|
|
if verify_detached(&parts[0], &signature, public).is_ok()
|
|
|| verify_detached(&with_final_crlf(&parts[0]), &signature, public).is_ok()
|
|
{
|
|
verified = true;
|
|
signer = Some(fingerprint.clone());
|
|
break;
|
|
}
|
|
}
|
|
let mut clear = parse_clear_mime(&parts[0])?;
|
|
clear.pgp = true;
|
|
clear.signed = true;
|
|
clear.verified = verified;
|
|
clear.signer_fingerprint = signer;
|
|
Ok(clear)
|
|
}
|
|
|
|
fn parse_clear_mime(bytes: &[u8]) -> Result<ProcessedMessage, String> {
|
|
if bytes.len() > MAX_DECRYPTED_MESSAGE_BYTES {
|
|
return Err("Decrypted OpenPGP MIME payload exceeds the safe processing limit".to_string());
|
|
}
|
|
use base64::Engine;
|
|
use mail_parser::MimeHeaders;
|
|
let parsed = mail_parser::MessageParser::default()
|
|
.parse(bytes)
|
|
.ok_or_else(|| "Failed to parse decrypted OpenPGP MIME payload".to_string())?;
|
|
let text = parsed.body_text(0).map(|value| value.to_string());
|
|
let has_html = parsed.parts.iter().any(|part| {
|
|
part.content_type()
|
|
.is_some_and(|ct| ct.ctype() == "text" && ct.subtype() == Some("html"))
|
|
});
|
|
let html = if has_html {
|
|
parsed.body_html(0).map(|value| value.to_string())
|
|
} else {
|
|
None
|
|
};
|
|
let mut attachments = Vec::new();
|
|
for attachment in parsed.attachments() {
|
|
let data = attachment.contents();
|
|
let content_type = attachment.content_type().map_or_else(
|
|
|| "application/octet-stream".to_string(),
|
|
|ct| {
|
|
ct.subtype()
|
|
.map(|sub| format!("{}/{}", ct.ctype(), sub))
|
|
.unwrap_or_else(|| ct.ctype().to_string())
|
|
},
|
|
);
|
|
attachments.push(ProcessedAttachment {
|
|
filename: attachment.attachment_name().map(|value| value.to_string()),
|
|
content_type,
|
|
size: data.len(),
|
|
data_base64: base64::engine::general_purpose::STANDARD.encode(data),
|
|
});
|
|
}
|
|
Ok(ProcessedMessage {
|
|
pgp: true,
|
|
encrypted: false,
|
|
decrypted: false,
|
|
signed: false,
|
|
verified: false,
|
|
signer_fingerprint: None,
|
|
signer_email: None,
|
|
signer_trust: None,
|
|
text,
|
|
html,
|
|
attachments,
|
|
})
|
|
}
|
|
|
|
fn decoded_part_contents(part: &[u8]) -> Vec<u8> {
|
|
if let Some(parsed) = mail_parser::MessageParser::default().parse(part)
|
|
&& let Some(root) = parsed.parts.first()
|
|
{
|
|
return root.contents().to_vec();
|
|
}
|
|
split_headers_body(part).1.to_vec()
|
|
}
|
|
|
|
fn split_headers_body(raw: &[u8]) -> (&[u8], &[u8]) {
|
|
if let Some(pos) = find_bytes(raw, b"\r\n\r\n") {
|
|
return (&raw[..pos], &raw[pos + 4..]);
|
|
}
|
|
if let Some(pos) = find_bytes(raw, b"\n\n") {
|
|
return (&raw[..pos], &raw[pos + 2..]);
|
|
}
|
|
(raw, &[])
|
|
}
|
|
|
|
fn header_value(headers: &[u8], wanted: &str) -> Option<String> {
|
|
let text = String::from_utf8_lossy(headers);
|
|
let mut current_name = String::new();
|
|
let mut current_value = String::new();
|
|
for line in text.lines() {
|
|
let line = line.trim_end_matches('\r');
|
|
if line.starts_with(' ') || line.starts_with('\t') {
|
|
if !current_name.is_empty() {
|
|
current_value.push(' ');
|
|
current_value.push_str(line.trim());
|
|
}
|
|
continue;
|
|
}
|
|
if current_name.eq_ignore_ascii_case(wanted) {
|
|
return Some(current_value.trim().to_string());
|
|
}
|
|
if let Some((name, value)) = line.split_once(':') {
|
|
current_name = name.trim().to_string();
|
|
current_value = value.trim().to_string();
|
|
}
|
|
}
|
|
current_name
|
|
.eq_ignore_ascii_case(wanted)
|
|
.then(|| current_value.trim().to_string())
|
|
}
|
|
|
|
fn mime_boundary(content_type: &str) -> Option<String> {
|
|
for parameter in content_type.split(';').skip(1) {
|
|
let (name, value) = parameter.trim().split_once('=')?;
|
|
if name.trim().eq_ignore_ascii_case("boundary") {
|
|
return Some(value.trim().trim_matches('"').to_string());
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn split_multipart(body: &[u8], boundary: &str) -> Result<Vec<Vec<u8>>, String> {
|
|
if boundary.is_empty() || boundary.len() > 200 {
|
|
return Err("Invalid MIME boundary".to_string());
|
|
}
|
|
let marker = format!("--{boundary}").into_bytes();
|
|
let mut positions = Vec::new();
|
|
let mut cursor = 0usize;
|
|
while cursor < body.len() {
|
|
let Some(rel) = find_bytes(&body[cursor..], &marker) else {
|
|
break;
|
|
};
|
|
let pos = cursor + rel;
|
|
if pos == 0 || body.get(pos.wrapping_sub(1)) == Some(&b'\n') {
|
|
positions.push(pos);
|
|
}
|
|
cursor = pos.saturating_add(marker.len());
|
|
}
|
|
let mut parts = Vec::new();
|
|
for window in positions.windows(2) {
|
|
let start_marker = window[0];
|
|
let next_marker = window[1];
|
|
let after = start_marker + marker.len();
|
|
if body.get(after..after + 2) == Some(b"--") {
|
|
break;
|
|
}
|
|
let mut start = after;
|
|
if body.get(start..start + 2) == Some(b"\r\n") {
|
|
start += 2;
|
|
} else if body.get(start) == Some(&b'\n') {
|
|
start += 1;
|
|
}
|
|
let mut end = next_marker;
|
|
if end >= 2 && &body[end - 2..end] == b"\r\n" {
|
|
end -= 2;
|
|
} else if end >= 1 && body[end - 1] == b'\n' {
|
|
end -= 1;
|
|
}
|
|
if end >= start {
|
|
parts.push(body[start..end].to_vec());
|
|
}
|
|
}
|
|
if parts.is_empty() {
|
|
return Err("Could not split OpenPGP MIME multipart body".to_string());
|
|
}
|
|
Ok(parts)
|
|
}
|
|
|
|
fn with_final_crlf(data: &[u8]) -> Vec<u8> {
|
|
let mut value = data.to_vec();
|
|
if !value.ends_with(b"\r\n") {
|
|
value.extend_from_slice(b"\r\n");
|
|
}
|
|
value
|
|
}
|
|
|
|
fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
|
if needle.is_empty() {
|
|
return Some(0);
|
|
}
|
|
haystack
|
|
.windows(needle.len())
|
|
.position(|window| window == needle)
|
|
}
|
|
|
|
fn extract_armored_block<'a>(text: &'a str, kind: &str) -> Option<&'a str> {
|
|
let begin = format!("-----BEGIN {kind}-----");
|
|
let end = format!(
|
|
"-----END {}-----",
|
|
if kind == "PGP SIGNED MESSAGE" {
|
|
"PGP SIGNATURE"
|
|
} else {
|
|
kind
|
|
}
|
|
);
|
|
let start = text.find(&begin)?;
|
|
let tail = &text[start..];
|
|
let finish = tail.find(&end)? + end.len();
|
|
Some(&tail[..finish])
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn openpgp_roundtrip_sign_encrypt_and_import() {
|
|
let key =
|
|
generate_key("RustBox Test", "pgp-test@example.invalid", "").expect("generate key");
|
|
let secret = key.secret_armor.as_deref().expect("secret armor");
|
|
|
|
let signed = inline_sign("hello rustbox", secret, "").expect("sign");
|
|
let verified = verify_inline(&signed, &key.public_armor).expect("verify");
|
|
assert!(verified.contains("hello rustbox"));
|
|
|
|
let encrypted = inline_encrypt(
|
|
"secret rustbox",
|
|
std::slice::from_ref(&key.public_armor),
|
|
Some((secret, "")),
|
|
)
|
|
.expect("encrypt");
|
|
let decrypted = decrypt_inline(
|
|
&encrypted,
|
|
secret,
|
|
"",
|
|
std::slice::from_ref(&key.public_armor),
|
|
)
|
|
.expect("decrypt");
|
|
assert_eq!(decrypted.plaintext, "secret rustbox");
|
|
assert!(decrypted.signed);
|
|
assert!(decrypted.verified);
|
|
|
|
let imported_public = import_key(key.public_armor.as_bytes()).expect("import public armor");
|
|
assert_eq!(imported_public.fingerprint, key.fingerprint);
|
|
let imported_secret = import_key(secret.as_bytes()).expect("import secret armor");
|
|
assert_eq!(imported_secret.fingerprint, key.fingerprint);
|
|
assert!(imported_secret.secret_armor.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn pgp_mime_signed_and_encrypted_processing() {
|
|
let key =
|
|
generate_key("RustBox Test", "mime-test@example.invalid", "").expect("generate key");
|
|
let secret = key.secret_armor.as_deref().expect("secret armor");
|
|
let clear = b"Content-Type: text/plain; charset=utf-8\r\n\r\nHello MIME";
|
|
let signature = sign_detached(clear, secret, "").expect("sign mime");
|
|
let signed = format!(
|
|
"Content-Type: multipart/signed; protocol=\"application/pgp-signature\"; boundary=\"rb\"\r\n\r\n--rb\r\n{}\r\n--rb\r\nContent-Type: application/pgp-signature\r\n\r\n{}\r\n--rb--\r\n",
|
|
String::from_utf8_lossy(clear),
|
|
signature
|
|
);
|
|
let public = vec![(key.fingerprint.clone(), key.public_armor.clone())];
|
|
let processed =
|
|
process_message(signed.as_bytes(), &[], "", &public).expect("process signed mime");
|
|
assert!(processed.signed);
|
|
assert!(processed.verified);
|
|
|
|
let encrypted =
|
|
encrypt_bytes(clear, std::slice::from_ref(&key.public_armor)).expect("encrypt mime");
|
|
let encrypted_mime = format!(
|
|
"Content-Type: multipart/encrypted; protocol=\"application/pgp-encrypted\"; boundary=\"rbe\"\r\n\r\n--rbe\r\nContent-Type: application/pgp-encrypted\r\n\r\nVersion: 1\r\n--rbe\r\nContent-Type: application/octet-stream\r\n\r\n{encrypted}\r\n--rbe--\r\n"
|
|
);
|
|
let secrets = vec![secret.to_string()];
|
|
let processed = process_message(encrypted_mime.as_bytes(), &secrets, "", &public)
|
|
.expect("process encrypted mime");
|
|
assert!(processed.encrypted);
|
|
assert!(processed.decrypted);
|
|
assert_eq!(processed.text.as_deref(), Some("Hello MIME"));
|
|
}
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn private_key_storage_is_isolated_and_restrictive() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let root = std::env::temp_dir().join(format!("rustbox-pgp-test-{}", uuid::Uuid::new_v4()));
|
|
let root_text = root.to_string_lossy().into_owned();
|
|
let fingerprint = "0123456789ABCDEF0123456789ABCDEF01234567";
|
|
|
|
let first = store_secret_key(&root_text, "user-a", fingerprint, "PRIVATE-A")
|
|
.expect("store first private key");
|
|
let second = store_secret_key(&root_text, "user-b", fingerprint, "PRIVATE-B")
|
|
.expect("store second private key");
|
|
|
|
assert_ne!(
|
|
first, second,
|
|
"different users must never share a private-key path"
|
|
);
|
|
assert!(first.contains("user-a/pgp/private"));
|
|
assert!(second.contains("user-b/pgp/private"));
|
|
assert_eq!(
|
|
fs::read_to_string(&first).expect("read first key"),
|
|
"PRIVATE-A"
|
|
);
|
|
assert_eq!(
|
|
fs::read_to_string(&second).expect("read second key"),
|
|
"PRIVATE-B"
|
|
);
|
|
|
|
let first_mode = fs::metadata(&first)
|
|
.expect("first key metadata")
|
|
.permissions()
|
|
.mode()
|
|
& 0o777;
|
|
let first_dir_mode = fs::metadata(private_key_dir(&root_text, "user-a"))
|
|
.expect("first key directory metadata")
|
|
.permissions()
|
|
.mode()
|
|
& 0o777;
|
|
assert_eq!(first_mode, 0o600);
|
|
assert_eq!(first_dir_mode, 0o700);
|
|
|
|
assert!(store_secret_key(&root_text, "user-a", "../../escape", "bad").is_err());
|
|
fs::remove_dir_all(&root).expect("cleanup PGP test directory");
|
|
}
|
|
}
|