use crate::errors::AshError;
use crate::proof::{
ash_build_proof, ash_build_proof_scoped, ash_build_proof_unified, ash_derive_client_secret,
ash_hash_body, ash_validate_timestamp_format,
};
use crate::validate::ash_validate_nonce;
#[derive(Debug)]
pub struct BuildRequestInput<'a> {
pub method: &'a str,
pub path: &'a str,
pub raw_query: &'a str,
pub canonical_body: &'a str,
pub nonce: &'a str,
pub context_id: &'a str,
pub timestamp: &'a str,
pub scope: Option<&'a [&'a str]>,
pub previous_proof: Option<&'a str>,
}
#[derive(Debug)]
pub struct BuildRequestResult {
pub proof: String,
pub body_hash: String,
pub binding: String,
pub timestamp: String,
pub nonce: String,
pub scope_hash: String,
pub chain_hash: String,
pub meta: Option<BuildMeta>,
}
#[derive(Debug)]
pub struct BuildMeta {
pub canonical_query: String,
}
pub fn build_request_proof(input: &BuildRequestInput<'_>) -> Result<BuildRequestResult, AshError> {
ash_validate_nonce(input.nonce)?;
ash_validate_timestamp_format(input.timestamp)?;
let binding =
crate::ash_normalize_binding(input.method, input.path, input.raw_query)?;
let body_hash = ash_hash_body(input.canonical_body);
let client_secret = ash_derive_client_secret(input.nonce, input.context_id, &binding)?;
let (proof, scope_hash, chain_hash) = match (input.scope, input.previous_proof) {
(Some(scope), Some(prev)) => {
let r = ash_build_proof_unified(
&client_secret,
input.timestamp,
&binding,
input.canonical_body,
scope,
Some(prev),
)?;
(r.proof, r.scope_hash, r.chain_hash)
}
(None, Some(prev)) => {
let r = ash_build_proof_unified(
&client_secret,
input.timestamp,
&binding,
input.canonical_body,
&[],
Some(prev),
)?;
(r.proof, r.scope_hash, r.chain_hash)
}
(Some(scope), None) if !scope.is_empty() => {
let (proof, scope_hash) = ash_build_proof_scoped(
&client_secret,
input.timestamp,
&binding,
input.canonical_body,
scope,
)?;
(proof, scope_hash, String::new())
}
_ => {
let proof = ash_build_proof(&client_secret, input.timestamp, &binding, &body_hash)?;
(proof, String::new(), String::new())
}
};
let canonical_query = if binding.contains('|') {
binding.rsplitn(2, '|').next().unwrap_or("").to_string()
} else {
String::new()
};
let meta = if cfg!(debug_assertions) {
Some(BuildMeta { canonical_query })
} else {
None
};
Ok(BuildRequestResult {
proof,
body_hash,
binding,
timestamp: input.timestamp.to_string(),
nonce: input.nonce.to_string(),
scope_hash,
chain_hash,
meta,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::errors::{AshErrorCode, InternalReason};
#[test]
fn test_basic_build_succeeds() {
let input = BuildRequestInput {
method: "POST",
path: "/api/transfer",
raw_query: "",
canonical_body: r#"{"amount":100}"#,
nonce: "0123456789abcdef0123456789abcdef",
context_id: "ctx_test123",
timestamp: "1700000000",
scope: None,
previous_proof: None,
};
let result = build_request_proof(&input).unwrap();
assert_eq!(result.proof.len(), 64);
assert_eq!(result.body_hash.len(), 64);
assert_eq!(result.binding, "POST|/api/transfer|");
assert_eq!(result.timestamp, "1700000000");
assert_eq!(result.nonce, "0123456789abcdef0123456789abcdef");
assert!(result.scope_hash.is_empty());
assert!(result.chain_hash.is_empty());
}
#[test]
fn test_build_normalizes_method() {
let input = BuildRequestInput {
method: "post",
path: "/api/test",
raw_query: "",
canonical_body: "{}",
nonce: "0123456789abcdef0123456789abcdef",
context_id: "ctx_test",
timestamp: "1700000000",
scope: None,
previous_proof: None,
};
let result = build_request_proof(&input).unwrap();
assert!(result.binding.starts_with("POST|"));
}
#[test]
fn test_build_normalizes_path() {
let input = BuildRequestInput {
method: "GET",
path: "/api//users/",
raw_query: "",
canonical_body: "{}",
nonce: "0123456789abcdef0123456789abcdef",
context_id: "ctx_test",
timestamp: "1700000000",
scope: None,
previous_proof: None,
};
let result = build_request_proof(&input).unwrap();
assert_eq!(result.binding, "GET|/api/users|");
}
#[test]
fn test_build_canonicalizes_query() {
let input = BuildRequestInput {
method: "GET",
path: "/api/search",
raw_query: "z=3&a=1",
canonical_body: "{}",
nonce: "0123456789abcdef0123456789abcdef",
context_id: "ctx_test",
timestamp: "1700000000",
scope: None,
previous_proof: None,
};
let result = build_request_proof(&input).unwrap();
assert_eq!(result.binding, "GET|/api/search|a=1&z=3");
}
#[test]
fn test_build_bad_nonce_fails_first() {
let input = BuildRequestInput {
method: "POST",
path: "/api/test",
raw_query: "",
canonical_body: "{}",
nonce: "short",
context_id: "ctx_test",
timestamp: "1700000000",
scope: None,
previous_proof: None,
};
let err = build_request_proof(&input).unwrap_err();
assert_eq!(err.code(), AshErrorCode::ValidationError);
assert_eq!(err.reason(), InternalReason::NonceTooShort);
}
#[test]
fn test_build_bad_timestamp_fails_second() {
let input = BuildRequestInput {
method: "POST",
path: "/api/test",
raw_query: "",
canonical_body: "{}",
nonce: "0123456789abcdef0123456789abcdef",
context_id: "ctx_test",
timestamp: "not_a_number",
scope: None,
previous_proof: None,
};
let err = build_request_proof(&input).unwrap_err();
assert_eq!(err.code(), AshErrorCode::TimestampInvalid);
}
#[test]
fn test_build_bad_path_fails() {
let input = BuildRequestInput {
method: "POST",
path: "no_leading_slash",
raw_query: "",
canonical_body: "{}",
nonce: "0123456789abcdef0123456789abcdef",
context_id: "ctx_test",
timestamp: "1700000000",
scope: None,
previous_proof: None,
};
let err = build_request_proof(&input).unwrap_err();
assert_eq!(err.code(), AshErrorCode::ValidationError);
}
#[test]
fn test_build_verify_roundtrip() {
let nonce = "0123456789abcdef0123456789abcdef";
let context_id = "ctx_roundtrip";
let canonical_body = r#"{"amount":100}"#;
let timestamp = "1700000000";
let build_result = build_request_proof(&BuildRequestInput {
method: "POST",
path: "/api/transfer",
raw_query: "sort=name",
canonical_body,
nonce,
context_id,
timestamp,
scope: None,
previous_proof: None,
})
.unwrap();
let client_secret =
ash_derive_client_secret(nonce, context_id, &build_result.binding).unwrap();
let expected_proof =
ash_build_proof(&client_secret, timestamp, &build_result.binding, &build_result.body_hash)
.unwrap();
assert_eq!(build_result.proof, expected_proof);
}
#[test]
fn test_build_scoped_proof() {
let input = BuildRequestInput {
method: "POST",
path: "/api/transfer",
raw_query: "",
canonical_body: r#"{"amount":100,"recipient":"alice"}"#,
nonce: "0123456789abcdef0123456789abcdef",
context_id: "ctx_scoped",
timestamp: "1700000000",
scope: Some(&["amount", "recipient"]),
previous_proof: None,
};
let result = build_request_proof(&input).unwrap();
assert_eq!(result.proof.len(), 64);
assert!(!result.scope_hash.is_empty());
assert!(result.chain_hash.is_empty());
}
#[test]
fn test_build_chained_proof() {
let first = build_request_proof(&BuildRequestInput {
method: "POST",
path: "/api/step1",
raw_query: "",
canonical_body: r#"{"step":1}"#,
nonce: "0123456789abcdef0123456789abcdef",
context_id: "ctx_chain",
timestamp: "1700000000",
scope: None,
previous_proof: None,
})
.unwrap();
let second = build_request_proof(&BuildRequestInput {
method: "POST",
path: "/api/step2",
raw_query: "",
canonical_body: r#"{"step":2}"#,
nonce: "0123456789abcdef0123456789abcdef",
context_id: "ctx_chain",
timestamp: "1700000001",
scope: None,
previous_proof: Some(&first.proof),
})
.unwrap();
assert_eq!(second.proof.len(), 64);
assert!(!second.chain_hash.is_empty());
assert_eq!(second.chain_hash.len(), 64);
}
#[test]
fn precedence_bad_nonce_before_bad_timestamp() {
let input = BuildRequestInput {
method: "POST",
path: "/api/test",
raw_query: "",
canonical_body: "{}",
nonce: "short", context_id: "ctx_test",
timestamp: "not_a_number", scope: None,
previous_proof: None,
};
let err = build_request_proof(&input).unwrap_err();
assert_eq!(err.reason(), InternalReason::NonceTooShort);
}
#[test]
fn precedence_bad_timestamp_before_bad_path() {
let input = BuildRequestInput {
method: "POST",
path: "no_slash", raw_query: "",
canonical_body: "{}",
nonce: "0123456789abcdef0123456789abcdef",
context_id: "ctx_test",
timestamp: "not_a_number", scope: None,
previous_proof: None,
};
let err = build_request_proof(&input).unwrap_err();
assert_eq!(err.code(), AshErrorCode::TimestampInvalid);
}
}