use base64ct::Encoding as _;
pub const WEBHOOK_NAMESPACE: &str = "gunnar-webhook";
pub const SIGNATURE_HEADER: &str = "x-gunnar-signature";
pub const NAMESPACE_HEADER: &str = "x-gunnar-signature-namespace";
pub const KEY_FINGERPRINT_HEADER: &str = "x-gunnar-key-fingerprint";
pub const EVENT_HEADER: &str = "x-gunnar-event";
pub const DELIVERY_HEADER: &str = "x-gunnar-delivery";
pub const HMAC_HEADER: &str = "x-hub-signature-256";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SignedDelivery {
pub key_fingerprint: String,
pub signature_pem: String,
}
impl SignedDelivery {
pub fn signature_header(&self) -> String {
base64ct::Base64::encode_string(self.signature_pem.as_bytes())
}
}
pub fn sign_delivery(
key: &ssh_key::PrivateKey,
body: &[u8],
) -> Result<SignedDelivery, ssh_key::Error> {
let signature = key.sign(WEBHOOK_NAMESPACE, ssh_key::HashAlg::Sha512, body)?;
Ok(SignedDelivery {
key_fingerprint: key
.public_key()
.fingerprint(ssh_key::HashAlg::Sha256)
.to_string(),
signature_pem: signature.to_pem(ssh_key::LineEnding::LF)?,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DeliveryProblem {
Malformed(String),
WrongKey {
signed_by: String,
offered: String,
},
WrongNamespace(String),
NotVerified,
}
impl std::fmt::Display for DeliveryProblem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DeliveryProblem::Malformed(why) => {
write!(
f,
"the delivery signature is not a well-formed sshsig: {why}"
)
}
DeliveryProblem::WrongKey { signed_by, offered } => write!(
f,
"the delivery says it was signed by {signed_by} and was checked against {offered}"
),
DeliveryProblem::WrongNamespace(ns) => write!(
f,
"the delivery signature's namespace is {ns:?}, not {WEBHOOK_NAMESPACE:?}"
),
DeliveryProblem::NotVerified => {
write!(f, "the delivery signature does not verify against the body")
}
}
}
}
pub fn verify_delivery(
key: &ssh_key::PublicKey,
body: &[u8],
signature_header: &str,
claimed_fingerprint: &str,
) -> Result<(), DeliveryProblem> {
let offered = key.fingerprint(ssh_key::HashAlg::Sha256).to_string();
if offered != claimed_fingerprint {
return Err(DeliveryProblem::WrongKey {
signed_by: claimed_fingerprint.to_owned(),
offered,
});
}
let pem = base64ct::Base64::decode_vec(signature_header.trim())
.map_err(|err| DeliveryProblem::Malformed(format!("header is not base64: {err}")))?;
let signature = ssh_key::SshSig::from_pem(&pem)
.map_err(|err| DeliveryProblem::Malformed(err.to_string()))?;
if signature.namespace() != WEBHOOK_NAMESPACE {
return Err(DeliveryProblem::WrongNamespace(
signature.namespace().to_owned(),
));
}
key.verify(WEBHOOK_NAMESPACE, body, &signature)
.map_err(|_| DeliveryProblem::NotVerified)
}
pub fn hmac_sha256_header(secret: &[u8], body: &[u8]) -> String {
format!(
"sha256={}",
crate::transparency::hex(&hmac_sha256(secret, body))
)
}
fn hmac_sha256(key: &[u8], message: &[u8]) -> [u8; 32] {
use sha2::Digest as _;
const BLOCK: usize = 64; let mut block = [0u8; BLOCK];
if key.len() > BLOCK {
let digest: [u8; 32] = sha2::Sha256::digest(key).into();
block[..32].copy_from_slice(&digest);
} else {
block[..key.len()].copy_from_slice(key);
}
let mut ipad = [0x36u8; BLOCK];
let mut opad = [0x5cu8; BLOCK];
for i in 0..BLOCK {
ipad[i] ^= block[i];
opad[i] ^= block[i];
}
let mut inner = sha2::Sha256::new();
inner.update(ipad);
inner.update(message);
let inner: [u8; 32] = inner.finalize().into();
let mut outer = sha2::Sha256::new();
outer.update(opad);
outer.update(inner);
outer.finalize().into()
}
#[cfg(test)]
mod tests {
use super::*;
fn key() -> ssh_key::PrivateKey {
ssh_key::PrivateKey::from_openssh(crate::mint_ed25519().expect("mint")).expect("parse")
}
#[test]
fn a_signed_delivery_verifies_against_the_signing_key() {
let key = key();
let body = br#"{"kind":"ref.updated","seq":7}"#;
let signed = sign_delivery(&key, body).expect("sign");
verify_delivery(
key.public_key(),
body,
&signed.signature_header(),
&signed.key_fingerprint,
)
.expect("a delivery signed by this key must verify against it");
}
#[test]
fn a_delivery_signed_by_another_key_is_refused() {
let signer = key();
let attacker = key();
let body = br#"{"kind":"ref.updated","seq":7}"#;
let signed = sign_delivery(&attacker, body).expect("sign");
let err = verify_delivery(
signer.public_key(),
body,
&signed.signature_header(),
&signer
.public_key()
.fingerprint(ssh_key::HashAlg::Sha256)
.to_string(),
)
.expect_err("a body signed by another key must not verify");
assert_eq!(err, DeliveryProblem::NotVerified);
}
#[test]
fn a_tampered_body_is_refused() {
let key = key();
let body = br#"{"kind":"ref.updated","seq":7}"#;
let signed = sign_delivery(&key, body).expect("sign");
let tampered = br#"{"kind":"ref.updated","seq":8}"#;
let err = verify_delivery(
key.public_key(),
tampered,
&signed.signature_header(),
&signed.key_fingerprint,
)
.expect_err("a single flipped byte in the body must fail the verify");
assert_eq!(err, DeliveryProblem::NotVerified);
}
#[test]
fn a_signature_from_another_namespace_is_refused() {
let key = key();
let body = br#"{"kind":"ref.updated","seq":7}"#;
let other = key
.sign(
crate::provenance::PROVENANCE_NAMESPACE,
ssh_key::HashAlg::Sha512,
body,
)
.expect("sign under provenance");
let header = base64ct::Base64::encode_string(
other
.to_pem(ssh_key::LineEnding::LF)
.expect("pem")
.as_bytes(),
);
let fp = key
.public_key()
.fingerprint(ssh_key::HashAlg::Sha256)
.to_string();
let err = verify_delivery(key.public_key(), body, &header, &fp)
.expect_err("a signature under another namespace must be refused");
assert_eq!(
err,
DeliveryProblem::WrongNamespace(crate::provenance::PROVENANCE_NAMESPACE.to_owned())
);
}
#[test]
fn hmac_matches_the_rfc_construction() {
let mac = hmac_sha256_header(b"It's a Secret to Everybody", b"Hello, World!");
assert_eq!(
mac,
"sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17"
);
}
}