openagent_auth_protocol/
handshake.rs1use crate::error::AuthProtocolError;
10use crate::message::{IdentityChallenge, IdentityProof, KeyType, CHALLENGE_TYPE};
11
12pub const DEFAULT_SESSION_TTL_SECS: u32 = 300;
14
15pub const DEFAULT_CHALLENGE_TTL_SECS: u32 = 60;
17
18pub const MAX_CHALLENGE_TTL_SECS: u32 = 300;
20
21pub const WELL_KNOWN_PATH: &str = "/.well-known/openagent";
23
24pub const AUTH_ENDPOINT_PATH: &str = "/.well-known/openagent/auth";
26
27pub const PROVE_ENDPOINT_PATH: &str = "/.well-known/openagent/auth/prove";
29
30pub 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
37pub 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
53pub 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
72pub 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
99pub 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
116pub 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
130pub 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
146pub 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
183fn 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 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 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 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}