use std::fs;
use std::io::Write;
use std::path::Path;
use age::secrecy::ExposeSecret;
use age::x25519::{Identity, Recipient};
use super::CryptoError;
#[derive(Clone)]
pub struct KeyPair {
pub public_key: String,
pub private_key: String,
}
impl std::fmt::Debug for KeyPair {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("KeyPair")
.field("public_key", &self.public_key)
.field("private_key", &"[REDACTED]")
.finish()
}
}
impl KeyPair {
pub fn new(public_key: String, private_key: String) -> Self {
Self {
public_key,
private_key,
}
}
pub fn to_recipient(&self) -> Result<Recipient, CryptoError> {
self.public_key
.parse::<Recipient>()
.map_err(|e| CryptoError::InvalidPublicKey(e.to_string()))
}
pub fn to_identity(&self) -> Result<Identity, CryptoError> {
self.private_key
.parse::<Identity>()
.map_err(|e| CryptoError::InvalidPrivateKey(e.to_string()))
}
}
pub fn generate_key_pair() -> KeyPair {
let identity = Identity::generate();
let recipient = identity.to_public();
KeyPair {
public_key: recipient.to_string(),
private_key: identity.to_string().expose_secret().to_string(),
}
}
pub fn save_private_key(path: &Path, private_key: &str) -> Result<(), CryptoError> {
let content = format!(
"# Stand encryption keys - DO NOT COMMIT TO VERSION CONTROL\n\
# Generated by: stand encrypt enable\n\
\n\
STAND_PRIVATE_KEY={}\n",
private_key
);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)?;
file.write_all(content.as_bytes())?;
}
#[cfg(not(unix))]
{
let mut file = fs::File::create(path)?;
file.write_all(content.as_bytes())?;
}
Ok(())
}
pub fn load_private_key(path: &Path) -> Result<String, CryptoError> {
let content = fs::read_to_string(path)?;
for line in content.lines() {
let line = line.trim();
if let Some(key) = line.strip_prefix("STAND_PRIVATE_KEY=") {
if key.trim().is_empty() {
return Err(CryptoError::InvalidPrivateKey(
"Private key value is empty in .stand.keys file".to_string(),
));
}
return Ok(key.to_string());
}
}
Err(CryptoError::NoPrivateKey)
}
pub fn load_private_key_from_env() -> Result<Option<String>, CryptoError> {
match std::env::var("STAND_PRIVATE_KEY") {
Ok(key) if key.trim().is_empty() => Err(CryptoError::InvalidPrivateKey(
"STAND_PRIVATE_KEY environment variable is empty".to_string(),
)),
Ok(key) => Ok(Some(key)),
Err(std::env::VarError::NotPresent) => Ok(None),
Err(std::env::VarError::NotUnicode(_)) => Err(CryptoError::InvalidPrivateKey(
"STAND_PRIVATE_KEY environment variable contains invalid UTF-8".to_string(),
)),
}
}
pub fn parse_public_key(public_key: &str) -> Result<Recipient, CryptoError> {
public_key
.parse::<Recipient>()
.map_err(|e| CryptoError::InvalidPublicKey(e.to_string()))
}
pub fn parse_private_key(private_key: &str) -> Result<Identity, CryptoError> {
private_key
.parse::<Identity>()
.map_err(|e| CryptoError::InvalidPrivateKey(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use tempfile::tempdir;
#[test]
fn test_generate_key_pair() {
let key_pair = generate_key_pair();
assert!(
key_pair.public_key.starts_with("age1"),
"Public key should start with 'age1', got: {}",
key_pair.public_key
);
assert!(
key_pair.private_key.starts_with("AGE-SECRET-KEY-1"),
"Private key should start with 'AGE-SECRET-KEY-1', got: {}",
key_pair.private_key
);
}
#[test]
fn test_key_pair_to_recipient_and_identity() {
let key_pair = generate_key_pair();
assert!(key_pair.to_recipient().is_ok());
assert!(key_pair.to_identity().is_ok());
}
#[test]
fn test_save_and_load_private_key() {
let dir = tempdir().unwrap();
let key_file = dir.path().join(".stand.keys");
let key_pair = generate_key_pair();
save_private_key(&key_file, &key_pair.private_key).unwrap();
let loaded = load_private_key(&key_file).unwrap();
assert_eq!(loaded, key_pair.private_key);
}
#[test]
fn test_load_private_key_missing_file() {
let result = load_private_key(Path::new("/nonexistent/.stand.keys"));
assert!(result.is_err());
}
#[test]
fn test_parse_invalid_public_key() {
let result = parse_public_key("invalid-key");
assert!(result.is_err());
assert!(matches!(result, Err(CryptoError::InvalidPublicKey(_))));
}
#[test]
fn test_parse_invalid_private_key() {
let result = parse_private_key("invalid-key");
assert!(result.is_err());
assert!(matches!(result, Err(CryptoError::InvalidPrivateKey(_))));
}
#[test]
#[serial]
fn test_load_private_key_from_env() {
let key_pair = generate_key_pair();
std::env::set_var("STAND_PRIVATE_KEY", &key_pair.private_key);
let result = load_private_key_from_env();
assert!(result.is_ok());
assert_eq!(result.unwrap(), Some(key_pair.private_key));
std::env::remove_var("STAND_PRIVATE_KEY");
}
#[test]
#[serial]
fn test_load_private_key_from_env_not_set() {
std::env::remove_var("STAND_PRIVATE_KEY");
let result = load_private_key_from_env();
assert!(result.is_ok());
assert!(result.unwrap().is_none());
}
#[test]
fn test_load_private_key_empty_value() {
let dir = tempdir().unwrap();
let key_file = dir.path().join(".stand.keys");
std::fs::write(&key_file, "STAND_PRIVATE_KEY=\n").unwrap();
let result = load_private_key(&key_file);
assert!(result.is_err());
assert!(matches!(result, Err(CryptoError::InvalidPrivateKey(_))));
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("empty"),
"Error should mention 'empty', got: {}",
err_msg
);
}
#[test]
#[serial]
fn test_load_private_key_from_env_empty() {
std::env::set_var("STAND_PRIVATE_KEY", "");
let result = load_private_key_from_env();
assert!(result.is_err());
assert!(matches!(result, Err(CryptoError::InvalidPrivateKey(_))));
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("empty"),
"Error should mention 'empty', got: {}",
err_msg
);
std::env::remove_var("STAND_PRIVATE_KEY");
}
#[test]
fn test_key_pair_debug_masks_private_key() {
let key_pair = KeyPair::new(
"age1public".to_string(),
"AGE-SECRET-KEY-1SECRET".to_string(),
);
let debug_output = format!("{:?}", key_pair);
assert!(
debug_output.contains("age1public"),
"Debug output should contain the public key"
);
assert!(
!debug_output.contains("AGE-SECRET-KEY-1SECRET"),
"Debug output must NOT contain the private key"
);
assert!(
debug_output.contains("[REDACTED]"),
"Debug output should contain [REDACTED] for private key"
);
}
#[test]
#[cfg(unix)]
fn test_save_private_key_sets_secure_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
let key_file = dir.path().join(".stand.keys");
let key_pair = generate_key_pair();
save_private_key(&key_file, &key_pair.private_key).unwrap();
let metadata = std::fs::metadata(&key_file).unwrap();
let mode = metadata.permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "File should have 0600 permissions");
}
}