Skip to main content

gunnar_core/
webhook.rs

1//! Outbound webhook delivery signatures (the event stream, the outbound leg).
2//!
3//! A webhook is a subscriber to the event bus that POSTs each event to a
4//! configured URL. This module is the part that makes gunnar's webhook worth
5//! more than a plain POST: **every delivery is signed**, so the receiver can
6//! prove the body came from this gunnar and was not forged or replayed by
7//! anything that merely knows the URL.
8//!
9//! # Why `sshsig`, and why its own namespace
10//!
11//! The signature is an `sshsig` — the same `SSHSIG` format `ssh-keygen -Y sign`
12//! produces and `ssh-keygen -Y verify` checks — exactly as the build record
13//! ([`crate::provenance`]), the forge event ([`crate::forge`]) and the signed
14//! tree head ([`crate::transparency`]) are. gunnar signs everything this way,
15//! and a webhook is not a place to invent a second scheme (one implementation).
16//!
17//! The receiver verifies with the server's **published public key** and no
18//! shared secret: a leaked delivery URL is not a leaked signing key, and a
19//! receiver behind a CDN or a queue can still tell a real delivery from a
20//! replayed or forged one. That is the property an HMAC shared secret — the
21//! GitHub model — cannot give, because with HMAC the verifier holds the same
22//! secret the signer does and anything that can verify can also forge.
23//!
24//! The namespace is [`WEBHOOK_NAMESPACE`], distinct from `git`,
25//! `gunnar-provenance`, `gunnar-grpc` and `gunnar-transparency`. `sshsig` binds
26//! the namespace into what is signed, so a webhook delivery signature can never
27//! be lifted and presented as a commit signature, a build attestation or a
28//! control-plane credential, and none of those can be presented as a webhook
29//! delivery. A signature is scoped to the one job it was made for.
30//!
31//! # What is signed
32//!
33//! The **exact bytes of the request body** — the JSON the receiver reads off
34//! the socket, byte for byte. Not a re-serialisation the receiver has to
35//! reproduce (JSON has no canonical form, so signing the meaning rather than
36//! the bytes would make the signature depend on the receiver's serialiser), and
37//! not a digest the receiver has to trust the sender computed. The verifier
38//! hashes the body it actually received and checks the signature over that, so
39//! a single flipped byte anywhere in transit fails the check.
40
41use base64ct::Encoding as _;
42
43/// The `sshsig` namespace an outbound webhook delivery is signed under.
44///
45/// Distinct from every other namespace in this repository so a signature made
46/// for one job never verifies for another. See the module header.
47pub const WEBHOOK_NAMESPACE: &str = "gunnar-webhook";
48
49/// The HTTP header carrying the base64 of the `sshsig` PEM.
50///
51/// The PEM is `-----BEGIN SSH SIGNATURE-----…` and is multi-line, which an HTTP
52/// header value cannot be, so it travels base64-encoded and is decoded back to
53/// the PEM `ssh-keygen -Y verify` wants.
54pub const SIGNATURE_HEADER: &str = "x-gunnar-signature";
55/// The header naming the `sshsig` namespace, so a receiver can build the
56/// `ssh-keygen -Y verify -n <namespace>` invocation without hard-coding it.
57pub const NAMESPACE_HEADER: &str = "x-gunnar-signature-namespace";
58/// The header carrying `SHA256:…`, the fingerprint of the key that signed —
59/// which of the server's published keys to verify against.
60pub const KEY_FINGERPRINT_HEADER: &str = "x-gunnar-key-fingerprint";
61/// The header naming the event kind, e.g. `ref.updated`. Lets a receiver route
62/// or drop before parsing the body.
63pub const EVENT_HEADER: &str = "x-gunnar-event";
64/// The header carrying a per-delivery id (`<epoch>:<seq>`), so a receiver can
65/// deduplicate a redelivered event.
66pub const DELIVERY_HEADER: &str = "x-gunnar-delivery";
67/// The GitHub-compatible HMAC header, set only when an endpoint configures a
68/// shared secret. `sha256=<hex>` over the body. The `sshsig` above is the
69/// default and the one that does not require the receiver to hold a secret;
70/// this exists so a receiver written for GitHub's webhooks can be pointed at
71/// gunnar unchanged.
72pub const HMAC_HEADER: &str = "x-hub-signature-256";
73
74/// A signed delivery: the fingerprint of the signing key and the `sshsig`.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct SignedDelivery {
77    /// `SHA256:…` of the key that signed. Goes in [`KEY_FINGERPRINT_HEADER`].
78    pub key_fingerprint: String,
79    /// The `sshsig`, PEM-armoured.
80    pub signature_pem: String,
81}
82
83impl SignedDelivery {
84    /// The base64 of the PEM, for [`SIGNATURE_HEADER`].
85    pub fn signature_header(&self) -> String {
86        base64ct::Base64::encode_string(self.signature_pem.as_bytes())
87    }
88}
89
90/// Sign a delivery body under [`WEBHOOK_NAMESPACE`].
91///
92/// `body` is the exact request body bytes. See the module header for why it is
93/// the bytes and not a re-rendering of their meaning.
94pub fn sign_delivery(
95    key: &ssh_key::PrivateKey,
96    body: &[u8],
97) -> Result<SignedDelivery, ssh_key::Error> {
98    let signature = key.sign(WEBHOOK_NAMESPACE, ssh_key::HashAlg::Sha512, body)?;
99    Ok(SignedDelivery {
100        key_fingerprint: key
101            .public_key()
102            .fingerprint(ssh_key::HashAlg::Sha256)
103            .to_string(),
104        signature_pem: signature.to_pem(ssh_key::LineEnding::LF)?,
105    })
106}
107
108/// What went wrong verifying a delivery. The variants a receiver distinguishes.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub enum DeliveryProblem {
111    /// The signature header was not the base64 of a well-formed `sshsig`.
112    Malformed(String),
113    /// The delivery names a key that is not the one offered to check it.
114    WrongKey {
115        /// The fingerprint the delivery claims.
116        signed_by: String,
117        /// The fingerprint of the key given to check it.
118        offered: String,
119    },
120    /// The signature was made under a different namespace — refused before the
121    /// verify, so a signature for another job is never reported as verifying.
122    WrongNamespace(String),
123    /// Well formed, right key, right namespace, and it does not check out —
124    /// the body was altered, or it was signed by a different key.
125    NotVerified,
126}
127
128impl std::fmt::Display for DeliveryProblem {
129    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130        match self {
131            DeliveryProblem::Malformed(why) => {
132                write!(
133                    f,
134                    "the delivery signature is not a well-formed sshsig: {why}"
135                )
136            }
137            DeliveryProblem::WrongKey { signed_by, offered } => write!(
138                f,
139                "the delivery says it was signed by {signed_by} and was checked against {offered}"
140            ),
141            DeliveryProblem::WrongNamespace(ns) => write!(
142                f,
143                "the delivery signature's namespace is {ns:?}, not {WEBHOOK_NAMESPACE:?}"
144            ),
145            DeliveryProblem::NotVerified => {
146                write!(f, "the delivery signature does not verify against the body")
147            }
148        }
149    }
150}
151
152/// Verify a signed delivery against a public key.
153///
154/// `signature_header` is the value of [`SIGNATURE_HEADER`] as received (the
155/// base64 of the PEM); `claimed_fingerprint` is [`KEY_FINGERPRINT_HEADER`];
156/// `body` is the exact bytes the receiver read. Mirrors
157/// [`crate::provenance::verify_build`]: fingerprint before signature, namespace
158/// before verify, so a signature by another key or for another purpose is never
159/// reported as this one verifying.
160///
161/// This is the Rust twin of the `ssh-keygen -Y verify` a shell receiver runs;
162/// the format is standard `SSHSIG`, so the two agree. The step-by-step guide
163/// gives the `ssh-keygen` recipe.
164pub fn verify_delivery(
165    key: &ssh_key::PublicKey,
166    body: &[u8],
167    signature_header: &str,
168    claimed_fingerprint: &str,
169) -> Result<(), DeliveryProblem> {
170    let offered = key.fingerprint(ssh_key::HashAlg::Sha256).to_string();
171    if offered != claimed_fingerprint {
172        return Err(DeliveryProblem::WrongKey {
173            signed_by: claimed_fingerprint.to_owned(),
174            offered,
175        });
176    }
177    let pem = base64ct::Base64::decode_vec(signature_header.trim())
178        .map_err(|err| DeliveryProblem::Malformed(format!("header is not base64: {err}")))?;
179    let signature = ssh_key::SshSig::from_pem(&pem)
180        .map_err(|err| DeliveryProblem::Malformed(err.to_string()))?;
181    if signature.namespace() != WEBHOOK_NAMESPACE {
182        return Err(DeliveryProblem::WrongNamespace(
183            signature.namespace().to_owned(),
184        ));
185    }
186    key.verify(WEBHOOK_NAMESPACE, body, &signature)
187        .map_err(|_| DeliveryProblem::NotVerified)
188}
189
190/// The GitHub-compatible `X-Hub-Signature-256` value for `body` under `secret`:
191/// `sha256=<hex>` of `HMAC-SHA256(secret, body)`.
192///
193/// Optional and off by default. It is here for a receiver already written for
194/// GitHub; the `sshsig` above is the one gunnar leads with, because it does not
195/// make the verifier hold a secret it could also forge with.
196pub fn hmac_sha256_header(secret: &[u8], body: &[u8]) -> String {
197    format!(
198        "sha256={}",
199        crate::transparency::hex(&hmac_sha256(secret, body))
200    )
201}
202
203/// HMAC-SHA256, computed here rather than with a new crate: `sha2` is already a
204/// dependency (the crate budget), and HMAC over a fixed hash is the RFC 2104
205/// construction and nothing more.
206fn hmac_sha256(key: &[u8], message: &[u8]) -> [u8; 32] {
207    use sha2::Digest as _;
208    const BLOCK: usize = 64; // SHA-256's block size in bytes.
209    let mut block = [0u8; BLOCK];
210    if key.len() > BLOCK {
211        let digest: [u8; 32] = sha2::Sha256::digest(key).into();
212        block[..32].copy_from_slice(&digest);
213    } else {
214        block[..key.len()].copy_from_slice(key);
215    }
216    let mut ipad = [0x36u8; BLOCK];
217    let mut opad = [0x5cu8; BLOCK];
218    for i in 0..BLOCK {
219        ipad[i] ^= block[i];
220        opad[i] ^= block[i];
221    }
222    let mut inner = sha2::Sha256::new();
223    inner.update(ipad);
224    inner.update(message);
225    let inner: [u8; 32] = inner.finalize().into();
226    let mut outer = sha2::Sha256::new();
227    outer.update(opad);
228    outer.update(inner);
229    outer.finalize().into()
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    fn key() -> ssh_key::PrivateKey {
237        ssh_key::PrivateKey::from_openssh(crate::mint_ed25519().expect("mint")).expect("parse")
238    }
239
240    #[test]
241    fn a_signed_delivery_verifies_against_the_signing_key() {
242        let key = key();
243        let body = br#"{"kind":"ref.updated","seq":7}"#;
244        let signed = sign_delivery(&key, body).expect("sign");
245        // The applied output: the exact received bytes plus a PASSING verify.
246        verify_delivery(
247            key.public_key(),
248            body,
249            &signed.signature_header(),
250            &signed.key_fingerprint,
251        )
252        .expect("a delivery signed by this key must verify against it");
253    }
254
255    #[test]
256    fn a_delivery_signed_by_another_key_is_refused() {
257        // The red first: a body signed with the WRONG key must FAIL. Without
258        // the namespace-and-fingerprint discipline this is exactly the check
259        // that silently passes.
260        let signer = key();
261        let attacker = key();
262        let body = br#"{"kind":"ref.updated","seq":7}"#;
263        let signed = sign_delivery(&attacker, body).expect("sign");
264        // Presented as if it came from the server's key.
265        let err = verify_delivery(
266            signer.public_key(),
267            body,
268            &signed.signature_header(),
269            &signer
270                .public_key()
271                .fingerprint(ssh_key::HashAlg::Sha256)
272                .to_string(),
273        )
274        .expect_err("a body signed by another key must not verify");
275        assert_eq!(err, DeliveryProblem::NotVerified);
276    }
277
278    #[test]
279    fn a_tampered_body_is_refused() {
280        let key = key();
281        let body = br#"{"kind":"ref.updated","seq":7}"#;
282        let signed = sign_delivery(&key, body).expect("sign");
283        let tampered = br#"{"kind":"ref.updated","seq":8}"#;
284        let err = verify_delivery(
285            key.public_key(),
286            tampered,
287            &signed.signature_header(),
288            &signed.key_fingerprint,
289        )
290        .expect_err("a single flipped byte in the body must fail the verify");
291        assert_eq!(err, DeliveryProblem::NotVerified);
292    }
293
294    #[test]
295    fn a_signature_from_another_namespace_is_refused() {
296        // A provenance signature (a different namespace) must never pass as a
297        // webhook delivery. Sign the same bytes under the provenance namespace
298        // and present it as a delivery.
299        let key = key();
300        let body = br#"{"kind":"ref.updated","seq":7}"#;
301        let other = key
302            .sign(
303                crate::provenance::PROVENANCE_NAMESPACE,
304                ssh_key::HashAlg::Sha512,
305                body,
306            )
307            .expect("sign under provenance");
308        let header = base64ct::Base64::encode_string(
309            other
310                .to_pem(ssh_key::LineEnding::LF)
311                .expect("pem")
312                .as_bytes(),
313        );
314        let fp = key
315            .public_key()
316            .fingerprint(ssh_key::HashAlg::Sha256)
317            .to_string();
318        let err = verify_delivery(key.public_key(), body, &header, &fp)
319            .expect_err("a signature under another namespace must be refused");
320        assert_eq!(
321            err,
322            DeliveryProblem::WrongNamespace(crate::provenance::PROVENANCE_NAMESPACE.to_owned())
323        );
324    }
325
326    #[test]
327    fn hmac_matches_the_rfc_construction() {
328        // A known GitHub-doc vector shape: HMAC-SHA256 is deterministic, so a
329        // stable secret/body gives a stable digest. This guards the hand-rolled
330        // HMAC against a block-padding bug.
331        let mac = hmac_sha256_header(b"It's a Secret to Everybody", b"Hello, World!");
332        assert_eq!(
333            mac,
334            "sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17"
335        );
336    }
337}