openlatch-client 0.1.13

The open-source security layer for AI agents — client forwarder
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use hmac::{Hmac, Mac};
use sha2::Sha256;

use crate::error::{OlError, ERR_CANONICALIZATION_FAILED};

type HmacSha256 = Hmac<Sha256>;

pub fn compute_entry_hmac(entry_json: &serde_json::Value, key: &[u8]) -> Result<String, OlError> {
    let canonicalized = canonicalize_entry(entry_json)?;
    let mut mac = HmacSha256::new_from_slice(key).expect("HMAC-SHA-256 accepts any key length");
    mac.update(canonicalized.as_bytes());
    let result = mac.finalize().into_bytes();
    Ok(URL_SAFE_NO_PAD.encode(result))
}

pub fn verify_entry_hmac(
    entry_json: &serde_json::Value,
    expected_hmac: &str,
    key: &[u8],
) -> Result<bool, OlError> {
    let computed = compute_entry_hmac(entry_json, key)?;
    Ok(constant_time_eq(
        computed.as_bytes(),
        expected_hmac.as_bytes(),
    ))
}

fn canonicalize_entry(entry_json: &serde_json::Value) -> Result<String, OlError> {
    let mut obj = match entry_json.clone() {
        serde_json::Value::Object(map) => map,
        _ => {
            return Err(OlError::new(
                ERR_CANONICALIZATION_FAILED,
                "Hook entry is not a JSON object",
            ));
        }
    };

    if let Some(serde_json::Value::Object(marker)) = obj.get_mut("_openlatch") {
        marker.remove("hmac");
    }

    serde_jcs::to_string(&serde_json::Value::Object(obj)).map_err(|e| {
        OlError::new(
            ERR_CANONICALIZATION_FAILED,
            format!("JCS canonicalization failed: {e}"),
        )
    })
}

fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
    if a.len() != b.len() {
        return false;
    }
    let mut diff = 0u8;
    for (x, y) in a.iter().zip(b.iter()) {
        diff |= x ^ y;
    }
    diff == 0
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn test_key() -> Vec<u8> {
        vec![0x42; 32]
    }

    #[test]
    fn roundtrip_compute_verify() {
        let entry = json!({
            "matcher": "",
            "_openlatch": {"v": 1, "id": "test-id", "installed_at": "2026-04-16T12:00:00Z"},
            "hooks": [{"type": "command", "command": "test", "timeout": 10}]
        });

        let hmac = compute_entry_hmac(&entry, &test_key()).unwrap();
        assert!(verify_entry_hmac(&entry, &hmac, &test_key()).unwrap());
    }

    #[test]
    fn hmac_field_excluded_from_computation() {
        let entry_without = json!({
            "_openlatch": {"v": 1, "id": "test-id", "installed_at": "2026-04-16T12:00:00Z"},
            "hooks": [{"type": "command", "command": "test"}]
        });
        let entry_with = json!({
            "_openlatch": {"v": 1, "id": "test-id", "installed_at": "2026-04-16T12:00:00Z", "hmac": "old-value"},
            "hooks": [{"type": "command", "command": "test"}]
        });

        let h1 = compute_entry_hmac(&entry_without, &test_key()).unwrap();
        let h2 = compute_entry_hmac(&entry_with, &test_key()).unwrap();
        assert_eq!(h1, h2);
    }

    #[test]
    fn different_key_orderings_same_hmac() {
        let entry_a = json!({
            "hooks": [{"command": "test", "type": "command"}],
            "matcher": "",
            "_openlatch": {"id": "test-id", "installed_at": "2026-04-16T12:00:00Z", "v": 1}
        });
        let entry_b = json!({
            "_openlatch": {"v": 1, "id": "test-id", "installed_at": "2026-04-16T12:00:00Z"},
            "matcher": "",
            "hooks": [{"type": "command", "command": "test"}]
        });

        let h1 = compute_entry_hmac(&entry_a, &test_key()).unwrap();
        let h2 = compute_entry_hmac(&entry_b, &test_key()).unwrap();
        assert_eq!(
            h1, h2,
            "JCS must produce identical output for equivalent objects"
        );
    }

    #[test]
    fn url_modification_invalidates_hmac() {
        let entry = json!({
            "_openlatch": {"v": 1, "id": "test-id", "installed_at": "2026-04-16T12:00:00Z"},
            "hooks": [{"type": "command", "command": "openlatch-hook --agent claude-code"}]
        });
        let hmac = compute_entry_hmac(&entry, &test_key()).unwrap();

        let mut tampered = entry.clone();
        tampered["hooks"][0]["command"] = json!("evil-binary --exfiltrate");
        assert!(!verify_entry_hmac(&tampered, &hmac, &test_key()).unwrap());
    }

    #[test]
    fn timeout_modification_invalidates_hmac() {
        let entry = json!({
            "_openlatch": {"v": 1, "id": "test-id", "installed_at": "2026-04-16T12:00:00Z"},
            "hooks": [{"type": "command", "command": "test", "timeout": 10}]
        });
        let hmac = compute_entry_hmac(&entry, &test_key()).unwrap();

        let mut tampered = entry.clone();
        tampered["hooks"][0]["timeout"] = json!(0);
        assert!(!verify_entry_hmac(&tampered, &hmac, &test_key()).unwrap());
    }

    #[test]
    fn id_modification_invalidates_hmac() {
        let entry = json!({
            "_openlatch": {"v": 1, "id": "original-id", "installed_at": "2026-04-16T12:00:00Z"},
            "hooks": [{"type": "command", "command": "test"}]
        });
        let hmac = compute_entry_hmac(&entry, &test_key()).unwrap();

        let mut tampered = entry.clone();
        tampered["_openlatch"]["id"] = json!("swapped-id");
        assert!(!verify_entry_hmac(&tampered, &hmac, &test_key()).unwrap());
    }

    #[test]
    fn wrong_key_fails_verification() {
        let entry = json!({
            "_openlatch": {"v": 1, "id": "test-id", "installed_at": "2026-04-16T12:00:00Z"},
            "hooks": [{"type": "command", "command": "test"}]
        });
        let hmac = compute_entry_hmac(&entry, &test_key()).unwrap();
        let wrong_key = vec![0x99; 32];
        assert!(!verify_entry_hmac(&entry, &hmac, &wrong_key).unwrap());
    }
}