From dc683c7e4ce80b00920c86c496fac750fa9956b9 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 2 May 2026 12:13:57 -0400 Subject: [PATCH] feat(core): add device module with ed25519 signing OpenSSH-format keypair generation, signing, and verification. Foundation for device authentication. Co-Authored-By: Claude Opus 4.5 --- crates/relicario-core/Cargo.toml | 1 + crates/relicario-core/src/device.rs | 135 ++++++++++++++++++++++++++++ crates/relicario-core/src/lib.rs | 3 + 3 files changed, 139 insertions(+) create mode 100644 crates/relicario-core/src/device.rs diff --git a/crates/relicario-core/Cargo.toml b/crates/relicario-core/Cargo.toml index 2eb1a32..3e5cc50 100644 --- a/crates/relicario-core/Cargo.toml +++ b/crates/relicario-core/Cargo.toml @@ -15,6 +15,7 @@ sha2 = "0.10" sha1 = "0.10" hmac = "0.12" ed25519-dalek = { version = "2", features = ["rand_core"] } +ssh-key = { version = "0.6", features = ["ed25519", "std"] } image = { version = "0.25", default-features = false, features = ["jpeg"] } # Typed-item additions diff --git a/crates/relicario-core/src/device.rs b/crates/relicario-core/src/device.rs new file mode 100644 index 0000000..4d779fc --- /dev/null +++ b/crates/relicario-core/src/device.rs @@ -0,0 +1,135 @@ +//! 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)> { + 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 { + 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 { + 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()) +} + +#[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()); + } +} diff --git a/crates/relicario-core/src/lib.rs b/crates/relicario-core/src/lib.rs index d4caeae..4ae324e 100644 --- a/crates/relicario-core/src/lib.rs +++ b/crates/relicario-core/src/lib.rs @@ -83,3 +83,6 @@ pub use backup::{pack_backup, unpack_backup, BackupInput, BackupOutput, BackupIt pub mod import_lastpass; pub use import_lastpass::{parse_lastpass_csv, ImportWarning}; + +pub mod device; +pub use device::{DeviceEntry, RevokedEntry, generate_keypair, sign, verify};