Skip to main content

coven_ssh/
credentials.rs

1// ABOUTME: SSH authentication credentials for gRPC requests.
2// ABOUTME: Signs timestamp|nonce messages and applies auth headers to tonic requests.
3
4use 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
12/// Generate a random nonce for authentication.
13///
14/// Returns a 32-character hex string (16 random bytes).
15pub fn generate_nonce() -> String {
16    let mut bytes = [0u8; 16];
17    rand::thread_rng().fill_bytes(&mut bytes);
18    hex::encode(bytes)
19}
20
21/// Get current Unix timestamp in seconds.
22pub fn current_timestamp() -> i64 {
23    SystemTime::now()
24        .duration_since(UNIX_EPOCH)
25        .expect("time went backwards")
26        .as_secs() as i64
27}
28
29/// Sign a message with the private key.
30///
31/// Returns the signature in SSH wire format, base64 encoded:
32/// - Algorithm name as SSH string (4-byte length prefix + "ssh-ed25519")
33/// - Signature blob as SSH string (4-byte length prefix + 64-byte signature)
34///
35/// Only ed25519 keys are supported.
36///
37/// # Errors
38/// Returns `SshError::UnsupportedKeyType` for non-ed25519 keys.
39pub 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            // Convert to ed25519-dalek signing key
45            let signing_key = SigningKey::from_bytes(&ed25519_keypair.private.to_bytes());
46            let signature = signing_key.sign(message.as_bytes());
47
48            // Build SSH signature wire format to match Go's ssh.Signature:
49            // - Format: SSH string (4-byte length prefix + "ssh-ed25519")
50            // - Blob: SSH string (4-byte length prefix + 64-byte signature)
51            let algo_name = b"ssh-ed25519";
52            let sig_bytes = signature.to_bytes();
53
54            let mut wire_data = Vec::new();
55            // Write algorithm name as SSH string
56            wire_data.extend_from_slice(&(algo_name.len() as u32).to_be_bytes());
57            wire_data.extend_from_slice(algo_name);
58            // Write signature blob as SSH string
59            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/// SSH authentication credentials for gRPC metadata.
69///
70/// Contains all the fields needed to authenticate with coven-gateway:
71/// - `pubkey`: OpenSSH format public key
72/// - `signature`: Base64-encoded SSH signature of `timestamp|nonce`
73/// - `timestamp`: Unix timestamp when credentials were created
74/// - `nonce`: Random hex string to prevent replay attacks
75#[derive(Debug, Clone)]
76pub struct SshAuthCredentials {
77    /// OpenSSH format public key string.
78    pub pubkey: String,
79    /// Base64-encoded SSH signature of the message `{timestamp}|{nonce}`.
80    pub signature: String,
81    /// Unix timestamp when these credentials were created.
82    pub timestamp: i64,
83    /// Random nonce to prevent replay attacks.
84    pub nonce: String,
85}
86
87impl SshAuthCredentials {
88    /// Create new authentication credentials by signing `timestamp|nonce`.
89    ///
90    /// Generates a fresh timestamp and nonce, signs the combined message,
91    /// and packages everything needed for gRPC authentication.
92    ///
93    /// # Errors
94    /// Returns an error if signing fails or the public key cannot be serialized.
95    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    /// Get the age of these credentials in seconds.
115    ///
116    /// Returns the number of seconds since these credentials were created.
117    pub fn age_secs(&self) -> i64 {
118        current_timestamp() - self.timestamp
119    }
120
121    /// Check if these credentials are stale and should be refreshed.
122    ///
123    /// Credentials are considered stale if they are older than the given TTL.
124    /// The gateway rejects signatures older than 5 minutes (300 seconds),
125    /// so a typical TTL would be 240 seconds (4 minutes) to refresh early.
126    pub fn is_stale(&self, ttl_secs: i64) -> bool {
127        self.age_secs() > ttl_secs
128    }
129
130    /// Apply credentials to a gRPC request as metadata headers.
131    ///
132    /// Adds the following headers to the request:
133    /// - `x-ssh-pubkey`: The OpenSSH format public key
134    /// - `x-ssh-signature`: The base64-encoded signature
135    /// - `x-ssh-timestamp`: The Unix timestamp as a string
136    /// - `x-ssh-nonce`: The random nonce
137    ///
138    /// # Errors
139    /// Returns an error if any metadata value is invalid.
140    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    /// Generate a fresh ed25519 key for testing.
184    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        // 1577836800 = 2020-01-01 00:00:00 UTC
213        assert!(ts > 1577836800, "timestamp should be after 2020");
214    }
215
216    #[test]
217    fn test_sign_message_deterministic() {
218        // ed25519 signatures are deterministic for the same key and message
219        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        // Signature should have SSH wire format:
254        // 4-byte algo name length + "ssh-ed25519" (11 bytes) + 4-byte sig length + signature (64 bytes)
255        // Total: 4 + 11 + 4 + 64 = 83 bytes
256        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        // Check algorithm name
270        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        // Check signature length
279        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        // Verify the signature using ed25519-dalek directly
286        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        // Decode the wire format
293        let wire = base64::engine::general_purpose::STANDARD
294            .decode(&sig_base64)
295            .expect("should decode");
296
297        // Extract the raw signature bytes (skip 4-byte algo len + 11-byte algo name + 4-byte sig len)
298        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        // Get the public key bytes for verification
302        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        // Verify
311        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        // Verify the signature format
322        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        // Verify the timestamp is reasonable
328        assert!(
329            creds.timestamp > 1577836800,
330            "timestamp should be after 2020"
331        );
332
333        // Verify the nonce format
334        assert_eq!(creds.nonce.len(), 32, "nonce should be 32 hex chars");
335
336        // Verify the pubkey is valid openssh format
337        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        // Use an ECDSA key (P-256) to test the unsupported key type error path
363        // This key was generated for testing purposes
364        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        // Try to parse the ECDSA key - it may or may not be supported by the ssh_key crate
375        // depending on feature flags
376        match PrivateKey::from_openssh(ecdsa_openssh) {
377            Ok(ecdsa_key) => {
378                // If parsing succeeded, try to sign with it
379                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                // If parsing failed (ECDSA not supported by crate features), that's ok
387                // The code path we're trying to test is for when you HAVE a non-ed25519 key
388                // but the signing code only supports ed25519. If the crate can't even parse
389                // ECDSA keys, then the error path is effectively unreachable.
390            }
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        // Age should be very small (less than 1 second) right after creation
400        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        // Fresh credentials should not be stale with a 240s TTL
414        assert!(
415            !creds.is_stale(240),
416            "fresh credentials should not be stale"
417        );
418
419        // Fresh credentials (age=0) are NOT stale with 0s TTL since 0 > 0 is false
420        assert!(
421            !creds.is_stale(0),
422            "credentials with age=0 are not stale with TTL=0"
423        );
424
425        // Fresh credentials should be stale with a negative TTL (always stale)
426        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        // Verify the actual values match the credentials
445        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}