clawdentity_core/identity/
signing.rs1use base64::Engine;
2use base64::engine::general_purpose::URL_SAFE_NO_PAD;
3use ed25519_dalek::{Signer, SigningKey};
4use sha2::{Digest, Sha256};
5
6use crate::error::{CoreError, Result};
7
8pub const CANONICAL_REQUEST_VERSION: &str = "CLAW-PROOF-V1";
9pub const X_CLAW_TIMESTAMP: &str = "X-Claw-Timestamp";
10pub const X_CLAW_NONCE: &str = "X-Claw-Nonce";
11pub const X_CLAW_BODY_SHA256: &str = "X-Claw-Body-SHA256";
12pub const X_CLAW_PROOF: &str = "X-Claw-Proof";
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct SignHttpRequestInput<'a> {
16 pub method: &'a str,
17 pub path_with_query: &'a str,
18 pub timestamp: &'a str,
19 pub nonce: &'a str,
20 pub body: &'a [u8],
21 pub secret_key: &'a SigningKey,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct SignedRequest {
26 pub canonical_request: String,
27 pub proof: String,
28 pub body_hash: String,
29 pub headers: Vec<(String, String)>,
30}
31
32pub fn canonicalize_request(input: &SignHttpRequestInput<'_>, body_hash: &str) -> String {
34 [
35 CANONICAL_REQUEST_VERSION,
36 &input.method.to_uppercase(),
37 input.path_with_query,
38 input.timestamp,
39 input.nonce,
40 body_hash,
41 ]
42 .join("\n")
43}
44
45pub fn hash_body_sha256_base64url(body: &[u8]) -> String {
47 let digest = Sha256::digest(body);
48 URL_SAFE_NO_PAD.encode(digest)
49}
50
51pub fn sign_http_request(input: &SignHttpRequestInput<'_>) -> Result<SignedRequest> {
53 if input.method.trim().is_empty() {
54 return Err(CoreError::InvalidInput("method is required".to_string()));
55 }
56 if input.path_with_query.trim().is_empty() {
57 return Err(CoreError::InvalidInput(
58 "pathWithQuery is required".to_string(),
59 ));
60 }
61 if input.timestamp.trim().is_empty() {
62 return Err(CoreError::InvalidInput("timestamp is required".to_string()));
63 }
64 if input.nonce.trim().is_empty() {
65 return Err(CoreError::InvalidInput("nonce is required".to_string()));
66 }
67
68 let body_hash = hash_body_sha256_base64url(input.body);
69 let canonical_request = canonicalize_request(input, &body_hash);
70 let signature = input.secret_key.sign(canonical_request.as_bytes());
71 let proof = URL_SAFE_NO_PAD.encode(signature.to_bytes());
72
73 Ok(SignedRequest {
74 canonical_request,
75 proof: proof.clone(),
76 body_hash: body_hash.clone(),
77 headers: vec![
78 (X_CLAW_TIMESTAMP.to_string(), input.timestamp.to_string()),
79 (X_CLAW_NONCE.to_string(), input.nonce.to_string()),
80 (X_CLAW_BODY_SHA256.to_string(), body_hash),
81 (X_CLAW_PROOF.to_string(), proof),
82 ],
83 })
84}
85
86#[cfg(test)]
87mod tests {
88 use ed25519_dalek::SigningKey;
89
90 use super::{SignHttpRequestInput, canonicalize_request, sign_http_request};
91
92 #[test]
93 fn canonical_request_uses_expected_format() {
94 let key = SigningKey::from_bytes(&[42_u8; 32]);
95 let input = SignHttpRequestInput {
96 method: "post",
97 path_with_query: "/pair/start?x=1",
98 timestamp: "1700000000",
99 nonce: "abc",
100 body: br#"{"hello":"world"}"#,
101 secret_key: &key,
102 };
103 let signed = sign_http_request(&input).expect("sign");
104 let canonical = canonicalize_request(&input, &signed.body_hash);
105 assert_eq!(signed.canonical_request, canonical);
106 assert!(signed.proof.len() > 10);
107 assert_eq!(signed.headers.len(), 4);
108 }
109}