use sha2::{Digest, Sha256};
use crate::compare::ash_timing_safe_equal;
use crate::errors::{AshError, AshErrorCode};
pub const ASH_SDK_VERSION: &str = "2.3.5";
pub const ASH_VERSION_PREFIX: &str = "ASHv2.1";
use hmac::{Hmac, Mac};
use sha2::Sha256 as HmacSha256;
type HmacSha256Type = Hmac<HmacSha256>;
const MIN_NONCE_BYTES: usize = 16;
const MIN_NONCE_HEX_CHARS: usize = 32;
const MAX_ARRAY_INDEX: usize = 10000;
const MAX_TOTAL_ARRAY_ALLOCATION: usize = 10000;
const MAX_SCOPE_PATH_DEPTH: usize = 32;
const MAX_TIMESTAMP: u64 = 32503680000;
const MAX_SCOPE_FIELDS: usize = 100;
const SCOPE_FIELD_DELIMITER: char = '\x1F';
const MAX_BINDING_LENGTH: usize = 8192;
const MAX_CONTEXT_ID_LENGTH: usize = 256;
const MAX_NONCE_LENGTH: usize = 512;
const MAX_SCOPE_FIELD_NAME_LENGTH: usize = 64;
const MAX_TOTAL_SCOPE_LENGTH: usize = 4096;
pub fn ash_generate_nonce(bytes: usize) -> Result<String, AshError> {
if bytes < MIN_NONCE_BYTES {
return Err(AshError::new(
AshErrorCode::ValidationError,
format!("Nonce must be at least {} bytes for adequate entropy", MIN_NONCE_BYTES),
));
}
use getrandom::getrandom;
let mut buf = vec![0u8; bytes];
getrandom(&mut buf).map_err(|e| {
AshError::new(
AshErrorCode::InternalError,
format!("Random number generation failed: {}", e),
)
})?;
Ok(hex::encode(buf))
}
pub fn ash_generate_nonce_or_panic(bytes: usize) -> String {
ash_generate_nonce(bytes).expect("Nonce generation failed (check byte count >= 16 and RNG availability)")
}
pub fn ash_generate_context_id() -> Result<String, AshError> {
Ok(format!("ash_{}", ash_generate_nonce(16)?))
}
pub fn ash_generate_context_id_256() -> Result<String, AshError> {
Ok(format!("ash_{}", ash_generate_nonce(32)?))
}
pub fn ash_derive_client_secret(nonce: &str, context_id: &str, binding: &str) -> Result<String, AshError> {
crate::validate::ash_validate_nonce(nonce)?;
if context_id.is_empty() {
return Err(AshError::new(
AshErrorCode::ValidationError,
"context_id cannot be empty",
));
}
if context_id.len() > MAX_CONTEXT_ID_LENGTH {
return Err(AshError::new(
AshErrorCode::ValidationError,
format!("context_id exceeds maximum length of {} characters", MAX_CONTEXT_ID_LENGTH),
));
}
if !context_id.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') {
return Err(AshError::new(
AshErrorCode::ValidationError,
"context_id must contain only ASCII alphanumeric characters, underscore, hyphen, or dot",
));
}
if binding.is_empty() {
return Err(AshError::new(
AshErrorCode::ValidationError,
"binding cannot be empty",
));
}
if binding.len() > MAX_BINDING_LENGTH {
return Err(AshError::new(
AshErrorCode::ValidationError,
format!("binding exceeds maximum length of {} bytes", MAX_BINDING_LENGTH),
));
}
let mut mac =
HmacSha256Type::new_from_slice(nonce.as_bytes()).expect("HMAC can take key of any size");
mac.update(format!("{}|{}", context_id, binding).as_bytes());
Ok(hex::encode(mac.finalize().into_bytes()))
}
const SHA256_HEX_LENGTH: usize = 64;
pub fn ash_build_proof(
client_secret: &str,
timestamp: &str,
binding: &str,
body_hash: &str,
) -> Result<String, AshError> {
if client_secret.is_empty() {
return Err(AshError::new(
AshErrorCode::ValidationError,
"client_secret cannot be empty",
));
}
if timestamp.is_empty() {
return Err(AshError::new(
AshErrorCode::ValidationError,
"timestamp cannot be empty",
));
}
if binding.is_empty() {
return Err(AshError::new(
AshErrorCode::ValidationError,
"binding cannot be empty",
));
}
if binding.len() > MAX_BINDING_LENGTH {
return Err(AshError::new(
AshErrorCode::ValidationError,
format!("binding exceeds maximum length of {} bytes", MAX_BINDING_LENGTH),
));
}
if body_hash.len() != SHA256_HEX_LENGTH {
return Err(AshError::new(
AshErrorCode::ValidationError,
format!(
"body_hash must be {} hex characters (SHA-256), got {}",
SHA256_HEX_LENGTH,
body_hash.len()
),
));
}
if !body_hash.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(AshError::new(
AshErrorCode::ValidationError,
"body_hash must contain only hexadecimal characters (0-9, a-f, A-F)",
));
}
let message = format!("{}|{}|{}", timestamp, binding, body_hash);
let mut mac = HmacSha256Type::new_from_slice(client_secret.as_bytes())
.expect("HMAC can take key of any size");
mac.update(message.as_bytes());
Ok(hex::encode(mac.finalize().into_bytes()))
}
pub fn ash_verify_proof(
nonce: &str,
context_id: &str,
binding: &str,
timestamp: &str,
body_hash: &str,
client_proof: &str,
) -> Result<bool, AshError> {
ash_validate_timestamp_format(timestamp)?;
let client_secret = ash_derive_client_secret(nonce, context_id, binding)?;
let expected_proof = ash_build_proof(&client_secret, timestamp, binding, body_hash)?;
Ok(ash_timing_safe_equal(expected_proof.as_bytes(), client_proof.as_bytes()))
}
#[allow(clippy::too_many_arguments)]
pub fn ash_verify_proof_with_freshness(
nonce: &str,
context_id: &str,
binding: &str,
timestamp: &str,
body_hash: &str,
client_proof: &str,
max_age_seconds: u64,
clock_skew_seconds: u64,
) -> Result<bool, AshError> {
ash_validate_timestamp(timestamp, max_age_seconds, clock_skew_seconds)?;
let client_secret = ash_derive_client_secret(nonce, context_id, binding)?;
let expected_proof = ash_build_proof(&client_secret, timestamp, binding, body_hash)?;
Ok(ash_timing_safe_equal(expected_proof.as_bytes(), client_proof.as_bytes()))
}
pub fn ash_hash_body(canonical_body: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(canonical_body.as_bytes());
hex::encode(hasher.finalize())
}
fn ash_normalize_scope(scope: &[&str]) -> Vec<String> {
let mut sorted: Vec<String> = scope.iter().map(|s| s.to_string()).collect();
sorted.sort();
sorted.dedup();
sorted
}
fn ash_join_scope_fields(scope: &[&str]) -> Result<String, AshError> {
let mut total_length: usize = 0;
for field in scope {
if field.is_empty() {
return Err(AshError::new(
AshErrorCode::ValidationError,
"Scope field names cannot be empty",
));
}
if field.len() > MAX_SCOPE_FIELD_NAME_LENGTH {
return Err(AshError::new(
AshErrorCode::ValidationError,
format!("Scope field name exceeds maximum length of {} characters", MAX_SCOPE_FIELD_NAME_LENGTH),
));
}
total_length = total_length.saturating_add(field.len()).saturating_add(1);
if field.contains(SCOPE_FIELD_DELIMITER) {
return Err(AshError::new(
AshErrorCode::ValidationError,
"Scope field contains reserved delimiter character (U+001F)",
));
}
}
if total_length > MAX_TOTAL_SCOPE_LENGTH {
return Err(AshError::new(
AshErrorCode::ValidationError,
format!("Total scope length exceeds maximum of {} bytes", MAX_TOTAL_SCOPE_LENGTH),
));
}
let normalized = ash_normalize_scope(scope);
Ok(normalized.join(&SCOPE_FIELD_DELIMITER.to_string()))
}
pub fn ash_hash_scope(scope: &[&str]) -> Result<String, AshError> {
if scope.is_empty() {
return Ok(String::new());
}
Ok(ash_hash_body(&ash_join_scope_fields(scope)?))
}
#[cfg(test)]
mod tests_proof {
use super::*;
const TEST_NONCE: &str = "0123456789abcdef0123456789abcdef"; const TEST_NONCE_2: &str = "fedcba9876543210fedcba9876543210"; const TEST_BODY_HASH: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
#[allow(dead_code)]
const TEST_BODY_HASH_2: &str = "d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592";
#[test]
fn test_derive_client_secret_deterministic() {
let secret1 = ash_derive_client_secret(TEST_NONCE, "ctx_abc", "POST /login").unwrap();
let secret2 = ash_derive_client_secret(TEST_NONCE, "ctx_abc", "POST /login").unwrap();
assert_eq!(secret1, secret2);
}
#[test]
fn test_derive_client_secret_different_inputs() {
let secret1 = ash_derive_client_secret(TEST_NONCE, "ctx_abc", "POST /login").unwrap();
let secret2 = ash_derive_client_secret(TEST_NONCE_2, "ctx_abc", "POST /login").unwrap();
assert_ne!(secret1, secret2);
}
#[test]
fn test_derive_client_secret_rejects_short_nonce() {
let result = ash_derive_client_secret("short", "ctx_abc", "POST /login");
assert!(result.is_err());
assert!(result.unwrap_err().message().contains("hex characters"));
}
#[test]
fn test_derive_client_secret_rejects_non_hex_nonce() {
let result = ash_derive_client_secret("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", "ctx_abc", "POST /login");
assert!(result.is_err());
assert!(result.unwrap_err().message().contains("hexadecimal"));
}
#[test]
fn test_derive_client_secret_rejects_delimiter_in_context_id() {
let result = ash_derive_client_secret(TEST_NONCE, "ctx|abc", "POST /login");
assert!(result.is_err());
let msg = result.unwrap_err().message().to_lowercase();
assert!(msg.contains("delimiter") || msg.contains("alphanumeric") || msg.contains("character"));
}
#[test]
fn test_derive_client_secret_allows_delimiter_in_binding() {
let result = ash_derive_client_secret(TEST_NONCE, "ctx_abc", "POST|/login|");
assert!(result.is_ok());
}
#[test]
fn test_derive_client_secret_rejects_empty_context_id() {
let result = ash_derive_client_secret(TEST_NONCE, "", "POST /login");
assert!(result.is_err());
assert!(result.unwrap_err().message().contains("empty"));
}
#[test]
fn test_build_proof_deterministic() {
let proof1 = ash_build_proof("secret", "1234567890", "POST /login", TEST_BODY_HASH).unwrap();
let proof2 = ash_build_proof("secret", "1234567890", "POST /login", TEST_BODY_HASH).unwrap();
assert_eq!(proof1, proof2);
}
#[test]
fn test_build_proof_rejects_empty_inputs() {
assert!(ash_build_proof("", "1234567890", "POST /login", TEST_BODY_HASH).is_err());
assert!(ash_build_proof("secret", "", "POST /login", TEST_BODY_HASH).is_err());
assert!(ash_build_proof("secret", "1234567890", "", TEST_BODY_HASH).is_err());
assert!(ash_build_proof("secret", "1234567890", "POST /login", "").is_err());
}
#[test]
fn test_build_proof_rejects_invalid_body_hash() {
assert!(ash_build_proof("secret", "1234567890", "POST /login", "abc123").is_err());
let too_long = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855aa";
assert!(ash_build_proof("secret", "1234567890", "POST /login", too_long).is_err());
let non_hex = "g3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
assert!(ash_build_proof("secret", "1234567890", "POST /login", non_hex).is_err());
}
#[test]
fn test_ash_verify_proof() {
let nonce = TEST_NONCE;
let context_id = "ctx_abc";
let binding = "POST /login";
let timestamp = "1234567890";
let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
let proof = ash_build_proof(&client_secret, timestamp, binding, TEST_BODY_HASH).unwrap();
assert!(ash_verify_proof(
nonce, context_id, binding, timestamp, TEST_BODY_HASH, &proof
).unwrap());
}
#[test]
fn test_ash_hash_body() {
let hash = ash_hash_body(r#"{"name":"John"}"#);
assert_eq!(hash.len(), 64); }
#[test]
fn test_timestamp_rejects_leading_zeros() {
let result = ash_validate_timestamp_format("0123456789");
assert!(result.is_err());
assert!(result.unwrap_err().message().contains("leading zeros"));
let result = ash_validate_timestamp_format("0");
assert!(result.is_ok());
}
#[test]
fn test_context_id_max_length() {
let long_context = "a".repeat(257); let result = ash_derive_client_secret(TEST_NONCE, &long_context, "POST|/api|");
assert!(result.is_err());
assert!(result.unwrap_err().message().contains("maximum length"));
}
#[test]
fn test_context_id_at_max_length() {
let max_context = "a".repeat(256); let result = ash_derive_client_secret(TEST_NONCE, &max_context, "POST|/api|");
assert!(result.is_ok());
}
#[test]
fn test_context_id_rejects_invalid_chars() {
let result = ash_derive_client_secret(TEST_NONCE, "ctx with space", "POST|/api|");
assert!(result.is_err());
assert!(result.unwrap_err().message().contains("alphanumeric"));
let result = ash_derive_client_secret(TEST_NONCE, "ctx@special", "POST|/api|");
assert!(result.is_err());
let result = ash_derive_client_secret(TEST_NONCE, "ctx\x00null", "POST|/api|");
assert!(result.is_err());
}
#[test]
fn test_context_id_allows_valid_chars() {
let result = ash_derive_client_secret(TEST_NONCE, "ctx_ABC-123.test", "POST|/api|");
assert!(result.is_ok());
}
#[test]
fn test_nonce_max_length() {
let long_nonce = "0".repeat(513); let result = ash_derive_client_secret(&long_nonce, "ctx_test", "POST|/api|");
assert!(result.is_err());
assert!(result.unwrap_err().message().contains("maximum length"));
}
#[test]
fn test_nonce_at_max_length() {
let max_nonce = "0".repeat(512); let result = ash_derive_client_secret(&max_nonce, "ctx_test", "POST|/api|");
assert!(result.is_ok());
}
}
#[cfg(test)]
mod tests_sec_scope_001 {
use super::*;
#[test]
fn test_scope_field_name_max_length() {
let long_field = "a".repeat(65); let scope = vec![long_field.as_str()];
let result = ash_hash_scope(&scope);
assert!(result.is_err());
assert!(result.unwrap_err().message().contains("maximum length"));
}
#[test]
fn test_scope_field_name_at_max_length() {
let max_field = "a".repeat(64); let scope = vec![max_field.as_str()];
let result = ash_hash_scope(&scope);
assert!(result.is_ok());
}
#[test]
fn test_scope_total_length_limit() {
let fields: Vec<String> = (0..100).map(|i| format!("field_{:045}", i)).collect();
let scope: Vec<&str> = fields.iter().map(|s| s.as_str()).collect();
let result = ash_hash_scope(&scope);
assert!(result.is_err());
assert!(result.unwrap_err().message().contains("Total scope length"));
}
#[test]
fn test_scope_within_total_length_limit() {
let fields: Vec<String> = (0..50).map(|i| format!("field_{:043}", i)).collect();
let scope: Vec<&str> = fields.iter().map(|s| s.as_str()).collect();
let result = ash_hash_scope(&scope);
assert!(result.is_ok());
}
}
use serde_json::{Map, Value};
use crate::canonicalize::ash_canonicalize_json_value;
pub const DEFAULT_MAX_TIMESTAMP_AGE_SECONDS: u64 = 300;
pub const DEFAULT_CLOCK_SKEW_SECONDS: u64 = 60;
pub fn ash_validate_timestamp_format(timestamp: &str) -> Result<u64, AshError> {
if timestamp.is_empty() {
return Err(AshError::new(
AshErrorCode::TimestampInvalid,
"Timestamp cannot be empty",
));
}
if !timestamp.chars().all(|c| c.is_ascii_digit()) {
return Err(AshError::new(
AshErrorCode::TimestampInvalid,
"Timestamp must contain only digits (0-9)",
));
}
if timestamp.len() > 1 && timestamp.starts_with('0') {
return Err(AshError::new(
AshErrorCode::TimestampInvalid,
"Timestamp must not have leading zeros",
));
}
let ts: u64 = timestamp.parse().map_err(|_| {
AshError::new(
AshErrorCode::TimestampInvalid,
"Timestamp must be a valid integer",
)
})?;
if ts > MAX_TIMESTAMP {
return Err(AshError::new(
AshErrorCode::TimestampInvalid,
"Timestamp exceeds maximum allowed value",
));
}
Ok(ts)
}
pub fn ash_validate_timestamp(
timestamp: &str,
max_age_seconds: u64,
clock_skew_seconds: u64,
) -> Result<(), AshError> {
use std::time::{SystemTime, UNIX_EPOCH};
let ts = ash_validate_timestamp_format(timestamp)?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| {
AshError::new(
AshErrorCode::InternalError,
"System time error",
)
})?
.as_secs();
if ts > now.saturating_add(clock_skew_seconds) {
return Err(AshError::new(
AshErrorCode::TimestampInvalid,
"Timestamp is in the future",
));
}
if now > ts && now - ts > max_age_seconds {
return Err(AshError::new(
AshErrorCode::TimestampInvalid,
"Timestamp has expired",
));
}
Ok(())
}
pub fn ash_extract_scoped_fields(payload: &Value, scope: &[&str]) -> Result<Value, AshError> {
ash_extract_scoped_fields_internal(payload, scope, false)
}
pub fn ash_extract_scoped_fields_strict(
payload: &Value,
scope: &[&str],
strict: bool,
) -> Result<Value, AshError> {
ash_extract_scoped_fields_internal(payload, scope, strict)
}
fn ash_extract_scoped_fields_internal(
payload: &Value,
scope: &[&str],
strict: bool,
) -> Result<Value, AshError> {
if scope.is_empty() {
return Ok(payload.clone());
}
if scope.len() > MAX_SCOPE_FIELDS {
return Err(AshError::new(
AshErrorCode::ValidationError,
format!("Scope exceeds maximum of {} fields", MAX_SCOPE_FIELDS),
));
}
let total_allocation = ash_calculate_total_array_allocation(scope);
if total_allocation > MAX_TOTAL_ARRAY_ALLOCATION {
return Err(AshError::new(
AshErrorCode::ValidationError,
format!(
"Scope array indices exceed maximum total allocation of {} elements",
MAX_TOTAL_ARRAY_ALLOCATION
),
));
}
let mut result = Map::new();
for field_path in scope {
let value = ash_get_nested_value(payload, field_path);
if let Some(v) = value {
ash_set_nested_value(&mut result, field_path, v);
} else if strict {
return Err(AshError::new(
AshErrorCode::ScopedFieldMissing,
format!("Required scoped field missing: {}", field_path),
));
}
}
Ok(Value::Object(result))
}
fn ash_calculate_total_array_allocation(scope: &[&str]) -> usize {
let mut total = 0usize;
for path in scope {
for part in path.split('.') {
let notation = ash_parse_all_array_indices(part);
for idx in ¬ation.indices {
total = total.saturating_add(idx.saturating_add(1));
}
}
}
total
}
fn ash_get_nested_value(payload: &Value, path: &str) -> Option<Value> {
ash_get_nested_value_with_depth(payload, path, 0)
}
fn ash_get_nested_value_with_depth(payload: &Value, path: &str, depth: usize) -> Option<Value> {
if depth > MAX_SCOPE_PATH_DEPTH {
return None;
}
let parts: Vec<&str> = path.split('.').collect();
if parts.len() > MAX_SCOPE_PATH_DEPTH {
return None;
}
let mut current = payload;
for part in parts {
let indices = ash_parse_all_array_indices(part);
match current {
Value::Object(map) => {
current = map.get(indices.key)?;
for idx in &indices.indices {
if *idx > MAX_ARRAY_INDEX {
return None;
}
if let Value::Array(arr) = current {
current = arr.get(*idx)?;
} else {
return None;
}
}
}
Value::Array(arr) => {
let idx: usize = indices.key.parse().ok()?;
if idx > MAX_ARRAY_INDEX {
return None;
}
current = arr.get(idx)?;
for idx in &indices.indices {
if *idx > MAX_ARRAY_INDEX {
return None;
}
if let Value::Array(arr) = current {
current = arr.get(*idx)?;
} else {
return None;
}
}
}
_ => return None,
}
}
Some(current.clone())
}
struct ArrayNotation<'a> {
key: &'a str,
indices: Vec<usize>,
}
fn ash_parse_all_array_indices(part: &str) -> ArrayNotation<'_> {
let bracket_start = match part.find('[') {
Some(pos) => pos,
None => return ArrayNotation { key: part, indices: vec![] },
};
let key = &part[..bracket_start];
let mut indices = Vec::new();
let mut remaining = &part[bracket_start..];
while remaining.starts_with('[') {
let bracket_end = match remaining.find(']') {
Some(pos) => pos,
None => break, };
let index_str = &remaining[1..bracket_end];
if index_str.is_empty() {
break; }
match index_str.parse::<usize>() {
Ok(idx) => indices.push(idx),
Err(_) => break, }
remaining = &remaining[bracket_end + 1..];
}
if !remaining.is_empty() {
return ArrayNotation { key, indices: vec![] };
}
ArrayNotation { key, indices }
}
fn ash_set_nested_value(result: &mut Map<String, Value>, path: &str, value: Value) {
ash_set_nested_value_with_depth(result, path, value, 0);
}
fn ash_set_nested_value_with_depth(result: &mut Map<String, Value>, path: &str, value: Value, depth: usize) {
if depth > MAX_SCOPE_PATH_DEPTH {
return;
}
let parts: Vec<&str> = path.split('.').collect();
if parts.len() > MAX_SCOPE_PATH_DEPTH {
return; }
if parts.len() == 1 {
let notation = ash_parse_all_array_indices(parts[0]);
if notation.indices.is_empty() {
result.insert(notation.key.to_string(), value);
} else {
ash_set_value_at_indices(result, notation.key, ¬ation.indices, value);
}
return;
}
let notation = ash_parse_all_array_indices(parts[0]);
let remaining_path = parts[1..].join(".");
if notation.indices.is_empty() {
let nested = result
.entry(notation.key.to_string())
.or_insert_with(|| Value::Object(Map::new()));
if let Value::Object(nested_map) = nested {
ash_set_nested_value_with_depth(nested_map, &remaining_path, value, depth + 1);
}
} else {
let target = ash_get_or_create_at_indices(result, notation.key, ¬ation.indices);
if let Some(Value::Object(nested_map)) = target {
ash_set_nested_value_with_depth(nested_map, &remaining_path, value, depth + 1);
}
}
}
fn ash_set_value_at_indices(result: &mut Map<String, Value>, key: &str, indices: &[usize], value: Value) {
if indices.is_empty() {
result.insert(key.to_string(), value);
return;
}
for idx in indices {
if *idx > MAX_ARRAY_INDEX {
return; }
}
let arr = result
.entry(key.to_string())
.or_insert_with(|| Value::Array(Vec::new()));
ash_set_value_in_nested_array(arr, indices, value);
}
fn ash_set_value_in_nested_array(current: &mut Value, indices: &[usize], value: Value) {
if indices.is_empty() {
*current = value;
return;
}
let idx = indices[0];
let remaining = &indices[1..];
if !current.is_array() {
*current = Value::Array(Vec::new());
}
if let Value::Array(arr) = current {
while arr.len() <= idx {
if remaining.is_empty() {
arr.push(Value::Null);
} else {
arr.push(Value::Array(Vec::new()));
}
}
if remaining.is_empty() {
arr[idx] = value;
} else {
ash_set_value_in_nested_array(&mut arr[idx], remaining, value);
}
}
}
fn ash_get_or_create_at_indices<'a>(
result: &'a mut Map<String, Value>,
key: &str,
indices: &[usize],
) -> Option<&'a mut Value> {
if indices.is_empty() {
return result.get_mut(key);
}
for idx in indices {
if *idx > MAX_ARRAY_INDEX {
return None;
}
}
let arr = result
.entry(key.to_string())
.or_insert_with(|| Value::Array(Vec::new()));
ash_navigate_to_nested_index(arr, indices)
}
fn ash_navigate_to_nested_index<'a>(current: &'a mut Value, indices: &[usize]) -> Option<&'a mut Value> {
if indices.is_empty() {
return Some(current);
}
let idx = indices[0];
let remaining = &indices[1..];
if !current.is_array() {
*current = Value::Array(Vec::new());
}
if let Value::Array(arr) = current {
while arr.len() <= idx {
if remaining.is_empty() {
arr.push(Value::Object(Map::new()));
} else {
arr.push(Value::Array(Vec::new()));
}
}
if remaining.is_empty() {
if !arr[idx].is_object() {
arr[idx] = Value::Object(Map::new());
}
Some(&mut arr[idx])
} else {
ash_navigate_to_nested_index(&mut arr[idx], remaining)
}
} else {
None
}
}
pub fn ash_build_proof_scoped(
client_secret: &str,
timestamp: &str,
binding: &str,
payload: &str,
scope: &[&str],
) -> Result<(String, String), AshError> {
if client_secret.is_empty() {
return Err(AshError::new(
AshErrorCode::ValidationError,
"client_secret cannot be empty",
));
}
if timestamp.is_empty() {
return Err(AshError::new(
AshErrorCode::ValidationError,
"timestamp cannot be empty",
));
}
if binding.is_empty() {
return Err(AshError::new(
AshErrorCode::ValidationError,
"binding cannot be empty",
));
}
let json_payload: Value = if payload.is_empty() || payload.trim().is_empty() {
Value::Object(serde_json::Map::new())
} else {
serde_json::from_str(payload)
.map_err(|_e| AshError::canonicalization_error())?
};
let scoped_payload = ash_extract_scoped_fields(&json_payload, scope)?;
let canonical_scoped = ash_canonicalize_json_value(&scoped_payload)?;
let body_hash = ash_hash_body(&canonical_scoped);
let scope_hash = ash_hash_scope(scope)?;
let message = format!("{}|{}|{}|{}", timestamp, binding, body_hash, scope_hash);
let mut mac = HmacSha256Type::new_from_slice(client_secret.as_bytes())
.expect("HMAC can take key of any size");
mac.update(message.as_bytes());
let proof = hex::encode(mac.finalize().into_bytes());
Ok((proof, scope_hash))
}
#[allow(clippy::too_many_arguments)]
pub fn ash_verify_proof_scoped(
nonce: &str,
context_id: &str,
binding: &str,
timestamp: &str,
payload: &str,
scope: &[&str],
scope_hash: &str,
client_proof: &str,
) -> Result<bool, AshError> {
ash_validate_timestamp_format(timestamp)?;
if scope.is_empty() && !scope_hash.is_empty() {
return Err(AshError::new(
AshErrorCode::ScopeMismatch,
"scope_hash must be empty when scope is empty",
));
}
let expected_scope_hash = ash_hash_scope(scope)?;
if !ash_timing_safe_equal(expected_scope_hash.as_bytes(), scope_hash.as_bytes()) {
return Ok(false);
}
let client_secret = ash_derive_client_secret(nonce, context_id, binding)?;
let (expected_proof, _) =
ash_build_proof_scoped(&client_secret, timestamp, binding, payload, scope)?;
Ok(ash_timing_safe_equal(
expected_proof.as_bytes(),
client_proof.as_bytes(),
))
}
pub fn ash_hash_scoped_body(payload: &str, scope: &[&str]) -> Result<String, AshError> {
ash_hash_scoped_body_internal(payload, scope, false)
}
pub fn ash_hash_scoped_body_strict(payload: &str, scope: &[&str]) -> Result<String, AshError> {
ash_hash_scoped_body_internal(payload, scope, true)
}
fn ash_hash_scoped_body_internal(payload: &str, scope: &[&str], strict: bool) -> Result<String, AshError> {
let json_payload: Value = if payload.is_empty() || payload.trim().is_empty() {
Value::Object(serde_json::Map::new())
} else {
serde_json::from_str(payload)
.map_err(|_e| AshError::canonicalization_error())?
};
let scoped_payload = ash_extract_scoped_fields_internal(&json_payload, scope, strict)?;
let canonical_scoped = ash_canonicalize_json_value(&scoped_payload)?;
Ok(ash_hash_body(&canonical_scoped))
}
#[cfg(test)]
mod tests_scoping {
use super::*;
const TEST_NONCE: &str = "0123456789abcdef0123456789abcdef";
#[test]
fn test_build_verify_scoped_proof() {
let nonce = TEST_NONCE;
let context_id = "ctx_abc123";
let binding = "POST /transfer";
let timestamp = "1234567890";
let payload = r#"{"amount":1000,"recipient":"user1","notes":"hi"}"#;
let scope = vec!["amount", "recipient"];
let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
let (proof, scope_hash) =
ash_build_proof_scoped(&client_secret, timestamp, binding, payload, &scope).unwrap();
let is_valid = ash_verify_proof_scoped(
nonce,
context_id,
binding,
timestamp,
payload,
&scope,
&scope_hash,
&proof,
)
.unwrap();
assert!(is_valid);
}
#[test]
fn test_scoped_proof_ignores_unscoped_changes() {
let nonce = TEST_NONCE;
let context_id = "ctx_abc123";
let binding = "POST /transfer";
let timestamp = "1234567890";
let scope = vec!["amount", "recipient"];
let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
let payload1 = r#"{"amount":1000,"recipient":"user1","notes":"hello"}"#;
let (proof, scope_hash) =
ash_build_proof_scoped(&client_secret, timestamp, binding, payload1, &scope).unwrap();
let payload2 = r#"{"amount":1000,"recipient":"user1","notes":"world"}"#;
let is_valid = ash_verify_proof_scoped(
nonce,
context_id,
binding,
timestamp,
payload2,
&scope,
&scope_hash,
&proof,
)
.unwrap();
assert!(is_valid);
}
#[test]
fn test_scoped_proof_detects_scoped_changes() {
let nonce = TEST_NONCE;
let context_id = "ctx_abc123";
let binding = "POST /transfer";
let timestamp = "1234567890";
let scope = vec!["amount", "recipient"];
let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
let payload1 = r#"{"amount":1000,"recipient":"user1","notes":"hello"}"#;
let (proof, scope_hash) =
ash_build_proof_scoped(&client_secret, timestamp, binding, payload1, &scope).unwrap();
let payload2 = r#"{"amount":9999,"recipient":"user1","notes":"hello"}"#;
let is_valid = ash_verify_proof_scoped(
nonce,
context_id,
binding,
timestamp,
payload2,
&scope,
&scope_hash,
&proof,
)
.unwrap();
assert!(!is_valid);
}
#[test]
fn test_extract_scoped_fields_with_array_index() {
let payload: Value = serde_json::from_str(
r#"{"items":[{"id":1,"name":"a"},{"id":2,"name":"b"}],"total":100}"#
).unwrap();
let scope = vec!["items[0]"];
let scoped = ash_extract_scoped_fields(&payload, &scope).unwrap();
assert!(scoped.is_object());
let items = scoped.get("items").expect("should have items key");
assert!(items.is_array(), "items should be an array, got: {:?}", items);
let arr = items.as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["id"], 1);
}
#[test]
fn test_extract_scoped_fields_with_nested_array_path() {
let payload: Value = serde_json::from_str(
r#"{"items":[{"id":1,"name":"a"},{"id":2,"name":"b"}]}"#
).unwrap();
let scope = vec!["items[0].id"];
let scoped = ash_extract_scoped_fields(&payload, &scope).unwrap();
assert!(scoped.is_object());
let items = scoped.get("items").expect("should have items key");
assert!(items.is_array(), "items should be an array");
let arr = items.as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["id"], 1);
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct UnifiedProofResult {
pub proof: String,
pub scope_hash: String,
pub chain_hash: String,
}
pub fn ash_hash_proof(proof: &str) -> Result<String, AshError> {
if proof.is_empty() {
return Err(AshError::new(
AshErrorCode::ValidationError,
"proof cannot be empty for chain hashing",
));
}
let mut hasher = Sha256::new();
hasher.update(proof.as_bytes());
Ok(hex::encode(hasher.finalize()))
}
pub fn ash_build_proof_unified(
client_secret: &str,
timestamp: &str,
binding: &str,
payload: &str,
scope: &[&str],
previous_proof: Option<&str>,
) -> Result<UnifiedProofResult, AshError> {
if client_secret.is_empty() {
return Err(AshError::new(
AshErrorCode::ValidationError,
"client_secret cannot be empty",
));
}
if timestamp.is_empty() {
return Err(AshError::new(
AshErrorCode::ValidationError,
"timestamp cannot be empty",
));
}
if binding.is_empty() {
return Err(AshError::new(
AshErrorCode::ValidationError,
"binding cannot be empty",
));
}
let json_payload: Value = if payload.is_empty() || payload.trim().is_empty() {
Value::Object(serde_json::Map::new())
} else {
serde_json::from_str(payload)
.map_err(|_e| AshError::canonicalization_error())?
};
let scoped_payload = ash_extract_scoped_fields(&json_payload, scope)?;
let canonical_scoped = ash_canonicalize_json_value(&scoped_payload)?;
let body_hash = ash_hash_body(&canonical_scoped);
let scope_hash = ash_hash_scope(scope)?;
let chain_hash = match previous_proof {
Some(prev) if !prev.is_empty() => ash_hash_proof(prev)?,
_ => String::new(),
};
let message = format!(
"{}|{}|{}|{}|{}",
timestamp, binding, body_hash, scope_hash, chain_hash
);
let mut mac = HmacSha256Type::new_from_slice(client_secret.as_bytes())
.expect("HMAC can take key of any size");
mac.update(message.as_bytes());
let proof = hex::encode(mac.finalize().into_bytes());
Ok(UnifiedProofResult {
proof,
scope_hash,
chain_hash,
})
}
#[allow(clippy::too_many_arguments)]
pub fn ash_verify_proof_unified(
nonce: &str,
context_id: &str,
binding: &str,
timestamp: &str,
payload: &str,
client_proof: &str,
scope: &[&str],
scope_hash: &str,
previous_proof: Option<&str>,
chain_hash: &str,
) -> Result<bool, AshError> {
ash_validate_timestamp_format(timestamp)?;
if scope.is_empty() && !scope_hash.is_empty() {
return Err(AshError::new(
AshErrorCode::ScopeMismatch,
"scope_hash must be empty when scope is empty",
));
}
if !scope.is_empty() {
let expected_scope_hash = ash_hash_scope(scope)?;
if !ash_timing_safe_equal(expected_scope_hash.as_bytes(), scope_hash.as_bytes()) {
return Ok(false);
}
}
let has_previous = previous_proof.is_some_and(|p| !p.is_empty());
if !has_previous && !chain_hash.is_empty() {
return Err(AshError::new(
AshErrorCode::ChainBroken,
"chain_hash must be empty when previous_proof is absent",
));
}
if let Some(prev) = previous_proof {
if !prev.is_empty() {
let expected_chain_hash = ash_hash_proof(prev)?;
if !ash_timing_safe_equal(expected_chain_hash.as_bytes(), chain_hash.as_bytes()) {
return Ok(false);
}
}
}
let client_secret = ash_derive_client_secret(nonce, context_id, binding)?;
let result = ash_build_proof_unified(
&client_secret,
timestamp,
binding,
payload,
scope,
previous_proof,
)?;
Ok(ash_timing_safe_equal(
result.proof.as_bytes(),
client_proof.as_bytes(),
))
}
#[cfg(test)]
mod tests_unified {
use super::*;
const TEST_NONCE: &str = "0123456789abcdef0123456789abcdef";
#[test]
fn test_unified_basic() {
let nonce = TEST_NONCE;
let context_id = "ctx_abc123";
let binding = "POST /api/test";
let timestamp = "1234567890";
let payload = r#"{"name":"John","age":30}"#;
let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
let result = ash_build_proof_unified(
&client_secret,
timestamp,
binding,
payload,
&[], None, )
.unwrap();
assert!(!result.proof.is_empty());
assert!(result.scope_hash.is_empty());
assert!(result.chain_hash.is_empty());
let is_valid = ash_verify_proof_unified(
nonce,
context_id,
binding,
timestamp,
payload,
&result.proof,
&[],
"",
None,
"",
)
.unwrap();
assert!(is_valid);
}
#[test]
fn test_unified_scoped_only() {
let nonce = TEST_NONCE;
let context_id = "ctx_abc123";
let binding = "POST /transfer";
let timestamp = "1234567890";
let payload = r#"{"amount":1000,"recipient":"user1","notes":"hi"}"#;
let scope = vec!["amount", "recipient"];
let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
let result = ash_build_proof_unified(
&client_secret,
timestamp,
binding,
payload,
&scope,
None, )
.unwrap();
assert!(!result.proof.is_empty());
assert!(!result.scope_hash.is_empty());
assert!(result.chain_hash.is_empty());
let is_valid = ash_verify_proof_unified(
nonce,
context_id,
binding,
timestamp,
payload,
&result.proof,
&scope,
&result.scope_hash,
None,
"",
)
.unwrap();
assert!(is_valid);
}
#[test]
fn test_unified_chained_only() {
let nonce = TEST_NONCE;
let context_id = "ctx_abc123";
let binding = "POST /checkout";
let timestamp = "1234567890";
let payload = r#"{"cart_id":"cart_123"}"#;
let previous_proof = "abc123def456";
let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
let result = ash_build_proof_unified(
&client_secret,
timestamp,
binding,
payload,
&[], Some(previous_proof),
)
.unwrap();
assert!(!result.proof.is_empty());
assert!(result.scope_hash.is_empty());
assert!(!result.chain_hash.is_empty());
let is_valid = ash_verify_proof_unified(
nonce,
context_id,
binding,
timestamp,
payload,
&result.proof,
&[],
"",
Some(previous_proof),
&result.chain_hash,
)
.unwrap();
assert!(is_valid);
}
#[test]
fn test_unified_full() {
let nonce = TEST_NONCE;
let context_id = "ctx_abc123";
let binding = "POST /payment";
let timestamp = "1234567890";
let payload = r#"{"amount":500,"currency":"USD","notes":"tip"}"#;
let scope = vec!["amount", "currency"];
let previous_proof = "checkout_proof_xyz";
let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
let result = ash_build_proof_unified(
&client_secret,
timestamp,
binding,
payload,
&scope,
Some(previous_proof),
)
.unwrap();
assert!(!result.proof.is_empty());
assert!(!result.scope_hash.is_empty());
assert!(!result.chain_hash.is_empty());
let is_valid = ash_verify_proof_unified(
nonce,
context_id,
binding,
timestamp,
payload,
&result.proof,
&scope,
&result.scope_hash,
Some(previous_proof),
&result.chain_hash,
)
.unwrap();
assert!(is_valid);
}
#[test]
fn test_unified_chain_broken() {
let nonce = TEST_NONCE;
let context_id = "ctx_abc123";
let binding = "POST /payment";
let timestamp = "1234567890";
let payload = r#"{"amount":500}"#;
let previous_proof = "original_proof";
let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
let result = ash_build_proof_unified(
&client_secret,
timestamp,
binding,
payload,
&[],
Some(previous_proof),
)
.unwrap();
let is_valid = ash_verify_proof_unified(
nonce,
context_id,
binding,
timestamp,
payload,
&result.proof,
&[],
"",
Some("tampered_proof"), &result.chain_hash,
)
.unwrap();
assert!(!is_valid);
}
#[test]
fn test_unified_scope_tampered() {
let nonce = TEST_NONCE;
let context_id = "ctx_abc123";
let binding = "POST /transfer";
let timestamp = "1234567890";
let payload = r#"{"amount":1000,"recipient":"user1"}"#;
let scope = vec!["amount"];
let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
let result =
ash_build_proof_unified(&client_secret, timestamp, binding, payload, &scope, None)
.unwrap();
let tampered_scope = vec!["recipient"];
let is_valid = ash_verify_proof_unified(
nonce,
context_id,
binding,
timestamp,
payload,
&result.proof,
&tampered_scope, &result.scope_hash, None,
"",
)
.unwrap();
assert!(!is_valid);
}
#[test]
fn test_ash_hash_proof() {
let proof = "test_proof_123";
let hash1 = ash_hash_proof(proof).unwrap();
let hash2 = ash_hash_proof(proof).unwrap();
assert_eq!(hash1, hash2);
assert_eq!(hash1.len(), 64); }
#[test]
fn test_ash_hash_proof_rejects_empty() {
let result = ash_hash_proof("");
assert!(result.is_err());
assert!(result.unwrap_err().message().contains("empty"));
}
#[test]
fn test_unified_rejects_scope_hash_when_scope_empty() {
let nonce = TEST_NONCE;
let context_id = "ctx_abc123";
let binding = "POST /api/test";
let timestamp = "1234567890";
let payload = r#"{"name":"John"}"#;
let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
let result = ash_build_proof_unified(
&client_secret,
timestamp,
binding,
payload,
&[], None,
)
.unwrap();
let verify_result = ash_verify_proof_unified(
nonce,
context_id,
binding,
timestamp,
payload,
&result.proof,
&[], "fake_scope_hash", None,
"",
);
assert!(verify_result.is_err());
assert_eq!(verify_result.unwrap_err().code(), crate::AshErrorCode::ScopeMismatch);
}
#[test]
fn test_unified_rejects_chain_hash_when_no_previous_proof() {
let nonce = TEST_NONCE;
let context_id = "ctx_abc123";
let binding = "POST /api/test";
let timestamp = "1234567890";
let payload = r#"{"name":"John"}"#;
let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
let result = ash_build_proof_unified(
&client_secret,
timestamp,
binding,
payload,
&[],
None, )
.unwrap();
let verify_result = ash_verify_proof_unified(
nonce,
context_id,
binding,
timestamp,
payload,
&result.proof,
&[],
"",
None, "fake_chain_hash", );
assert!(verify_result.is_err());
assert_eq!(verify_result.unwrap_err().code(), crate::AshErrorCode::ChainBroken);
}
}
#[cfg(test)]
mod tests_sec011 {
use super::*;
#[test]
fn test_large_array_index_rejected() {
let payload: Value = serde_json::from_str(
r#"{"items":[{"id":1}]}"#
).unwrap();
let scope = vec!["items[999999]"];
let result = ash_extract_scoped_fields(&payload, &scope);
assert!(result.is_err());
assert!(result.unwrap_err().message().contains("allocation"));
}
#[test]
fn test_valid_array_index_works() {
let payload: Value = serde_json::from_str(
r#"{"items":[{"id":1},{"id":2},{"id":3}]}"#
).unwrap();
let scope = vec!["items[1]"];
let scoped = ash_extract_scoped_fields(&payload, &scope).unwrap();
assert!(scoped.is_object());
let items = scoped.get("items").expect("should have items");
let arr = items.as_array().unwrap();
assert_eq!(arr.len(), 2); assert_eq!(arr[1]["id"], 2);
}
#[test]
fn test_moderate_array_index_within_limit() {
let payload: Value = serde_json::from_str(
r#"{"items":[{"id":1}]}"#
).unwrap();
let scope = vec!["items[99]"];
let result = ash_extract_scoped_fields(&payload, &scope);
assert!(result.is_ok());
}
}
#[cfg(test)]
mod tests_sec018 {
use super::*;
#[test]
fn test_rejects_unreasonably_large_timestamp() {
let huge_timestamp = "99999999999999999"; let result = ash_validate_timestamp(huge_timestamp, 300, 60);
assert!(result.is_err());
assert!(result.unwrap_err().message().contains("maximum"));
}
#[test]
fn test_accepts_normal_timestamp() {
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let result = ash_validate_timestamp(&now.to_string(), 300, 60);
assert!(result.is_ok());
}
}
#[cfg(test)]
mod tests_sec019 {
use super::*;
#[test]
fn test_deep_scope_path_ignored() {
let payload: Value = serde_json::json!({"a": {"b": {"c": 1}}});
let deep_path = (0..35).map(|_| "x").collect::<Vec<_>>().join(".");
let scope = vec![deep_path.as_str()];
let scoped = ash_extract_scoped_fields(&payload, &scope).unwrap();
assert!(scoped.is_object());
assert!(scoped.as_object().unwrap().is_empty());
}
#[test]
fn test_normal_depth_path_works() {
let payload: Value = serde_json::json!({"a": {"b": {"c": 1}}});
let scope = vec!["a.b.c"];
let scoped = ash_extract_scoped_fields(&payload, &scope).unwrap();
assert!(scoped.is_object());
let c_value = scoped.get("a")
.and_then(|a| a.get("b"))
.and_then(|b| b.get("c"));
assert_eq!(c_value, Some(&serde_json::json!(1)));
}
}
#[cfg(test)]
mod tests_bug022 {
use super::*;
#[test]
fn test_multi_dimensional_array_get() {
let payload: Value = serde_json::json!({
"matrix": [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
});
let scope = vec!["matrix[1][2]"];
let scoped = ash_extract_scoped_fields(&payload, &scope).unwrap();
assert!(scoped.is_object());
let matrix = scoped.get("matrix").expect("should have matrix");
let arr = matrix.as_array().unwrap();
assert_eq!(arr.len(), 2); let inner = arr[1].as_array().unwrap();
assert_eq!(inner.len(), 3); assert_eq!(inner[2], 6);
}
#[test]
fn test_multi_dimensional_array_nested_object() {
let payload: Value = serde_json::json!({
"items": [
[{"id": 1, "name": "a"}, {"id": 2, "name": "b"}],
[{"id": 3, "name": "c"}, {"id": 4, "name": "d"}]
]
});
let scope = vec!["items[1][0].name"];
let scoped = ash_extract_scoped_fields(&payload, &scope).unwrap();
let items = scoped.get("items").expect("should have items");
let outer = items.as_array().unwrap();
assert_eq!(outer.len(), 2);
let inner = outer[1].as_array().unwrap();
assert_eq!(inner.len(), 1);
let obj = inner[0].as_object().unwrap();
assert_eq!(obj.get("name").unwrap(), "c");
}
#[test]
fn test_ash_parse_all_array_indices() {
let notation = ash_parse_all_array_indices("items[0][1][2]");
assert_eq!(notation.key, "items");
assert_eq!(notation.indices, vec![0, 1, 2]);
let notation2 = ash_parse_all_array_indices("simple");
assert_eq!(notation2.key, "simple");
assert!(notation2.indices.is_empty());
let notation3 = ash_parse_all_array_indices("arr[5]");
assert_eq!(notation3.key, "arr");
assert_eq!(notation3.indices, vec![5]);
}
#[test]
fn test_multi_dimensional_invalid_index() {
let notation = ash_parse_all_array_indices("items[0][abc][2]");
assert_eq!(notation.key, "items");
assert!(notation.indices.is_empty());
}
#[test]
fn test_multi_dimensional_trailing_text() {
let notation = ash_parse_all_array_indices("items[0][1]extra");
assert_eq!(notation.key, "items");
assert!(notation.indices.is_empty());
}
}
#[cfg(test)]
mod tests_bug023 {
use super::*;
const TEST_NONCE: &str = "0123456789abcdef0123456789abcdef";
#[test]
fn test_scope_order_independent() {
let scope1 = vec!["amount", "recipient"];
let scope2 = vec!["recipient", "amount"];
let hash1 = ash_hash_scope(&scope1).unwrap();
let hash2 = ash_hash_scope(&scope2).unwrap();
assert_eq!(hash1, hash2, "Scope order should not affect hash");
}
#[test]
fn test_scope_deduplication() {
let scope1 = vec!["amount", "amount", "recipient"];
let scope2 = vec!["amount", "recipient"];
let hash1 = ash_hash_scope(&scope1).unwrap();
let hash2 = ash_hash_scope(&scope2).unwrap();
assert_eq!(hash1, hash2, "Duplicate fields should be deduplicated");
}
#[test]
fn test_scope_rejects_delimiter_in_field_name() {
let scope_with_delimiter = vec!["amount", "field\x1Fname"];
let result = ash_hash_scope(&scope_with_delimiter);
assert!(result.is_err(), "Should reject field names containing delimiter");
assert!(result.unwrap_err().message().contains("delimiter"));
}
#[test]
fn test_scoped_proof_order_independent() {
let nonce = TEST_NONCE;
let context_id = "ctx_abc123";
let binding = "POST /transfer";
let timestamp = "1234567890";
let payload = r#"{"amount":1000,"recipient":"user1","notes":"hi"}"#;
let client_scope = vec!["recipient", "amount"];
let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
let (proof, scope_hash) =
ash_build_proof_scoped(&client_secret, timestamp, binding, payload, &client_scope).unwrap();
let server_scope = vec!["amount", "recipient"];
let is_valid = ash_verify_proof_scoped(
nonce,
context_id,
binding,
timestamp,
payload,
&server_scope, &scope_hash,
&proof,
)
.unwrap();
assert!(is_valid, "Verification should succeed regardless of scope order");
}
}
#[cfg(test)]
mod tests_bug036 {
use super::*;
#[test]
fn test_rejects_excessive_array_allocation() {
let payload: Value = serde_json::json!({});
let scope = vec![
"items[9999]",
"other[9999]",
];
let result = ash_extract_scoped_fields(&payload, &scope);
assert!(result.is_err());
assert!(result.unwrap_err().message().contains("allocation"));
}
#[test]
fn test_accepts_reasonable_array_allocation() {
let payload: Value = serde_json::json!({
"items": [{"id": 1}, {"id": 2}, {"id": 3}]
});
let scope = vec!["items[0]", "items[1]", "items[2]"];
let result = ash_extract_scoped_fields(&payload, &scope);
assert!(result.is_ok());
}
#[test]
fn test_allocation_calculation() {
let scope = vec!["items[10]", "matrix[5][5]"];
let total = ash_calculate_total_array_allocation(&scope);
assert_eq!(total, 23);
}
#[test]
fn test_allocation_calculation_overflow_protection() {
let scope = vec!["items[18446744073709551615]"];
let total = ash_calculate_total_array_allocation(&scope);
assert_eq!(total, usize::MAX);
}
}
#[cfg(test)]
mod tests_bug039 {
use super::*;
#[test]
fn test_rejects_empty_scope_field_name() {
let scope = vec!["amount", ""];
let result = ash_hash_scope(&scope);
assert!(result.is_err());
assert!(result.unwrap_err().message().contains("empty"));
}
#[test]
fn test_rejects_only_empty_scope_field() {
let scope = vec![""];
let result = ash_hash_scope(&scope);
assert!(result.is_err());
}
#[test]
fn test_accepts_valid_scope_fields() {
let scope = vec!["amount", "recipient"];
let result = ash_hash_scope(&scope);
assert!(result.is_ok());
}
}
#[cfg(test)]
mod tests_bug024 {
use super::*;
const TEST_NONCE: &str = "0123456789abcdef0123456789abcdef";
#[test]
fn test_empty_payload_scoped() {
let client_secret = "test_secret";
let timestamp = "1234567890";
let binding = "POST /api/test";
let result1 = ash_build_proof_scoped(client_secret, timestamp, binding, "", &[]);
assert!(result1.is_ok(), "Empty string payload should work");
let result2 = ash_build_proof_scoped(client_secret, timestamp, binding, " ", &[]);
assert!(result2.is_ok(), "Whitespace-only payload should work");
}
#[test]
fn test_empty_payload_unified() {
let client_secret = "test_secret";
let timestamp = "1234567890";
let binding = "POST /api/test";
let result = ash_build_proof_unified(
client_secret,
timestamp,
binding,
"",
&[],
None,
);
assert!(result.is_ok(), "Empty string payload should work");
}
#[test]
fn test_empty_payload_hash_scoped_body() {
let result = ash_hash_scoped_body("", &[]);
assert!(result.is_ok(), "Empty payload should work");
let result2 = ash_hash_scoped_body(" ", &[]);
assert!(result2.is_ok(), "Whitespace payload should work");
}
#[test]
fn test_empty_payload_produces_consistent_hash() {
let hash1 = ash_hash_scoped_body("", &[]).unwrap();
let hash2 = ash_hash_scoped_body("{}", &[]).unwrap();
assert_eq!(hash1, hash2, "Empty string and {{}} should produce same hash");
}
#[test]
fn test_empty_payload_verification() {
let nonce = TEST_NONCE;
let context_id = "ctx_abc123";
let binding = "POST /api/test";
let timestamp = "1234567890";
let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
let result = ash_build_proof_unified(
&client_secret,
timestamp,
binding,
"",
&[],
None,
).unwrap();
let is_valid = ash_verify_proof_unified(
nonce,
context_id,
binding,
timestamp,
"",
&result.proof,
&[],
"",
None,
"",
).unwrap();
assert!(is_valid);
}
}
#[cfg(test)]
mod tests_bug046_047 {
use super::*;
#[test]
fn test_build_proof_scoped_rejects_empty_client_secret() {
let result = ash_build_proof_scoped("", "1234567890", "POST|/api|", "{}", &[]);
assert!(result.is_err());
assert!(result.unwrap_err().message().contains("client_secret"));
}
#[test]
fn test_build_proof_scoped_rejects_empty_timestamp() {
let result = ash_build_proof_scoped("secret", "", "POST|/api|", "{}", &[]);
assert!(result.is_err());
assert!(result.unwrap_err().message().contains("timestamp"));
}
#[test]
fn test_build_proof_scoped_rejects_empty_binding() {
let result = ash_build_proof_scoped("secret", "1234567890", "", "{}", &[]);
assert!(result.is_err());
assert!(result.unwrap_err().message().contains("binding"));
}
#[test]
fn test_build_proof_unified_rejects_empty_client_secret() {
let result = ash_build_proof_unified("", "1234567890", "POST|/api|", "{}", &[], None);
assert!(result.is_err());
assert!(result.unwrap_err().message().contains("client_secret"));
}
#[test]
fn test_build_proof_unified_rejects_empty_timestamp() {
let result = ash_build_proof_unified("secret", "", "POST|/api|", "{}", &[], None);
assert!(result.is_err());
assert!(result.unwrap_err().message().contains("timestamp"));
}
#[test]
fn test_build_proof_unified_rejects_empty_binding() {
let result = ash_build_proof_unified("secret", "1234567890", "", "{}", &[], None);
assert!(result.is_err());
assert!(result.unwrap_err().message().contains("binding"));
}
}
#[cfg(test)]
mod tests_bug049 {
use super::*;
const TEST_NONCE: &str = "0123456789abcdef0123456789abcdef";
#[test]
fn test_verify_proof_scoped_rejects_scope_hash_when_scope_empty() {
let nonce = TEST_NONCE;
let context_id = "ctx_abc123";
let binding = "POST /api/test";
let timestamp = "1234567890";
let payload = r#"{"name":"John"}"#;
let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
let (proof, _) = ash_build_proof_scoped(
&client_secret,
timestamp,
binding,
payload,
&[], ).unwrap();
let verify_result = ash_verify_proof_scoped(
nonce,
context_id,
binding,
timestamp,
payload,
&[], "fake_scope_hash", &proof,
);
assert!(verify_result.is_err());
assert_eq!(verify_result.unwrap_err().code(), crate::AshErrorCode::ScopeMismatch);
}
#[test]
fn test_verify_proof_scoped_accepts_valid_empty_scope() {
let nonce = TEST_NONCE;
let context_id = "ctx_abc123";
let binding = "POST /api/test";
let timestamp = "1234567890";
let payload = r#"{"name":"John"}"#;
let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
let (proof, scope_hash) = ash_build_proof_scoped(
&client_secret,
timestamp,
binding,
payload,
&[], ).unwrap();
assert!(scope_hash.is_empty(), "scope_hash should be empty for empty scope");
let verify_result = ash_verify_proof_scoped(
nonce,
context_id,
binding,
timestamp,
payload,
&[],
"",
&proof,
);
assert!(verify_result.is_ok());
assert!(verify_result.unwrap());
}
}
#[cfg(test)]
mod tests_security_audit {
use super::*;
const TEST_NONCE: &str = "0123456789abcdef0123456789abcdef";
const TEST_BODY_HASH: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
#[test]
fn test_sec_audit_004_binding_length_limit_derive() {
let long_binding = "a".repeat(8193); let result = ash_derive_client_secret(TEST_NONCE, "ctx_abc", &long_binding);
assert!(result.is_err());
assert!(result.unwrap_err().message().contains("maximum length"));
}
#[test]
fn test_sec_audit_004_binding_length_limit_build() {
let long_binding = "a".repeat(8193); let result = ash_build_proof("secret", "1234567890", &long_binding, TEST_BODY_HASH);
assert!(result.is_err());
assert!(result.unwrap_err().message().contains("maximum length"));
}
#[test]
fn test_sec_audit_004_binding_at_limit_ok() {
let long_binding = "a".repeat(8192);
let result = ash_build_proof("secret", "1234567890", &long_binding, TEST_BODY_HASH);
assert!(result.is_ok());
}
#[test]
fn test_sec_audit_002_verify_with_freshness() {
use std::time::{SystemTime, UNIX_EPOCH};
let nonce = TEST_NONCE;
let context_id = "ctx_abc123";
let binding = "POST|/api/test|";
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let timestamp = now.to_string();
let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
let proof = ash_build_proof(&client_secret, ×tamp, binding, TEST_BODY_HASH).unwrap();
let result = ash_verify_proof_with_freshness(
nonce, context_id, binding, ×tamp, TEST_BODY_HASH, &proof,
300, 60
);
assert!(result.is_ok());
assert!(result.unwrap());
}
#[test]
fn test_sec_audit_002_verify_with_freshness_rejects_expired() {
use std::time::{SystemTime, UNIX_EPOCH};
let nonce = TEST_NONCE;
let context_id = "ctx_abc123";
let binding = "POST|/api/test|";
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let old_timestamp = (now - 600).to_string();
let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
let proof = ash_build_proof(&client_secret, &old_timestamp, binding, TEST_BODY_HASH).unwrap();
let result = ash_verify_proof_with_freshness(
nonce, context_id, binding, &old_timestamp, TEST_BODY_HASH, &proof,
300, 60 );
assert!(result.is_err());
assert!(result.unwrap_err().message().contains("expired"));
}
#[test]
fn test_sec_audit_003_generic_error_message() {
let field_with_delimiter = format!("secret_field{}name", SCOPE_FIELD_DELIMITER);
let result = ash_hash_scope(&[&field_with_delimiter]);
assert!(result.is_err());
let error_msg = result.unwrap_err().message().to_string();
assert!(!error_msg.contains("secret_field")); assert!(!error_msg.contains("name")); assert!(error_msg.contains("delimiter")); }
}