use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use ed25519_dalek::{SecretKey, SigningKey, VerifyingKey};
pub const KEY_FILE_NAME: &str = "peer_key.ed25519";
#[derive(Clone)]
pub struct GatewayPeerKeys {
inner: Arc<GatewayPeerKeysInner>,
}
struct GatewayPeerKeysInner {
signing: SigningKey,
public: VerifyingKey,
}
impl GatewayPeerKeys {
pub fn load_or_create(state_dir: &Path) -> Result<Self, GatewayPeerKeyError> {
let key_path = state_dir.join(KEY_FILE_NAME);
if key_path.exists() {
return Self::load(&key_path);
}
fs::create_dir_all(state_dir).map_err(|source| GatewayPeerKeyError::Io {
path: state_dir.to_path_buf(),
source,
})?;
let keys = Self::ephemeral();
keys.persist_to(&key_path)?;
Ok(keys)
}
pub fn ephemeral() -> Self {
let mut rng = rand_core::OsRng;
let signing = SigningKey::generate(&mut rng);
let public = signing.verifying_key();
Self {
inner: Arc::new(GatewayPeerKeysInner { signing, public }),
}
}
fn load(path: &Path) -> Result<Self, GatewayPeerKeyError> {
let bytes = fs::read(path).map_err(|source| GatewayPeerKeyError::Io {
path: path.to_path_buf(),
source,
})?;
if bytes.len() != 32 {
return Err(GatewayPeerKeyError::InvalidLength {
path: path.to_path_buf(),
actual: bytes.len(),
});
}
let mut secret: SecretKey = [0u8; 32];
secret.copy_from_slice(&bytes);
let signing = SigningKey::from_bytes(&secret);
let public = signing.verifying_key();
Ok(Self {
inner: Arc::new(GatewayPeerKeysInner { signing, public }),
})
}
fn persist_to(&self, path: &Path) -> Result<(), GatewayPeerKeyError> {
let bytes = self.inner.signing.to_bytes();
fs::write(path, bytes).map_err(|source| GatewayPeerKeyError::Io {
path: path.to_path_buf(),
source,
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
}
Ok(())
}
pub fn pubkey_bytes(&self) -> [u8; 32] {
self.inner.public.to_bytes()
}
pub fn verifying_key(&self) -> &VerifyingKey {
&self.inner.public
}
pub fn signing_key(&self) -> &SigningKey {
&self.inner.signing
}
pub fn pubkey_b64(&self) -> String {
BASE64.encode(self.inner.public.to_bytes())
}
}
impl std::fmt::Debug for GatewayPeerKeys {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GatewayPeerKeys")
.field("pubkey_b64", &self.pubkey_b64())
.finish()
}
}
#[derive(Debug)]
pub enum GatewayPeerKeyError {
InvalidLength { path: PathBuf, actual: usize },
Io {
path: PathBuf,
source: std::io::Error,
},
}
impl std::fmt::Display for GatewayPeerKeyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidLength { path, actual } => write!(
f,
"gateway peer key file {} is {actual} bytes (expected 32)",
path.display()
),
Self::Io { path, source } => {
write!(
f,
"gateway peer key io error for {}: {source}",
path.display()
)
}
}
}
}
impl std::error::Error for GatewayPeerKeyError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io { source, .. } => Some(source),
Self::InvalidLength { .. } => None,
}
}
}
pub fn decode_pubkey_b64(text: &str) -> Result<[u8; 32], PubkeyDecodeError> {
let trimmed = text.trim();
let body = trimmed.strip_prefix("ed25519:").unwrap_or(trimmed);
let bytes = BASE64.decode(body).map_err(PubkeyDecodeError::Base64)?;
if bytes.len() != 32 {
return Err(PubkeyDecodeError::WrongLength(bytes.len()));
}
let mut out = [0u8; 32];
out.copy_from_slice(&bytes);
Ok(out)
}
#[derive(Debug)]
pub enum PubkeyDecodeError {
Base64(base64::DecodeError),
WrongLength(usize),
}
impl std::fmt::Display for PubkeyDecodeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Base64(e) => write!(f, "invalid pubkey base64: {e}"),
Self::WrongLength(n) => write!(f, "pubkey must be 32 bytes, got {n}"),
}
}
}
impl std::error::Error for PubkeyDecodeError {}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn ephemeral_minted_keys_are_unique() {
let a = GatewayPeerKeys::ephemeral();
let b = GatewayPeerKeys::ephemeral();
assert_ne!(
a.pubkey_bytes(),
b.pubkey_bytes(),
"fresh ephemeral keys must be distinct"
);
}
#[test]
fn load_or_create_persists_and_round_trips() {
let dir = tempdir().expect("tempdir");
let first = GatewayPeerKeys::load_or_create(dir.path()).expect("create");
let pubkey = first.pubkey_bytes();
let second = GatewayPeerKeys::load_or_create(dir.path()).expect("load");
assert_eq!(second.pubkey_bytes(), pubkey, "key must persist");
assert!(dir.path().join(KEY_FILE_NAME).exists());
}
#[test]
fn load_or_create_rejects_short_file() {
let dir = tempdir().expect("tempdir");
std::fs::write(dir.path().join(KEY_FILE_NAME), b"too short").expect("write");
let err = GatewayPeerKeys::load_or_create(dir.path()).expect_err("short file must fail");
assert!(matches!(err, GatewayPeerKeyError::InvalidLength { .. }));
}
#[test]
fn pubkey_b64_round_trips() {
let keys = GatewayPeerKeys::ephemeral();
let encoded = keys.pubkey_b64();
let decoded = decode_pubkey_b64(&encoded).expect("decode");
assert_eq!(decoded, keys.pubkey_bytes());
}
#[test]
fn decode_strips_ed25519_prefix() {
let keys = GatewayPeerKeys::ephemeral();
let prefixed = format!("ed25519:{}", keys.pubkey_b64());
let decoded = decode_pubkey_b64(&prefixed).expect("decode prefixed");
assert_eq!(decoded, keys.pubkey_bytes());
}
#[test]
fn decode_rejects_wrong_length() {
let err = decode_pubkey_b64("aGVsbG8=").expect_err("must reject 5-byte payload");
assert!(matches!(err, PubkeyDecodeError::WrongLength(_)));
}
}