use std::path::Path;
use anyhow::{Context, Result};
use base64::{engine::general_purpose::STANDARD, Engine as _};
use ed25519_dalek::{Signer, SigningKey};
pub fn head_message(instance: &str, seq: i64, hash: &str) -> String {
format!("foreguard-head-v1\n{instance}\n{seq}\n{hash}")
}
pub fn generate_seed() -> Result<[u8; 32]> {
let mut seed = [0u8; 32];
getrandom::getrandom(&mut seed).map_err(|e| anyhow::anyhow!("OS RNG failed: {e}"))?;
Ok(seed)
}
pub fn write_seed(path: &Path, seed: &[u8; 32]) -> Result<()> {
use std::io::Write as _;
let encoded = STANDARD.encode(seed);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.with_context(|| format!("writing key file {}", path.display()))?;
f.write_all(encoded.as_bytes())?;
}
#[cfg(not(unix))]
{
std::fs::write(path, encoded)
.with_context(|| format!("writing key file {}", path.display()))?;
}
Ok(())
}
pub struct KeyPair {
signing: SigningKey,
}
impl KeyPair {
pub fn from_seed(seed: &[u8; 32]) -> Self {
Self {
signing: SigningKey::from_bytes(seed),
}
}
pub fn load(path: &Path) -> Result<Self> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("reading signing key {}", path.display()))?;
let bytes = STANDARD
.decode(raw.trim())
.context("signing key is not valid base64")?;
let seed: [u8; 32] = bytes
.as_slice()
.try_into()
.map_err(|_| anyhow::anyhow!("signing key seed must be exactly 32 bytes"))?;
Ok(Self::from_seed(&seed))
}
pub fn public_base64(&self) -> String {
STANDARD.encode(self.signing.verifying_key().to_bytes())
}
pub fn sign_base64(&self, message: &str) -> String {
STANDARD.encode(self.signing.sign(message.as_bytes()).to_bytes())
}
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
#[test]
fn sign_then_verify_roundtrips() {
let seed = generate_seed().unwrap();
let kp = KeyPair::from_seed(&seed);
let msg = head_message("agent-1", 7, "deadbeef");
let sig_b64 = kp.sign_base64(&msg);
let pk_bytes: [u8; 32] = STANDARD
.decode(kp.public_base64())
.unwrap()
.try_into()
.unwrap();
let vk = VerifyingKey::from_bytes(&pk_bytes).unwrap();
let sig_bytes: [u8; 64] = STANDARD.decode(&sig_b64).unwrap().try_into().unwrap();
let sig = Signature::from_bytes(&sig_bytes);
assert!(vk.verify(msg.as_bytes(), &sig).is_ok());
assert!(vk.verify(b"tampered", &sig).is_err());
}
#[test]
fn load_roundtrips_through_a_file() {
let seed = generate_seed().unwrap();
let path = std::env::temp_dir().join(format!("fg_key_{}.key", std::process::id()));
write_seed(&path, &seed).unwrap();
let a = KeyPair::from_seed(&seed).public_base64();
let b = KeyPair::load(&path).unwrap().public_base64();
assert_eq!(a, b, "a loaded key yields the same public key");
let _ = std::fs::remove_file(&path);
}
}