use crate::error::{Result, SshError};
use base64::Engine;
use ed25519_dalek::{Signer, SigningKey};
use rand::RngCore;
use ssh_key::PrivateKey;
use std::time::{SystemTime, UNIX_EPOCH};
use tonic::metadata::MetadataValue;
pub fn generate_nonce() -> String {
let mut bytes = [0u8; 16];
rand::thread_rng().fill_bytes(&mut bytes);
hex::encode(bytes)
}
pub fn current_timestamp() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time went backwards")
.as_secs() as i64
}
pub fn sign_message(private_key: &PrivateKey, message: &str) -> Result<String> {
let keypair = private_key.key_data();
match keypair {
ssh_key::private::KeypairData::Ed25519(ed25519_keypair) => {
let signing_key = SigningKey::from_bytes(&ed25519_keypair.private.to_bytes());
let signature = signing_key.sign(message.as_bytes());
let algo_name = b"ssh-ed25519";
let sig_bytes = signature.to_bytes();
let mut wire_data = Vec::new();
wire_data.extend_from_slice(&(algo_name.len() as u32).to_be_bytes());
wire_data.extend_from_slice(algo_name);
wire_data.extend_from_slice(&(sig_bytes.len() as u32).to_be_bytes());
wire_data.extend_from_slice(&sig_bytes);
Ok(base64::engine::general_purpose::STANDARD.encode(&wire_data))
}
_ => Err(SshError::UnsupportedKeyType("non-ed25519".to_string())),
}
}
#[derive(Debug, Clone)]
pub struct SshAuthCredentials {
pub pubkey: String,
pub signature: String,
pub timestamp: i64,
pub nonce: String,
}
impl SshAuthCredentials {
pub fn new(private_key: &PrivateKey) -> Result<Self> {
let timestamp = current_timestamp();
let nonce = generate_nonce();
let message = format!("{}|{}", timestamp, nonce);
let signature = sign_message(private_key, &message)?;
let pubkey = private_key
.public_key()
.to_openssh()
.map_err(SshError::SerializeKey)?;
Ok(Self {
pubkey,
signature,
timestamp,
nonce,
})
}
pub fn age_secs(&self) -> i64 {
current_timestamp() - self.timestamp
}
pub fn is_stale(&self, ttl_secs: i64) -> bool {
self.age_secs() > ttl_secs
}
pub fn apply_to_request<T>(&self, req: &mut tonic::Request<T>) -> Result<()> {
let metadata = req.metadata_mut();
metadata.insert(
"x-ssh-pubkey",
MetadataValue::try_from(&self.pubkey).map_err(|e| SshError::InvalidMetadata {
field: "x-ssh-pubkey".to_string(),
message: e.to_string(),
})?,
);
metadata.insert(
"x-ssh-signature",
MetadataValue::try_from(&self.signature).map_err(|e| SshError::InvalidMetadata {
field: "x-ssh-signature".to_string(),
message: e.to_string(),
})?,
);
metadata.insert(
"x-ssh-timestamp",
MetadataValue::try_from(self.timestamp.to_string()).map_err(|e| {
SshError::InvalidMetadata {
field: "x-ssh-timestamp".to_string(),
message: e.to_string(),
}
})?,
);
metadata.insert(
"x-ssh-nonce",
MetadataValue::try_from(&self.nonce).map_err(|e| SshError::InvalidMetadata {
field: "x-ssh-nonce".to_string(),
message: e.to_string(),
})?,
);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use ssh_key::Algorithm;
fn generate_test_key() -> PrivateKey {
PrivateKey::random(&mut rand::thread_rng(), Algorithm::Ed25519)
.expect("should generate ed25519 key")
}
#[test]
fn test_generate_nonce_uniqueness() {
let nonce1 = generate_nonce();
let nonce2 = generate_nonce();
assert_ne!(nonce1, nonce2, "nonces should be unique");
}
#[test]
fn test_generate_nonce_format() {
let nonce = generate_nonce();
assert_eq!(nonce.len(), 32, "nonce should be 32 hex chars (16 bytes)");
assert!(
nonce.chars().all(|c| c.is_ascii_hexdigit()),
"nonce should be hex"
);
}
#[test]
fn test_current_timestamp_reasonable() {
let ts = current_timestamp();
assert!(ts > 1577836800, "timestamp should be after 2020");
}
#[test]
fn test_sign_message_deterministic() {
let key = generate_test_key();
let message = "test message for signing";
let sig1 = sign_message(&key, message).expect("should sign");
let sig2 = sign_message(&key, message).expect("should sign again");
assert_eq!(sig1, sig2, "ed25519 signing should be deterministic");
}
#[test]
fn test_sign_message_different_messages() {
let key = generate_test_key();
let sig1 = sign_message(&key, "message1").expect("should sign");
let sig2 = sign_message(&key, "message2").expect("should sign");
assert_ne!(
sig1, sig2,
"different messages should have different signatures"
);
}
#[test]
fn test_sign_message_is_valid_base64() {
let key = generate_test_key();
let sig = sign_message(&key, "test").expect("should sign");
base64::engine::general_purpose::STANDARD
.decode(&sig)
.expect("signature should be valid base64");
}
#[test]
fn test_signature_wire_format() {
let key = generate_test_key();
let sig = sign_message(&key, "test").expect("should sign");
let decoded = base64::engine::general_purpose::STANDARD
.decode(&sig)
.expect("should decode");
assert_eq!(
decoded.len(),
83,
"SSH signature wire format should be 83 bytes"
);
let algo_len = u32::from_be_bytes(decoded[0..4].try_into().unwrap()) as usize;
assert_eq!(algo_len, 11, "algo name length should be 11");
assert_eq!(
&decoded[4..15],
b"ssh-ed25519",
"algo name should be ssh-ed25519"
);
let sig_len = u32::from_be_bytes(decoded[15..19].try_into().unwrap()) as usize;
assert_eq!(sig_len, 64, "ed25519 signature should be 64 bytes");
}
#[test]
fn test_signature_verification_with_dalek() {
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
let key = generate_test_key();
let message = "verify this message";
let sig_base64 = sign_message(&key, message).expect("should sign");
let wire = base64::engine::general_purpose::STANDARD
.decode(&sig_base64)
.expect("should decode");
let sig_bytes: [u8; 64] = wire[19..83].try_into().expect("should be 64 bytes");
let signature = Signature::from_bytes(&sig_bytes);
let pub_key = key.public_key();
let pub_key_bytes: [u8; 32] = match pub_key.key_data() {
ssh_key::public::KeyData::Ed25519(ed) => *ed.as_ref(),
_ => panic!("expected ed25519 key"),
};
let verifying_key =
VerifyingKey::from_bytes(&pub_key_bytes).expect("should create verifying key");
verifying_key
.verify(message.as_bytes(), &signature)
.expect("signature should verify");
}
#[test]
fn test_ssh_auth_credentials_creates_valid_signature() {
let key = generate_test_key();
let creds = SshAuthCredentials::new(&key).expect("should create credentials");
let wire = base64::engine::general_purpose::STANDARD
.decode(&creds.signature)
.expect("signature should be valid base64");
assert_eq!(wire.len(), 83, "signature wire format should be 83 bytes");
assert!(
creds.timestamp > 1577836800,
"timestamp should be after 2020"
);
assert_eq!(creds.nonce.len(), 32, "nonce should be 32 hex chars");
assert!(
creds.pubkey.starts_with("ssh-ed25519 "),
"pubkey should be openssh format"
);
}
#[test]
fn test_ssh_auth_credentials_apply_to_request() {
let key = generate_test_key();
let creds = SshAuthCredentials::new(&key).expect("should create credentials");
let mut request = tonic::Request::new(());
creds
.apply_to_request(&mut request)
.expect("should apply credentials");
let metadata = request.metadata();
assert!(metadata.contains_key("x-ssh-pubkey"));
assert!(metadata.contains_key("x-ssh-signature"));
assert!(metadata.contains_key("x-ssh-timestamp"));
assert!(metadata.contains_key("x-ssh-nonce"));
}
#[test]
fn test_sign_message_unsupported_key_type() {
let ecdsa_openssh = "-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS
1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQTNTn5FgZVuXQGxJe9jOgFhKJ6RCkqw
WcL9KlOmRJLdA2qFEvXhqmLs+hLJ0xMc3F6zhvUmhGJrmkWjD3w6PQ3MAAAAqDaGExY2hh
MWAAAAABMlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQQE00p+RYGV
bl0BsSXvYzoBYSiekQpKsFnC/SpTpkSS3QNqhRL14api7PoSydMTHNxes4b1JoRia5pFow
98Oj0NzAAAAIEAm8wBYp2hTLdMrxVJwGYC9hWVH1gqO4YDvJ5vGlLkQ/wAAAAOdGVzdEB0
ZXN0LmNvbQECAwQFBg==
-----END OPENSSH PRIVATE KEY-----";
match PrivateKey::from_openssh(ecdsa_openssh) {
Ok(ecdsa_key) => {
let result = sign_message(&ecdsa_key, "test message");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, crate::error::SshError::UnsupportedKeyType(_)));
}
Err(_) => {
}
}
}
#[test]
fn test_ssh_auth_credentials_age_secs() {
let key = generate_test_key();
let creds = SshAuthCredentials::new(&key).expect("should create credentials");
let age = creds.age_secs();
assert!(age >= 0, "age should be non-negative");
assert!(
age < 2,
"age should be less than 2 seconds right after creation"
);
}
#[test]
fn test_ssh_auth_credentials_is_stale() {
let key = generate_test_key();
let creds = SshAuthCredentials::new(&key).expect("should create credentials");
assert!(
!creds.is_stale(240),
"fresh credentials should not be stale"
);
assert!(
!creds.is_stale(0),
"credentials with age=0 are not stale with TTL=0"
);
assert!(
creds.is_stale(-1),
"credentials should be stale with -1s TTL"
);
}
#[test]
fn test_ssh_auth_credentials_metadata_values() {
let key = generate_test_key();
let creds = SshAuthCredentials::new(&key).expect("should create credentials");
let mut request = tonic::Request::new(());
creds
.apply_to_request(&mut request)
.expect("should apply credentials");
let metadata = request.metadata();
let pubkey_val = metadata.get("x-ssh-pubkey").expect("should have pubkey");
assert_eq!(pubkey_val.to_str().unwrap(), creds.pubkey);
let sig_val = metadata
.get("x-ssh-signature")
.expect("should have signature");
assert_eq!(sig_val.to_str().unwrap(), creds.signature);
let ts_val = metadata
.get("x-ssh-timestamp")
.expect("should have timestamp");
assert_eq!(ts_val.to_str().unwrap(), creds.timestamp.to_string());
let nonce_val = metadata.get("x-ssh-nonce").expect("should have nonce");
assert_eq!(nonce_val.to_str().unwrap(), creds.nonce);
}
}