use std::fmt;
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use base64::Engine;
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
use ed25519_dalek::Signer;
use rand::RngCore;
use zeroize::Zeroizing;
use crate::error::{TrustError, TrustResult};
const KEY_ENV_VAR: &str = "LYS_IDENTITY_KEY";
pub struct Ed25519Identity {
signing_key: ed25519_dalek::SigningKey,
verifying_key: ed25519_dalek::VerifyingKey,
}
impl fmt::Debug for Ed25519Identity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Ed25519Identity")
.field("signing_key", &"[REDACTED]")
.field("verifying_key", &self.public_key_bytes_hex())
.finish()
}
}
impl Ed25519Identity {
pub fn public_key_bytes(&self) -> [u8; 32] {
self.verifying_key.to_bytes()
}
pub fn sign(&self, message: &[u8]) -> [u8; 64] {
self.signing_key.sign(message).to_bytes()
}
pub fn verify(public_key: &[u8; 32], message: &[u8], signature: &[u8]) -> TrustResult<()> {
let sig_bytes: &[u8; 64] = signature
.try_into()
.map_err(|_err| TrustError::InvalidSignature)?;
let signature = ed25519_dalek::Signature::from_bytes(sig_bytes);
let vk = ed25519_dalek::VerifyingKey::from_bytes(public_key)
.map_err(|_err| TrustError::InvalidSignature)?;
vk.verify_strict(message, &signature)
.map_err(|_err| TrustError::InvalidSignature)
}
pub fn x25519_static_secret(&self) -> x25519_dalek::StaticSecret {
let scalar = Zeroizing::new(self.signing_key.to_scalar_bytes());
x25519_dalek::StaticSecret::from(*scalar)
}
pub fn x25519_public_key(&self) -> [u8; 32] {
x25519_dalek::PublicKey::from(&self.x25519_static_secret()).to_bytes()
}
pub fn load_or_generate(path: &Path) -> TrustResult<Self> {
if path.exists() {
load_existing(path)
} else {
generate_and_persist(path)
}
}
pub fn load(path: &Path) -> TrustResult<Self> {
load_existing(path)
}
pub fn from_env() -> TrustResult<Self> {
let raw = std::env::var(KEY_ENV_VAR).map_err(|_err| TrustError::KeyManagement {
reason: format!("environment variable {KEY_ENV_VAR} not set"),
})?;
let trimmed = raw.trim();
let decoded = Zeroizing::new(
URL_SAFE_NO_PAD
.decode(trimmed.trim_end_matches('='))
.or_else(|_err| STANDARD.decode(trimmed))
.map_err(|_err| TrustError::KeyManagement {
reason: format!("environment variable {KEY_ENV_VAR} contains invalid base64"),
})?,
);
let n = decoded.len();
if n != 32 {
return Err(TrustError::KeyManagement {
reason: format!(
"environment variable {KEY_ENV_VAR} decoded to {n} bytes, expected 32"
),
});
}
let mut seed = Zeroizing::new([0u8; 32]);
seed.copy_from_slice(&decoded);
Ok(Self::from_seed(&seed))
}
fn from_seed(seed: &Zeroizing<[u8; 32]>) -> Self {
let signing_key = ed25519_dalek::SigningKey::from_bytes(seed);
let verifying_key = signing_key.verifying_key();
Self {
signing_key,
verifying_key,
}
}
fn public_key_bytes_hex(&self) -> String {
crate::hex_lower(&self.verifying_key.to_bytes())
}
}
#[cfg(unix)]
fn warn_if_loose_permissions(path: &Path) {
use std::os::unix::fs::PermissionsExt;
let mode = match std::fs::metadata(path) {
Ok(m) => m.permissions().mode(),
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"could not stat identity key file to check permissions"
);
return;
}
};
if mode & 0o077 != 0 {
tracing::warn!(
path = %path.display(),
mode = format!("{:o}", mode & 0o777),
"identity key file has loose permissions; expected 0600"
);
}
}
#[cfg(not(unix))]
fn warn_if_loose_permissions(_path: &Path) {}
fn load_existing(path: &Path) -> TrustResult<Ed25519Identity> {
warn_if_loose_permissions(path);
let bytes = Zeroizing::new(std::fs::read(path).map_err(|e| TrustError::KeyManagement {
reason: format!("failed to read identity key: {e}"),
})?);
if bytes.len() != 32 {
return Err(TrustError::KeyManagement {
reason: format!(
"identity key file has invalid length: expected 32 bytes, got {}",
bytes.len()
),
});
}
let mut seed = Zeroizing::new([0u8; 32]);
seed.copy_from_slice(&bytes);
Ok(Ed25519Identity::from_seed(&seed))
}
static TMP_NAME_COUNTER: AtomicU64 = AtomicU64::new(0);
fn generate_and_persist(path: &Path) -> TrustResult<Ed25519Identity> {
let file_name = path.file_name().ok_or_else(|| TrustError::KeyManagement {
reason: format!(
"identity key path has no filename component: {}",
path.display()
),
})?;
let parent = path.parent().unwrap_or_else(|| Path::new(""));
let mut seed = Zeroizing::new([0u8; 32]);
rand::rng().fill_bytes(&mut *seed);
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).map_err(|e| TrustError::KeyManagement {
reason: format!("failed to write identity key: {e}"),
})?;
}
let unique = TMP_NAME_COUNTER.fetch_add(1, Ordering::Relaxed);
let mut tmp_name = file_name.to_os_string();
tmp_name.push(format!(".{}.{unique}.tmp", std::process::id()));
let tmp_path = if parent.as_os_str().is_empty() {
std::path::PathBuf::from(tmp_name)
} else {
parent.join(tmp_name)
};
if let Err(e) = write_identity_file(&tmp_path, &seed) {
remove_tmp_file(&tmp_path);
return Err(e);
}
match std::fs::hard_link(&tmp_path, path) {
Ok(()) => {
remove_tmp_file(&tmp_path);
tracing::info!(
path = %path.display(),
"generated and persisted identity key"
);
Ok(Ed25519Identity::from_seed(&seed))
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
remove_tmp_file(&tmp_path);
tracing::info!(
path = %path.display(),
"identity key was persisted concurrently; loading the persisted key"
);
load_existing(path)
}
Err(e) => {
remove_tmp_file(&tmp_path);
Err(TrustError::KeyManagement {
reason: format!("failed to write identity key: {e}"),
})
}
}
}
fn remove_tmp_file(tmp_path: &Path) {
if let Err(e) = std::fs::remove_file(tmp_path)
&& e.kind() != std::io::ErrorKind::NotFound
{
tracing::warn!(
path = %tmp_path.display(),
error = %e,
"failed to remove temporary identity key file after write failure"
);
}
}
#[cfg(unix)]
fn write_identity_file(path: &Path, seed: &[u8; 32]) -> TrustResult<()> {
use std::io::Write;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.map_err(|e| TrustError::KeyManagement {
reason: format!("failed to write identity key: {e}"),
})?;
file.write_all(seed)
.map_err(|e| TrustError::KeyManagement {
reason: format!("failed to write identity key: {e}"),
})?;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|e| {
TrustError::KeyManagement {
reason: format!("failed to write identity key: {e}"),
}
})?;
Ok(())
}
#[cfg(not(unix))]
fn write_identity_file(path: &Path, seed: &[u8; 32]) -> TrustResult<()> {
use std::io::Write;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(path)
.map_err(|e| TrustError::KeyManagement {
reason: format!("failed to write identity key: {e}"),
})?;
file.write_all(seed)
.map_err(|e| TrustError::KeyManagement {
reason: format!("failed to write identity key: {e}"),
})?;
Ok(())
}
#[cfg(test)]
#[path = "identity_tests.rs"]
mod tests;