Files
relicario/crates/relicario-core/src/device.rs
adlee-was-taken 8a72b5e192 feat(core): add device::fingerprint helper for SSH SHA256 fingerprints
Wraps ssh-key's PublicKey::fingerprint(HashAlg::Sha256). Output format
matches ssh-keygen -lf and git verify-commit --raw stderr
(SHA256:<43-char base64>). Used by the upcoming relicario-server
verify-commit rewrite (audit S1).

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-02 16:23:10 -04:00

169 lines
6.2 KiB
Rust

//! Device identity: ed25519 keypairs in OpenSSH format, signing and verification.
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use ssh_key::{LineEnding, PrivateKey, PublicKey};
use zeroize::Zeroizing;
use crate::error::{RelicarioError, Result};
/// A registered device entry in devices.json.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceEntry {
pub name: String,
/// OpenSSH public key format: "ssh-ed25519 AAAA..."
pub public_key: String,
pub added_at: i64,
pub added_by: String,
}
/// A revoked device entry in revoked.json.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RevokedEntry {
pub name: String,
pub public_key: String,
pub revoked_at: i64,
pub revoked_by: String,
}
/// Generate a new ed25519 keypair, returning (private_openssh, public_openssh).
pub fn generate_keypair() -> Result<(Zeroizing<String>, String)> {
use ssh_key::private::{Ed25519Keypair, Ed25519PrivateKey, KeypairData};
use ssh_key::public::Ed25519PublicKey;
let signing_key = SigningKey::generate(&mut rand::rngs::OsRng);
let verifying_key = signing_key.verifying_key();
// Build ssh-key types from raw bytes
let ed_private = Ed25519PrivateKey::from_bytes(signing_key.as_bytes());
let ed_public = Ed25519PublicKey(*verifying_key.as_bytes());
let keypair = Ed25519Keypair { public: ed_public, private: ed_private };
let keypair_data = KeypairData::Ed25519(keypair);
let ssh_private = PrivateKey::new(keypair_data, "")
.map_err(|e| RelicarioError::DeviceKey(format!("private key create: {e}")))?;
let ssh_public = ssh_private.public_key();
let private_pem = ssh_private
.to_openssh(LineEnding::LF)
.map_err(|e| RelicarioError::DeviceKey(format!("private key encode: {e}")))?;
let public_line = ssh_public
.to_openssh()
.map_err(|e| RelicarioError::DeviceKey(format!("public key encode: {e}")))?;
Ok((Zeroizing::new(private_pem.to_string()), public_line))
}
/// Sign data with an OpenSSH private key, returning base64 signature.
pub fn sign(private_key_openssh: &str, data: &[u8]) -> Result<String> {
use base64::Engine;
let private = PrivateKey::from_openssh(private_key_openssh)
.map_err(|e| RelicarioError::DeviceKey(format!("parse private key: {e}")))?;
let key_data = private
.key_data()
.ed25519()
.ok_or_else(|| RelicarioError::DeviceKey("not an ed25519 key".into()))?;
let secret_slice: &[u8] = key_data.private.as_ref();
let secret_bytes: [u8; 32] = secret_slice
.try_into()
.map_err(|_| RelicarioError::DeviceKey("invalid key length".into()))?;
let signing_key = SigningKey::from_bytes(&secret_bytes);
let signature = signing_key.sign(data);
Ok(base64::engine::general_purpose::STANDARD.encode(signature.to_bytes()))
}
/// Verify a signature against an OpenSSH public key.
pub fn verify(public_key_openssh: &str, data: &[u8], signature_b64: &str) -> Result<bool> {
use base64::Engine;
let public = PublicKey::from_openssh(public_key_openssh)
.map_err(|e| RelicarioError::DeviceKey(format!("parse public key: {e}")))?;
let key_data = public
.key_data()
.ed25519()
.ok_or_else(|| RelicarioError::DeviceKey("not an ed25519 key".into()))?;
let pub_slice: &[u8] = key_data.as_ref();
let pub_bytes: [u8; 32] = pub_slice
.try_into()
.map_err(|_| RelicarioError::DeviceKey("invalid key length".into()))?;
let verifying_key = VerifyingKey::from_bytes(&pub_bytes)
.map_err(|e| RelicarioError::DeviceKey(format!("invalid public key: {e}")))?;
let sig_bytes = base64::engine::general_purpose::STANDARD
.decode(signature_b64)
.map_err(|e| RelicarioError::DeviceKey(format!("decode signature: {e}")))?;
let signature = Signature::from_slice(&sig_bytes)
.map_err(|e| RelicarioError::DeviceKey(format!("parse signature: {e}")))?;
Ok(verifying_key.verify(data, &signature).is_ok())
}
/// Compute the OpenSSH SHA-256 fingerprint of a public key.
/// Output format matches `ssh-keygen -lf` and `git verify-commit --raw`:
/// `SHA256:<43-char base64 without padding>`.
pub fn fingerprint(public_key_openssh: &str) -> Result<String> {
use ssh_key::HashAlg;
let public = PublicKey::from_openssh(public_key_openssh)
.map_err(|e| RelicarioError::DeviceKey(format!("parse public key: {e}")))?;
Ok(public.fingerprint(HashAlg::Sha256).to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generate_and_sign_verify_roundtrip() {
let (private, public) = generate_keypair().unwrap();
let data = b"hello world";
let sig = sign(&private, data).unwrap();
assert!(verify(&public, data, &sig).unwrap());
}
#[test]
fn verify_rejects_wrong_data() {
let (private, public) = generate_keypair().unwrap();
let sig = sign(&private, b"hello").unwrap();
assert!(!verify(&public, b"world", &sig).unwrap());
}
#[test]
fn verify_rejects_wrong_key() {
let (private, _) = generate_keypair().unwrap();
let (_, other_public) = generate_keypair().unwrap();
let sig = sign(&private, b"hello").unwrap();
assert!(!verify(&other_public, b"hello", &sig).unwrap());
}
#[test]
fn fingerprint_matches_ssh_keygen_format() {
let (_, public) = generate_keypair().unwrap();
let fp = fingerprint(&public).unwrap();
assert!(fp.starts_with("SHA256:"), "fingerprint should start with SHA256: prefix, got {fp}");
let body = fp.strip_prefix("SHA256:").unwrap();
assert_eq!(body.len(), 43, "SHA-256 fingerprint body is 43 base64 chars (no padding)");
assert!(body.chars().all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/'));
}
#[test]
fn fingerprint_is_deterministic() {
let (_, public) = generate_keypair().unwrap();
assert_eq!(fingerprint(&public).unwrap(), fingerprint(&public).unwrap());
}
#[test]
fn fingerprint_differs_per_key() {
let (_, p1) = generate_keypair().unwrap();
let (_, p2) = generate_keypair().unwrap();
assert_ne!(fingerprint(&p1).unwrap(), fingerprint(&p2).unwrap());
}
}