1use crate::error::{Result, SshError};
5use base64::Engine;
6use ed25519_dalek::{Signer, SigningKey};
7use rand::RngCore;
8use ssh_key::PrivateKey;
9use std::time::{SystemTime, UNIX_EPOCH};
10use tonic::metadata::MetadataValue;
11
12pub fn generate_nonce() -> String {
16 let mut bytes = [0u8; 16];
17 rand::thread_rng().fill_bytes(&mut bytes);
18 hex::encode(bytes)
19}
20
21pub fn current_timestamp() -> i64 {
23 SystemTime::now()
24 .duration_since(UNIX_EPOCH)
25 .expect("time went backwards")
26 .as_secs() as i64
27}
28
29pub fn sign_message(private_key: &PrivateKey, message: &str) -> Result<String> {
40 let keypair = private_key.key_data();
41
42 match keypair {
43 ssh_key::private::KeypairData::Ed25519(ed25519_keypair) => {
44 let signing_key = SigningKey::from_bytes(&ed25519_keypair.private.to_bytes());
46 let signature = signing_key.sign(message.as_bytes());
47
48 let algo_name = b"ssh-ed25519";
52 let sig_bytes = signature.to_bytes();
53
54 let mut wire_data = Vec::new();
55 wire_data.extend_from_slice(&(algo_name.len() as u32).to_be_bytes());
57 wire_data.extend_from_slice(algo_name);
58 wire_data.extend_from_slice(&(sig_bytes.len() as u32).to_be_bytes());
60 wire_data.extend_from_slice(&sig_bytes);
61
62 Ok(base64::engine::general_purpose::STANDARD.encode(&wire_data))
63 }
64 _ => Err(SshError::UnsupportedKeyType("non-ed25519".to_string())),
65 }
66}
67
68#[derive(Debug, Clone)]
76pub struct SshAuthCredentials {
77 pub pubkey: String,
79 pub signature: String,
81 pub timestamp: i64,
83 pub nonce: String,
85}
86
87impl SshAuthCredentials {
88 pub fn new(private_key: &PrivateKey) -> Result<Self> {
96 let timestamp = current_timestamp();
97 let nonce = generate_nonce();
98 let message = format!("{}|{}", timestamp, nonce);
99
100 let signature = sign_message(private_key, &message)?;
101 let pubkey = private_key
102 .public_key()
103 .to_openssh()
104 .map_err(SshError::SerializeKey)?;
105
106 Ok(Self {
107 pubkey,
108 signature,
109 timestamp,
110 nonce,
111 })
112 }
113
114 pub fn age_secs(&self) -> i64 {
118 current_timestamp() - self.timestamp
119 }
120
121 pub fn is_stale(&self, ttl_secs: i64) -> bool {
127 self.age_secs() > ttl_secs
128 }
129
130 pub fn apply_to_request<T>(&self, req: &mut tonic::Request<T>) -> Result<()> {
141 let metadata = req.metadata_mut();
142
143 metadata.insert(
144 "x-ssh-pubkey",
145 MetadataValue::try_from(&self.pubkey).map_err(|e| SshError::InvalidMetadata {
146 field: "x-ssh-pubkey".to_string(),
147 message: e.to_string(),
148 })?,
149 );
150 metadata.insert(
151 "x-ssh-signature",
152 MetadataValue::try_from(&self.signature).map_err(|e| SshError::InvalidMetadata {
153 field: "x-ssh-signature".to_string(),
154 message: e.to_string(),
155 })?,
156 );
157 metadata.insert(
158 "x-ssh-timestamp",
159 MetadataValue::try_from(self.timestamp.to_string()).map_err(|e| {
160 SshError::InvalidMetadata {
161 field: "x-ssh-timestamp".to_string(),
162 message: e.to_string(),
163 }
164 })?,
165 );
166 metadata.insert(
167 "x-ssh-nonce",
168 MetadataValue::try_from(&self.nonce).map_err(|e| SshError::InvalidMetadata {
169 field: "x-ssh-nonce".to_string(),
170 message: e.to_string(),
171 })?,
172 );
173
174 Ok(())
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181 use ssh_key::Algorithm;
182
183 fn generate_test_key() -> PrivateKey {
185 PrivateKey::random(&mut rand::thread_rng(), Algorithm::Ed25519)
186 .expect("should generate ed25519 key")
187 }
188
189 #[test]
190 fn test_generate_nonce_uniqueness() {
191 let nonce1 = generate_nonce();
192 let nonce2 = generate_nonce();
193
194 assert_ne!(nonce1, nonce2, "nonces should be unique");
195 }
196
197 #[test]
198 fn test_generate_nonce_format() {
199 let nonce = generate_nonce();
200
201 assert_eq!(nonce.len(), 32, "nonce should be 32 hex chars (16 bytes)");
202 assert!(
203 nonce.chars().all(|c| c.is_ascii_hexdigit()),
204 "nonce should be hex"
205 );
206 }
207
208 #[test]
209 fn test_current_timestamp_reasonable() {
210 let ts = current_timestamp();
211
212 assert!(ts > 1577836800, "timestamp should be after 2020");
214 }
215
216 #[test]
217 fn test_sign_message_deterministic() {
218 let key = generate_test_key();
220 let message = "test message for signing";
221
222 let sig1 = sign_message(&key, message).expect("should sign");
223 let sig2 = sign_message(&key, message).expect("should sign again");
224
225 assert_eq!(sig1, sig2, "ed25519 signing should be deterministic");
226 }
227
228 #[test]
229 fn test_sign_message_different_messages() {
230 let key = generate_test_key();
231
232 let sig1 = sign_message(&key, "message1").expect("should sign");
233 let sig2 = sign_message(&key, "message2").expect("should sign");
234
235 assert_ne!(
236 sig1, sig2,
237 "different messages should have different signatures"
238 );
239 }
240
241 #[test]
242 fn test_sign_message_is_valid_base64() {
243 let key = generate_test_key();
244 let sig = sign_message(&key, "test").expect("should sign");
245
246 base64::engine::general_purpose::STANDARD
247 .decode(&sig)
248 .expect("signature should be valid base64");
249 }
250
251 #[test]
252 fn test_signature_wire_format() {
253 let key = generate_test_key();
257 let sig = sign_message(&key, "test").expect("should sign");
258
259 let decoded = base64::engine::general_purpose::STANDARD
260 .decode(&sig)
261 .expect("should decode");
262
263 assert_eq!(
264 decoded.len(),
265 83,
266 "SSH signature wire format should be 83 bytes"
267 );
268
269 let algo_len = u32::from_be_bytes(decoded[0..4].try_into().unwrap()) as usize;
271 assert_eq!(algo_len, 11, "algo name length should be 11");
272 assert_eq!(
273 &decoded[4..15],
274 b"ssh-ed25519",
275 "algo name should be ssh-ed25519"
276 );
277
278 let sig_len = u32::from_be_bytes(decoded[15..19].try_into().unwrap()) as usize;
280 assert_eq!(sig_len, 64, "ed25519 signature should be 64 bytes");
281 }
282
283 #[test]
284 fn test_signature_verification_with_dalek() {
285 use ed25519_dalek::{Signature, Verifier, VerifyingKey};
287
288 let key = generate_test_key();
289 let message = "verify this message";
290 let sig_base64 = sign_message(&key, message).expect("should sign");
291
292 let wire = base64::engine::general_purpose::STANDARD
294 .decode(&sig_base64)
295 .expect("should decode");
296
297 let sig_bytes: [u8; 64] = wire[19..83].try_into().expect("should be 64 bytes");
299 let signature = Signature::from_bytes(&sig_bytes);
300
301 let pub_key = key.public_key();
303 let pub_key_bytes: [u8; 32] = match pub_key.key_data() {
304 ssh_key::public::KeyData::Ed25519(ed) => *ed.as_ref(),
305 _ => panic!("expected ed25519 key"),
306 };
307 let verifying_key =
308 VerifyingKey::from_bytes(&pub_key_bytes).expect("should create verifying key");
309
310 verifying_key
312 .verify(message.as_bytes(), &signature)
313 .expect("signature should verify");
314 }
315
316 #[test]
317 fn test_ssh_auth_credentials_creates_valid_signature() {
318 let key = generate_test_key();
319 let creds = SshAuthCredentials::new(&key).expect("should create credentials");
320
321 let wire = base64::engine::general_purpose::STANDARD
323 .decode(&creds.signature)
324 .expect("signature should be valid base64");
325 assert_eq!(wire.len(), 83, "signature wire format should be 83 bytes");
326
327 assert!(
329 creds.timestamp > 1577836800,
330 "timestamp should be after 2020"
331 );
332
333 assert_eq!(creds.nonce.len(), 32, "nonce should be 32 hex chars");
335
336 assert!(
338 creds.pubkey.starts_with("ssh-ed25519 "),
339 "pubkey should be openssh format"
340 );
341 }
342
343 #[test]
344 fn test_ssh_auth_credentials_apply_to_request() {
345 let key = generate_test_key();
346 let creds = SshAuthCredentials::new(&key).expect("should create credentials");
347
348 let mut request = tonic::Request::new(());
349 creds
350 .apply_to_request(&mut request)
351 .expect("should apply credentials");
352
353 let metadata = request.metadata();
354 assert!(metadata.contains_key("x-ssh-pubkey"));
355 assert!(metadata.contains_key("x-ssh-signature"));
356 assert!(metadata.contains_key("x-ssh-timestamp"));
357 assert!(metadata.contains_key("x-ssh-nonce"));
358 }
359
360 #[test]
361 fn test_sign_message_unsupported_key_type() {
362 let ecdsa_openssh = "-----BEGIN OPENSSH PRIVATE KEY-----
365b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS
3661zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQTNTn5FgZVuXQGxJe9jOgFhKJ6RCkqw
367WcL9KlOmRJLdA2qFEvXhqmLs+hLJ0xMc3F6zhvUmhGJrmkWjD3w6PQ3MAAAAqDaGExY2hh
368MWAAAAABMlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQQE00p+RYGV
369bl0BsSXvYzoBYSiekQpKsFnC/SpTpkSS3QNqhRL14api7PoSydMTHNxes4b1JoRia5pFow
37098Oj0NzAAAAIEAm8wBYp2hTLdMrxVJwGYC9hWVH1gqO4YDvJ5vGlLkQ/wAAAAOdGVzdEB0
371ZXN0LmNvbQECAwQFBg==
372-----END OPENSSH PRIVATE KEY-----";
373
374 match PrivateKey::from_openssh(ecdsa_openssh) {
377 Ok(ecdsa_key) => {
378 let result = sign_message(&ecdsa_key, "test message");
380 assert!(result.is_err());
381
382 let err = result.unwrap_err();
383 assert!(matches!(err, crate::error::SshError::UnsupportedKeyType(_)));
384 }
385 Err(_) => {
386 }
391 }
392 }
393
394 #[test]
395 fn test_ssh_auth_credentials_age_secs() {
396 let key = generate_test_key();
397 let creds = SshAuthCredentials::new(&key).expect("should create credentials");
398
399 let age = creds.age_secs();
401 assert!(age >= 0, "age should be non-negative");
402 assert!(
403 age < 2,
404 "age should be less than 2 seconds right after creation"
405 );
406 }
407
408 #[test]
409 fn test_ssh_auth_credentials_is_stale() {
410 let key = generate_test_key();
411 let creds = SshAuthCredentials::new(&key).expect("should create credentials");
412
413 assert!(
415 !creds.is_stale(240),
416 "fresh credentials should not be stale"
417 );
418
419 assert!(
421 !creds.is_stale(0),
422 "credentials with age=0 are not stale with TTL=0"
423 );
424
425 assert!(
427 creds.is_stale(-1),
428 "credentials should be stale with -1s TTL"
429 );
430 }
431
432 #[test]
433 fn test_ssh_auth_credentials_metadata_values() {
434 let key = generate_test_key();
435 let creds = SshAuthCredentials::new(&key).expect("should create credentials");
436
437 let mut request = tonic::Request::new(());
438 creds
439 .apply_to_request(&mut request)
440 .expect("should apply credentials");
441
442 let metadata = request.metadata();
443
444 let pubkey_val = metadata.get("x-ssh-pubkey").expect("should have pubkey");
446 assert_eq!(pubkey_val.to_str().unwrap(), creds.pubkey);
447
448 let sig_val = metadata
449 .get("x-ssh-signature")
450 .expect("should have signature");
451 assert_eq!(sig_val.to_str().unwrap(), creds.signature);
452
453 let ts_val = metadata
454 .get("x-ssh-timestamp")
455 .expect("should have timestamp");
456 assert_eq!(ts_val.to_str().unwrap(), creds.timestamp.to_string());
457
458 let nonce_val = metadata.get("x-ssh-nonce").expect("should have nonce");
459 assert_eq!(nonce_val.to_str().unwrap(), creds.nonce);
460 }
461}