ferrin_core/generate_text/approval/
signature.rs1use base64::Engine;
9use base64::engine::general_purpose::URL_SAFE_NO_PAD;
10use ferrin_spec::ApprovalId;
11use ferrin_spec::JsonValue;
12use ferrin_spec::ToolCallId;
13use ferrin_spec::ToolName;
14use hmac::Hmac;
15use hmac::KeyInit;
16use hmac::Mac;
17use secrecy::ExposeSecret;
18use secrecy::SecretBox;
19use sha2::Sha256;
20
21pub const SIGNATURE_DOMAIN: &str = "ferrin-tool-approval-v1";
23
24type HmacSha256 = Hmac<Sha256>;
25
26#[derive(Debug, Clone, Copy)]
28pub(crate) struct SignedFields<'a> {
29 pub(crate) approval_id: &'a ApprovalId,
30 pub(crate) tool_call_id: &'a ToolCallId,
31 pub(crate) tool_name: &'a ToolName,
32 pub(crate) input: &'a JsonValue,
33}
34
35fn payload(fields: SignedFields<'_>) -> String {
36 let digest = ferrin_tool::fingerprint::hash_canonical(fields.input);
37 serde_json::to_string(&[
38 SIGNATURE_DOMAIN,
39 fields.approval_id.as_str(),
40 fields.tool_call_id.as_str(),
41 fields.tool_name.as_str(),
42 digest.as_str(),
43 ])
44 .unwrap_or_default()
45}
46
47fn mac(secret: &SecretBox<[u8]>) -> HmacSha256 {
48 #[allow(
49 clippy::expect_used,
50 reason = "HMAC accepts keys of any length; new_from_slice cannot fail"
51 )]
52 HmacSha256::new_from_slice(secret.expose_secret()).expect("HMAC accepts any key length")
53}
54
55pub(crate) fn sign(secret: &SecretBox<[u8]>, fields: SignedFields<'_>) -> String {
57 let mut mac = mac(secret);
58 mac.update(payload(fields).as_bytes());
59 URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
60}
61
62pub(crate) fn verify(secret: &SecretBox<[u8]>, fields: SignedFields<'_>, signature: &str) -> bool {
64 let Ok(decoded) = URL_SAFE_NO_PAD.decode(signature) else {
65 return false;
66 };
67 let mut mac = mac(secret);
68 mac.update(payload(fields).as_bytes());
69 mac.verify_slice(&decoded).is_ok()
70}