Skip to main content

clawdentity_core/identity/
signing.rs

1use 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
32/// TODO(clawdentity): document `canonicalize_request`.
33pub 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
45/// TODO(clawdentity): document `hash_body_sha256_base64url`.
46pub fn hash_body_sha256_base64url(body: &[u8]) -> String {
47    let digest = Sha256::digest(body);
48    URL_SAFE_NO_PAD.encode(digest)
49}
50
51/// TODO(clawdentity): document `sign_http_request`.
52pub 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}