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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
135
crates/relicario-core/src/device.rs
Normal file
135
crates/relicario-core/src/device.rs
Normal file
@@ -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>, 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())
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user