Skip to main content

agentbin_core/
auth.rs

1use base64::{engine::general_purpose::STANDARD, Engine as _};
2use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
3use rand::rngs::OsRng;
4use sha2::{Digest, Sha256};
5
6use crate::error::CoreError;
7
8/// Generate an Ed25519 keypair.
9///
10/// Returns `(base64_private_key, base64_public_key)`.
11pub fn generate_keypair() -> Result<(String, String), CoreError> {
12    let signing_key = SigningKey::generate(&mut OsRng);
13    let verifying_key = signing_key.verifying_key();
14
15    let private_key_b64 = STANDARD.encode(signing_key.to_bytes());
16    let public_key_b64 = STANDARD.encode(verifying_key.to_bytes());
17
18    Ok((private_key_b64, public_key_b64))
19}
20
21/// Construct the canonical signing payload.
22///
23/// Format: `"{METHOD}\n{PATH}\n{TIMESTAMP}\n{BODY_SHA256_HEX}"`
24pub fn construct_signing_payload(method: &str, path: &str, timestamp: i64, body: &[u8]) -> String {
25    let hash = Sha256::digest(body);
26    let body_hash: String = hash.iter().map(|b| format!("{b:02x}")).collect();
27    format!("{method}\n{path}\n{timestamp}\n{body_hash}")
28}
29
30/// Sign a payload with a base64-encoded Ed25519 private key.
31///
32/// Returns a base64-encoded signature.
33pub fn sign_request(private_key_b64: &str, payload: &str) -> Result<String, CoreError> {
34    let key_bytes = STANDARD
35        .decode(private_key_b64)
36        .map_err(|e| CoreError::AuthError(format!("Invalid private key encoding: {e}")))?;
37
38    let key_array: [u8; 32] = key_bytes
39        .try_into()
40        .map_err(|_| CoreError::AuthError("Private key must be 32 bytes".to_string()))?;
41
42    let signing_key = SigningKey::from_bytes(&key_array);
43    let signature = signing_key.sign(payload.as_bytes());
44
45    Ok(STANDARD.encode(signature.to_bytes()))
46}
47
48/// Verify an Ed25519 signature against a payload.
49///
50/// Returns `Ok(())` on success, `Err(CoreError::AuthError)` on failure.
51pub fn verify_signature(
52    public_key_b64: &str,
53    payload: &str,
54    signature_b64: &str,
55) -> Result<(), CoreError> {
56    let key_bytes = STANDARD
57        .decode(public_key_b64)
58        .map_err(|e| CoreError::AuthError(format!("Invalid public key encoding: {e}")))?;
59
60    let key_array: [u8; 32] = key_bytes
61        .try_into()
62        .map_err(|_| CoreError::AuthError("Public key must be 32 bytes".to_string()))?;
63
64    let verifying_key = VerifyingKey::from_bytes(&key_array)
65        .map_err(|e| CoreError::AuthError(format!("Invalid public key: {e}")))?;
66
67    let sig_bytes = STANDARD
68        .decode(signature_b64)
69        .map_err(|e| CoreError::AuthError(format!("Invalid signature encoding: {e}")))?;
70
71    let sig_array: [u8; 64] = sig_bytes
72        .try_into()
73        .map_err(|_| CoreError::AuthError("Signature must be 64 bytes".to_string()))?;
74
75    let signature = Signature::from_bytes(&sig_array);
76
77    verifying_key
78        .verify(payload.as_bytes(), &signature)
79        .map_err(|e| CoreError::AuthError(format!("Signature verification failed: {e}")))
80}
81
82/// Validate that a request timestamp is within ±300 seconds of now.
83///
84/// Returns `Err(CoreError::AuthError)` if the timestamp is outside the window.
85pub fn validate_timestamp(request_timestamp: i64) -> Result<(), CoreError> {
86    let now = chrono::Utc::now().timestamp();
87    // Use i128 arithmetic to avoid overflow when `request_timestamp` is near
88    // i64::MIN — wrapping subtraction in release mode could otherwise produce a
89    // small positive diff, silently bypassing replay detection.
90    let diff = (now as i128 - request_timestamp as i128).unsigned_abs();
91
92    if diff > 300 {
93        return Err(CoreError::AuthError(
94            "Replay detected: timestamp outside acceptable window".to_string(),
95        ));
96    }
97
98    Ok(())
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn test_generate_keypair() {
107        let (private_key, public_key) = generate_keypair().unwrap();
108        let priv_bytes = STANDARD.decode(&private_key).unwrap();
109        let pub_bytes = STANDARD.decode(&public_key).unwrap();
110        assert_eq!(priv_bytes.len(), 32);
111        assert_eq!(pub_bytes.len(), 32);
112    }
113
114    #[test]
115    fn test_sign_and_verify() {
116        let (private_key, public_key) = generate_keypair().unwrap();
117        let payload = construct_signing_payload("POST", "/upload", 1_234_567_890, b"hello world");
118        let signature = sign_request(&private_key, &payload).unwrap();
119        assert!(verify_signature(&public_key, &payload, &signature).is_ok());
120    }
121
122    #[test]
123    fn test_verify_bad_signature() {
124        let (private_key, _) = generate_keypair().unwrap();
125        let (_, other_public_key) = generate_keypair().unwrap();
126        let payload = construct_signing_payload("POST", "/upload", 1_234_567_890, b"hello world");
127        let signature = sign_request(&private_key, &payload).unwrap();
128        assert!(verify_signature(&other_public_key, &payload, &signature).is_err());
129    }
130
131    #[test]
132    fn test_validate_timestamp() {
133        let now = chrono::Utc::now().timestamp();
134        assert!(validate_timestamp(now).is_ok());
135        assert!(validate_timestamp(now - 600).is_err());
136        assert!(validate_timestamp(now + 600).is_err());
137    }
138
139    #[test]
140    fn test_signing_payload_format() {
141        let payload = construct_signing_payload("GET", "/files", 1_234_567_890, b"");
142        let parts: Vec<&str> = payload.splitn(4, '\n').collect();
143        assert_eq!(parts.len(), 4);
144        assert_eq!(parts[0], "GET");
145        assert_eq!(parts[1], "/files");
146        assert_eq!(parts[2], "1234567890");
147        // SHA-256 of empty string
148        assert_eq!(
149            parts[3],
150            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
151        );
152    }
153}