1use crate::errors::AshError;
36use crate::proof::{
37 ash_build_proof, ash_build_proof_scoped, ash_build_proof_unified, ash_derive_client_secret,
38 ash_hash_body, ash_validate_timestamp_format,
39};
40use crate::validate::ash_validate_nonce;
41
42#[derive(Debug)]
55pub struct BuildRequestInput<'a> {
56 pub method: &'a str,
58
59 pub path: &'a str,
61
62 pub raw_query: &'a str,
64
65 pub canonical_body: &'a str,
67
68 pub nonce: &'a str,
70
71 pub context_id: &'a str,
73
74 pub timestamp: &'a str,
76
77 pub scope: Option<&'a [&'a str]>,
79
80 pub previous_proof: Option<&'a str>,
82}
83
84#[derive(Debug)]
90pub struct BuildRequestResult {
91 pub proof: String,
93
94 pub body_hash: String,
96
97 pub binding: String,
99
100 pub timestamp: String,
102
103 pub nonce: String,
105
106 pub scope_hash: String,
108
109 pub chain_hash: String,
111
112 pub meta: Option<BuildMeta>,
114}
115
116#[derive(Debug)]
118pub struct BuildMeta {
119 pub canonical_query: String,
121}
122
123pub fn build_request_proof(input: &BuildRequestInput<'_>) -> Result<BuildRequestResult, AshError> {
169 ash_validate_nonce(input.nonce)?;
171
172 ash_validate_timestamp_format(input.timestamp)?;
174
175 let binding =
177 crate::ash_normalize_binding(input.method, input.path, input.raw_query)?;
178
179 let body_hash = ash_hash_body(input.canonical_body);
181
182 let client_secret = ash_derive_client_secret(input.nonce, input.context_id, &binding)?;
184
185 let (proof, scope_hash, chain_hash) = match (input.scope, input.previous_proof) {
187 (Some(scope), Some(prev)) => {
189 let r = ash_build_proof_unified(
190 &client_secret,
191 input.timestamp,
192 &binding,
193 input.canonical_body,
194 scope,
195 Some(prev),
196 )?;
197 (r.proof, r.scope_hash, r.chain_hash)
198 }
199 (None, Some(prev)) => {
201 let r = ash_build_proof_unified(
202 &client_secret,
203 input.timestamp,
204 &binding,
205 input.canonical_body,
206 &[],
207 Some(prev),
208 )?;
209 (r.proof, r.scope_hash, r.chain_hash)
210 }
211 (Some(scope), None) if !scope.is_empty() => {
213 let (proof, scope_hash) = ash_build_proof_scoped(
214 &client_secret,
215 input.timestamp,
216 &binding,
217 input.canonical_body,
218 scope,
219 )?;
220 (proof, scope_hash, String::new())
221 }
222 _ => {
224 let proof = ash_build_proof(&client_secret, input.timestamp, &binding, &body_hash)?;
225 (proof, String::new(), String::new())
226 }
227 };
228
229 let canonical_query = if binding.contains('|') {
231 binding.rsplitn(2, '|').next().unwrap_or("").to_string()
233 } else {
234 String::new()
235 };
236
237 let meta = if cfg!(debug_assertions) {
238 Some(BuildMeta { canonical_query })
239 } else {
240 None
241 };
242
243 Ok(BuildRequestResult {
244 proof,
245 body_hash,
246 binding,
247 timestamp: input.timestamp.to_string(),
248 nonce: input.nonce.to_string(),
249 scope_hash,
250 chain_hash,
251 meta,
252 })
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258 use crate::errors::{AshErrorCode, InternalReason};
259
260 #[test]
261 fn test_basic_build_succeeds() {
262 let input = BuildRequestInput {
263 method: "POST",
264 path: "/api/transfer",
265 raw_query: "",
266 canonical_body: r#"{"amount":100}"#,
267 nonce: "0123456789abcdef0123456789abcdef",
268 context_id: "ctx_test123",
269 timestamp: "1700000000",
270 scope: None,
271 previous_proof: None,
272 };
273
274 let result = build_request_proof(&input).unwrap();
275 assert_eq!(result.proof.len(), 64);
276 assert_eq!(result.body_hash.len(), 64);
277 assert_eq!(result.binding, "POST|/api/transfer|");
278 assert_eq!(result.timestamp, "1700000000");
279 assert_eq!(result.nonce, "0123456789abcdef0123456789abcdef");
280 assert!(result.scope_hash.is_empty());
281 assert!(result.chain_hash.is_empty());
282 }
283
284 #[test]
285 fn test_build_normalizes_method() {
286 let input = BuildRequestInput {
287 method: "post",
288 path: "/api/test",
289 raw_query: "",
290 canonical_body: "{}",
291 nonce: "0123456789abcdef0123456789abcdef",
292 context_id: "ctx_test",
293 timestamp: "1700000000",
294 scope: None,
295 previous_proof: None,
296 };
297
298 let result = build_request_proof(&input).unwrap();
299 assert!(result.binding.starts_with("POST|"));
300 }
301
302 #[test]
303 fn test_build_normalizes_path() {
304 let input = BuildRequestInput {
305 method: "GET",
306 path: "/api//users/",
307 raw_query: "",
308 canonical_body: "{}",
309 nonce: "0123456789abcdef0123456789abcdef",
310 context_id: "ctx_test",
311 timestamp: "1700000000",
312 scope: None,
313 previous_proof: None,
314 };
315
316 let result = build_request_proof(&input).unwrap();
317 assert_eq!(result.binding, "GET|/api/users|");
318 }
319
320 #[test]
321 fn test_build_canonicalizes_query() {
322 let input = BuildRequestInput {
323 method: "GET",
324 path: "/api/search",
325 raw_query: "z=3&a=1",
326 canonical_body: "{}",
327 nonce: "0123456789abcdef0123456789abcdef",
328 context_id: "ctx_test",
329 timestamp: "1700000000",
330 scope: None,
331 previous_proof: None,
332 };
333
334 let result = build_request_proof(&input).unwrap();
335 assert_eq!(result.binding, "GET|/api/search|a=1&z=3");
336 }
337
338 #[test]
339 fn test_build_bad_nonce_fails_first() {
340 let input = BuildRequestInput {
341 method: "POST",
342 path: "/api/test",
343 raw_query: "",
344 canonical_body: "{}",
345 nonce: "short",
346 context_id: "ctx_test",
347 timestamp: "1700000000",
348 scope: None,
349 previous_proof: None,
350 };
351
352 let err = build_request_proof(&input).unwrap_err();
353 assert_eq!(err.code(), AshErrorCode::ValidationError);
354 assert_eq!(err.reason(), InternalReason::NonceTooShort);
355 }
356
357 #[test]
358 fn test_build_bad_timestamp_fails_second() {
359 let input = BuildRequestInput {
360 method: "POST",
361 path: "/api/test",
362 raw_query: "",
363 canonical_body: "{}",
364 nonce: "0123456789abcdef0123456789abcdef",
365 context_id: "ctx_test",
366 timestamp: "not_a_number",
367 scope: None,
368 previous_proof: None,
369 };
370
371 let err = build_request_proof(&input).unwrap_err();
372 assert_eq!(err.code(), AshErrorCode::TimestampInvalid);
373 }
374
375 #[test]
376 fn test_build_bad_path_fails() {
377 let input = BuildRequestInput {
378 method: "POST",
379 path: "no_leading_slash",
380 raw_query: "",
381 canonical_body: "{}",
382 nonce: "0123456789abcdef0123456789abcdef",
383 context_id: "ctx_test",
384 timestamp: "1700000000",
385 scope: None,
386 previous_proof: None,
387 };
388
389 let err = build_request_proof(&input).unwrap_err();
390 assert_eq!(err.code(), AshErrorCode::ValidationError);
391 }
392
393 #[test]
394 fn test_build_verify_roundtrip() {
395 let nonce = "0123456789abcdef0123456789abcdef";
397 let context_id = "ctx_roundtrip";
398 let canonical_body = r#"{"amount":100}"#;
399 let timestamp = "1700000000";
400
401 let build_result = build_request_proof(&BuildRequestInput {
402 method: "POST",
403 path: "/api/transfer",
404 raw_query: "sort=name",
405 canonical_body,
406 nonce,
407 context_id,
408 timestamp,
409 scope: None,
410 previous_proof: None,
411 })
412 .unwrap();
413
414 let client_secret =
416 ash_derive_client_secret(nonce, context_id, &build_result.binding).unwrap();
417 let expected_proof =
418 ash_build_proof(&client_secret, timestamp, &build_result.binding, &build_result.body_hash)
419 .unwrap();
420
421 assert_eq!(build_result.proof, expected_proof);
422 }
423
424 #[test]
425 fn test_build_scoped_proof() {
426 let input = BuildRequestInput {
427 method: "POST",
428 path: "/api/transfer",
429 raw_query: "",
430 canonical_body: r#"{"amount":100,"recipient":"alice"}"#,
431 nonce: "0123456789abcdef0123456789abcdef",
432 context_id: "ctx_scoped",
433 timestamp: "1700000000",
434 scope: Some(&["amount", "recipient"]),
435 previous_proof: None,
436 };
437
438 let result = build_request_proof(&input).unwrap();
439 assert_eq!(result.proof.len(), 64);
440 assert!(!result.scope_hash.is_empty());
441 assert!(result.chain_hash.is_empty());
442 }
443
444 #[test]
445 fn test_build_chained_proof() {
446 let first = build_request_proof(&BuildRequestInput {
448 method: "POST",
449 path: "/api/step1",
450 raw_query: "",
451 canonical_body: r#"{"step":1}"#,
452 nonce: "0123456789abcdef0123456789abcdef",
453 context_id: "ctx_chain",
454 timestamp: "1700000000",
455 scope: None,
456 previous_proof: None,
457 })
458 .unwrap();
459
460 let second = build_request_proof(&BuildRequestInput {
462 method: "POST",
463 path: "/api/step2",
464 raw_query: "",
465 canonical_body: r#"{"step":2}"#,
466 nonce: "0123456789abcdef0123456789abcdef",
467 context_id: "ctx_chain",
468 timestamp: "1700000001",
469 scope: None,
470 previous_proof: Some(&first.proof),
471 })
472 .unwrap();
473
474 assert_eq!(second.proof.len(), 64);
475 assert!(!second.chain_hash.is_empty());
476 assert_eq!(second.chain_hash.len(), 64);
478 }
479
480 #[test]
483 fn precedence_bad_nonce_before_bad_timestamp() {
484 let input = BuildRequestInput {
485 method: "POST",
486 path: "/api/test",
487 raw_query: "",
488 canonical_body: "{}",
489 nonce: "short", context_id: "ctx_test",
491 timestamp: "not_a_number", scope: None,
493 previous_proof: None,
494 };
495
496 let err = build_request_proof(&input).unwrap_err();
497 assert_eq!(err.reason(), InternalReason::NonceTooShort);
499 }
500
501 #[test]
502 fn precedence_bad_timestamp_before_bad_path() {
503 let input = BuildRequestInput {
504 method: "POST",
505 path: "no_slash", raw_query: "",
507 canonical_body: "{}",
508 nonce: "0123456789abcdef0123456789abcdef",
509 context_id: "ctx_test",
510 timestamp: "not_a_number", scope: None,
512 previous_proof: None,
513 };
514
515 let err = build_request_proof(&input).unwrap_err();
516 assert_eq!(err.code(), AshErrorCode::TimestampInvalid);
518 }
519}