Skip to main content

openagent_auth_protocol/
handshake.rs

1// Copyright (c) 2026 L1fe Labs, Inc. — Apache-2.0 OR MIT
2//! Core handshake logic for the OpenAgent Core Protocol identity flow.
3//!
4//! Implements the normative rules from the Core Protocol Specification:
5//! nonce shape (Section 7), challenge construction (Sections 4 / 15.2), the
6//! JCS signing payload (Section 5), and proof validation (Section 15.3).
7//! The specification is the authority; this module is its implementation.
8
9use crate::error::AuthProtocolError;
10use crate::message::{IdentityChallenge, IdentityProof, KeyType, CHALLENGE_TYPE};
11
12/// Default session TTL in seconds (5 minutes).
13pub const DEFAULT_SESSION_TTL_SECS: u32 = 300;
14
15/// Default challenge TTL in seconds (Section 7: default 30s, max 300s).
16pub const DEFAULT_CHALLENGE_TTL_SECS: u32 = 60;
17
18/// Maximum challenge TTL in seconds (Section 7).
19pub const MAX_CHALLENGE_TTL_SECS: u32 = 300;
20
21/// Well-known path for discovery.
22pub const WELL_KNOWN_PATH: &str = "/.well-known/openagent";
23
24/// Well-known path for the auth endpoint.
25pub const AUTH_ENDPOINT_PATH: &str = "/.well-known/openagent/auth";
26
27/// Well-known path for the prove endpoint.
28pub const PROVE_ENDPOINT_PATH: &str = "/.well-known/openagent/auth/prove";
29
30/// Generate a nonce per Section 7: 32 CSPRNG bytes as 64 lowercase hex chars.
31pub fn generate_nonce() -> Result<String, AuthProtocolError> {
32    let mut buf = [0u8; 32];
33    rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut buf);
34    Ok(hex::encode(buf))
35}
36
37/// Construct a fresh [`IdentityChallenge`] for `origin`, stamping the current
38/// UTC time in the Section 4 format (`Z` suffix, seconds precision).
39pub fn new_challenge(
40    origin: &str,
41    realm: Option<String>,
42) -> Result<IdentityChallenge, AuthProtocolError> {
43    let nonce = generate_nonce()?;
44    let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
45    Ok(IdentityChallenge::new(
46        nonce,
47        timestamp,
48        origin.to_string(),
49        realm,
50    ))
51}
52
53/// The signing payload for an [`IdentityProof`]: the JCS-canonicalized
54/// (RFC 8785) UTF-8 bytes of the challenge object, with no framing, prefix,
55/// or envelope (Section 5 / Section 15.2).
56pub fn canonical_challenge_bytes(
57    challenge: &IdentityChallenge,
58) -> Result<Vec<u8>, AuthProtocolError> {
59    let value =
60        serde_json::to_value(challenge).map_err(|e| AuthProtocolError::SerializationFailed {
61            message_type: "IdentityChallenge".to_string(),
62            reason: e.to_string(),
63        })?;
64    let canonical =
65        serde_jcs::to_string(&value).map_err(|e| AuthProtocolError::SerializationFailed {
66            message_type: "IdentityChallenge (JCS)".to_string(),
67            reason: e.to_string(),
68        })?;
69    Ok(canonical.into_bytes())
70}
71
72/// Validate an [`IdentityChallenge`] against the Section 4 field constraints.
73///
74/// A server MUST reject a challenge whose `type` differs, and an agent MUST
75/// reject a malformed challenge before signing it: signing binds the agent to
76/// these exact bytes.
77pub fn validate_challenge(challenge: &IdentityChallenge) -> Result<(), AuthProtocolError> {
78    if challenge.message_type != CHALLENGE_TYPE {
79        return Err(AuthProtocolError::InvalidChallenge {
80            reason: format!(
81                "type must be \"{CHALLENGE_TYPE}\", got \"{}\"",
82                challenge.message_type
83            ),
84        });
85    }
86    validate_nonce(&challenge.nonce)?;
87    validate_timestamp(&challenge.timestamp)?;
88    validate_origin(&challenge.origin)?;
89    if let Some(realm) = &challenge.realm {
90        if realm.is_empty() {
91            return Err(AuthProtocolError::InvalidChallenge {
92                reason: "realm must be non-empty when present".to_string(),
93            });
94        }
95    }
96    Ok(())
97}
98
99/// The nonce field: exactly 64 lowercase hex characters (Section 7).
100pub fn validate_nonce(nonce: &str) -> Result<(), AuthProtocolError> {
101    let valid = nonce.len() == 64
102        && nonce
103            .chars()
104            .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase());
105    if !valid {
106        return Err(AuthProtocolError::InvalidChallenge {
107            reason: format!(
108                "nonce must be exactly 64 lowercase hex characters, got {} chars",
109                nonce.len()
110            ),
111        });
112    }
113    Ok(())
114}
115
116/// The timestamp field: ISO 8601 UTC with `Z` suffix and seconds precision
117/// (Section 4.2). Fractional seconds and numeric offsets are rejected.
118pub fn validate_timestamp(timestamp: &str) -> Result<(), AuthProtocolError> {
119    let parsed = chrono::NaiveDateTime::parse_from_str(timestamp, "%Y-%m-%dT%H:%M:%SZ");
120    if parsed.is_err() {
121        return Err(AuthProtocolError::InvalidChallenge {
122            reason: format!(
123                "timestamp must be ISO 8601 UTC with Z suffix and seconds precision, got \"{timestamp}\""
124            ),
125        });
126    }
127    Ok(())
128}
129
130/// The origin field: `scheme://host[:port]` per RFC 6454 (Section 4.2).
131pub fn validate_origin(origin: &str) -> Result<(), AuthProtocolError> {
132    let valid = (origin.starts_with("https://") || origin.starts_with("http://"))
133        && origin.len() > "https://".len()
134        && !origin.ends_with('/')
135        && origin[origin.find("://").map_or(0, |i| i + 3)..]
136            .chars()
137            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '0'..='9'));
138    if !valid {
139        return Err(AuthProtocolError::InvalidChallenge {
140            reason: format!("origin must be of the form scheme://host[:port], got \"{origin}\""),
141        });
142    }
143    Ok(())
144}
145
146/// Validate an [`IdentityProof`] against a challenge (Section 15.3):
147/// the echoed nonce must match, and the key shape must match the declared
148/// scheme. Signature verification itself belongs to the verifier, which knows
149/// the trusted key set.
150pub fn validate_proof_shape(
151    proof: &IdentityProof,
152    challenge: &IdentityChallenge,
153) -> Result<(), AuthProtocolError> {
154    if proof.nonce != challenge.nonce {
155        return Err(AuthProtocolError::NonceMismatch {
156            expected: challenge.nonce.clone(),
157            received: proof.nonce.clone(),
158        });
159    }
160    let key_bytes = decode_base64url(&proof.public_key, "public_key")?;
161    let expected_len = match proof.key_type {
162        KeyType::Ed25519 => 32,
163        KeyType::Secp256k1 => 33,
164    };
165    if key_bytes.len() != expected_len {
166        return Err(AuthProtocolError::InvalidProof {
167            reason: format!(
168                "public_key must decode to {expected_len} bytes for {}, got {}",
169                proof.key_type,
170                key_bytes.len()
171            ),
172        });
173    }
174    let sig_bytes = decode_base64url(&proof.signature, "signature")?;
175    if sig_bytes.len() != 64 {
176        return Err(AuthProtocolError::InvalidProof {
177            reason: format!("signature must decode to 64 bytes, got {}", sig_bytes.len()),
178        });
179    }
180    Ok(())
181}
182
183/// Decode base64url without padding, as Section 15.3 requires for key and
184/// signature fields.
185fn decode_base64url(input: &str, field: &str) -> Result<Vec<u8>, AuthProtocolError> {
186    use base64::Engine;
187    base64::engine::general_purpose::URL_SAFE_NO_PAD
188        .decode(input)
189        .map_err(|e| AuthProtocolError::InvalidProof {
190            reason: format!("{field} is not valid base64url (no padding): {e}"),
191        })
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    fn valid_challenge() -> IdentityChallenge {
199        IdentityChallenge::new(
200            "a3f1c9".to_string() + &"0".repeat(58),
201            "2026-03-21T12:00:00Z".to_string(),
202            "https://api.example.com".to_string(),
203            None,
204        )
205    }
206
207    #[test]
208    fn nonce_is_64_lowercase_hex() {
209        let nonce = generate_nonce().expect("nonce");
210        assert_eq!(nonce.len(), 64);
211        assert!(nonce
212            .chars()
213            .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()));
214    }
215
216    #[test]
217    fn new_challenge_uses_spec_timestamp_format() {
218        let challenge = new_challenge("https://api.example.com", None).expect("challenge");
219        assert!(validate_challenge(&challenge).is_ok());
220    }
221
222    #[test]
223    fn canonical_bytes_are_deterministic_and_member_sorted() {
224        let challenge = valid_challenge();
225        let a = canonical_challenge_bytes(&challenge).expect("canonical");
226        let b = canonical_challenge_bytes(&challenge).expect("canonical");
227        assert_eq!(a, b);
228        let text = String::from_utf8(a).expect("utf8");
229        // JCS sorts object members lexicographically: nonce < origin <
230        // timestamp < type.
231        let nonce_at = text.find("\"nonce\"").expect("nonce key");
232        let origin_at = text.find("\"origin\"").expect("origin key");
233        let timestamp_at = text.find("\"timestamp\"").expect("timestamp key");
234        let type_at = text.find("\"type\"").expect("type key");
235        assert!(nonce_at < origin_at && origin_at < timestamp_at && timestamp_at < type_at);
236        // No whitespace between tokens.
237        assert!(!text.contains(": "));
238    }
239
240    #[test]
241    fn validate_challenge_rejects_wrong_type() {
242        let mut challenge = valid_challenge();
243        challenge.message_type = "openagent-challenge-v0".to_string();
244        assert!(matches!(
245            validate_challenge(&challenge),
246            Err(AuthProtocolError::InvalidChallenge { .. })
247        ));
248    }
249
250    #[test]
251    fn validate_challenge_rejects_short_nonce() {
252        let mut challenge = valid_challenge();
253        challenge.nonce = "abcd".to_string();
254        assert!(validate_challenge(&challenge).is_err());
255    }
256
257    #[test]
258    fn validate_challenge_rejects_fractional_seconds() {
259        let mut challenge = valid_challenge();
260        challenge.timestamp = "2026-03-21T12:00:00.5Z".to_string();
261        assert!(validate_challenge(&challenge).is_err());
262    }
263
264    #[test]
265    fn validate_challenge_rejects_offset_timezone() {
266        let mut challenge = valid_challenge();
267        challenge.timestamp = "2026-03-21T12:00:00+00:00".to_string();
268        assert!(validate_challenge(&challenge).is_err());
269    }
270
271    #[test]
272    fn validate_challenge_rejects_realm_empty() {
273        let mut challenge = valid_challenge();
274        challenge.realm = Some(String::new());
275        assert!(validate_challenge(&challenge).is_err());
276    }
277
278    #[test]
279    fn proof_shape_requires_nonce_echo() {
280        use base64::Engine;
281        let challenge = valid_challenge();
282        let proof = IdentityProof {
283            signature: base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([7u8; 64]),
284            public_key: base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([3u8; 32]),
285            key_type: KeyType::Ed25519,
286            nonce: "f".repeat(64),
287        };
288        assert!(matches!(
289            validate_proof_shape(&proof, &challenge),
290            Err(AuthProtocolError::NonceMismatch { .. })
291        ));
292    }
293
294    #[test]
295    fn proof_shape_checks_key_length_by_scheme() {
296        use base64::Engine;
297        let challenge = valid_challenge();
298        let proof = IdentityProof {
299            signature: base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([7u8; 64]),
300            // A secp256k1 key must be 33 bytes; 32 must fail.
301            public_key: base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([3u8; 32]),
302            key_type: KeyType::Secp256k1,
303            nonce: challenge.nonce.clone(),
304        };
305        assert!(matches!(
306            validate_proof_shape(&proof, &challenge),
307            Err(AuthProtocolError::InvalidProof { .. })
308        ));
309    }
310}