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());
}
}