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>
169 lines
6.2 KiB
Rust
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());
|
|
}
|
|
}
|