clawdentity-core 0.1.7

Core Rust library for Clawdentity identity, registry auth, relay, connector, and provider flows.
Documentation
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use ed25519_dalek::{Signer, SigningKey};
use sha2::{Digest, Sha256};

use crate::error::{CoreError, Result};

pub const CANONICAL_REQUEST_VERSION: &str = "CLAW-PROOF-V1";
pub const X_CLAW_TIMESTAMP: &str = "X-Claw-Timestamp";
pub const X_CLAW_NONCE: &str = "X-Claw-Nonce";
pub const X_CLAW_BODY_SHA256: &str = "X-Claw-Body-SHA256";
pub const X_CLAW_PROOF: &str = "X-Claw-Proof";

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SignHttpRequestInput<'a> {
    pub method: &'a str,
    pub path_with_query: &'a str,
    pub timestamp: &'a str,
    pub nonce: &'a str,
    pub body: &'a [u8],
    pub secret_key: &'a SigningKey,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SignedRequest {
    pub canonical_request: String,
    pub proof: String,
    pub body_hash: String,
    pub headers: Vec<(String, String)>,
}

/// TODO(clawdentity): document `canonicalize_request`.
pub fn canonicalize_request(input: &SignHttpRequestInput<'_>, body_hash: &str) -> String {
    [
        CANONICAL_REQUEST_VERSION,
        &input.method.to_uppercase(),
        input.path_with_query,
        input.timestamp,
        input.nonce,
        body_hash,
    ]
    .join("\n")
}

/// TODO(clawdentity): document `hash_body_sha256_base64url`.
pub fn hash_body_sha256_base64url(body: &[u8]) -> String {
    let digest = Sha256::digest(body);
    URL_SAFE_NO_PAD.encode(digest)
}

/// TODO(clawdentity): document `sign_http_request`.
pub fn sign_http_request(input: &SignHttpRequestInput<'_>) -> Result<SignedRequest> {
    if input.method.trim().is_empty() {
        return Err(CoreError::InvalidInput("method is required".to_string()));
    }
    if input.path_with_query.trim().is_empty() {
        return Err(CoreError::InvalidInput(
            "pathWithQuery is required".to_string(),
        ));
    }
    if input.timestamp.trim().is_empty() {
        return Err(CoreError::InvalidInput("timestamp is required".to_string()));
    }
    if input.nonce.trim().is_empty() {
        return Err(CoreError::InvalidInput("nonce is required".to_string()));
    }

    let body_hash = hash_body_sha256_base64url(input.body);
    let canonical_request = canonicalize_request(input, &body_hash);
    let signature = input.secret_key.sign(canonical_request.as_bytes());
    let proof = URL_SAFE_NO_PAD.encode(signature.to_bytes());

    Ok(SignedRequest {
        canonical_request,
        proof: proof.clone(),
        body_hash: body_hash.clone(),
        headers: vec![
            (X_CLAW_TIMESTAMP.to_string(), input.timestamp.to_string()),
            (X_CLAW_NONCE.to_string(), input.nonce.to_string()),
            (X_CLAW_BODY_SHA256.to_string(), body_hash),
            (X_CLAW_PROOF.to_string(), proof),
        ],
    })
}

#[cfg(test)]
mod tests {
    use ed25519_dalek::SigningKey;

    use super::{SignHttpRequestInput, canonicalize_request, sign_http_request};

    #[test]
    fn canonical_request_uses_expected_format() {
        let key = SigningKey::from_bytes(&[42_u8; 32]);
        let input = SignHttpRequestInput {
            method: "post",
            path_with_query: "/pair/start?x=1",
            timestamp: "1700000000",
            nonce: "abc",
            body: br#"{"hello":"world"}"#,
            secret_key: &key,
        };
        let signed = sign_http_request(&input).expect("sign");
        let canonical = canonicalize_request(&input, &signed.body_hash);
        assert_eq!(signed.canonical_request, canonical);
        assert!(signed.proof.len() > 10);
        assert_eq!(signed.headers.len(), 4);
    }
}