Skip to main content

ferrin_core/generate_text/approval/
signature.rs

1//! HMAC signatures over approval requests.
2//!
3//! Payload: the JSON array `["ferrin-tool-approval-v1", approval_id,
4//! tool_call_id, tool_name, input_digest]` where `input_digest` is the
5//! canonical-JSON SHA-256 of the input (`ferrin_tool::fingerprint::hash_canonical`).
6//! The signature is HMAC-SHA256 in unpadded base64url.
7
8use 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
21/// Domain separator of the signature payload.
22pub const SIGNATURE_DOMAIN: &str = "ferrin-tool-approval-v1";
23
24type HmacSha256 = Hmac<Sha256>;
25
26/// The fields covered by a signature.
27#[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
55/// Signs `fields`.
56pub(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
62/// Verifies `signature` over `fields` in constant time.
63pub(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}