Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-c0re/src/webhook_secret.rs
atlas 2c066cc871 hive-c0re: pin the two security boundaries that had no test
`is_forbidden` and the webhook-secret load/regenerate path were the last two
entries on the shortlist in hyperhive/hyperhive#3950; the other three landed in
hyperhive/hyperhive#4650. Both are classification logic whose failure mode is
silence, which is why they are worth a test rather than a coverage line.

`is_forbidden` gates the arms that tell an operator a Forgejo admin PATCH was
refused for want of a scope, and which credential to delete and re-mint to fix
it. One of those PATCHes is `ensure_repo_creation_disabled` — the lockdown that
stops an agent creating a repo it owns and self-merging in it. Two tests: a 403
is recognised in both shapes the typed client produces (the spec-listed
`Forbidden` kind and the bare `UnexpectedStatusCode`), and nothing else is —
not a 401, whose remedy is the automatic re-mint one function down, and not a
transport error that never reached the forge at all.

`load_or_generate` grows the path-taking half `load_or_generate_at`, the same
seam `swarm-controller`'s `webhook::load_or_generate_at` already has and for
the same stated reason. Three tests over it: a valid stored secret is returned
verbatim and never rotated (the newline this module writes itself makes the
trim load-bearing, not defensive); a malformed one is replaced by a secret that
reaches *disk*, not just the caller, and is then stable; and each near miss —
empty, whitespace, 63 chars, 65 chars, right length with a non-hex char — is
refused. That last one is the security case: `Hmac::new_from_slice` accepts a
key of any length, empty included, so a relaxed check fails nowhere and just
keys every signature off a guessable value.

Every test was confirmed able to fail: six mutations of the code under test,
each watched red, then reverted. The two halves of the validity check and the
two arms of `is_forbidden` were broken separately, so neither test passes on
one arm alone.
2026-09-23 17:00:44 +02:00

226 lines
9.6 KiB
Rust

//! Webhook HMAC secret — load-or-generate, persist, verify.
//!
//! A 32-byte secret is generated on first startup, hex-encoded, and stored at
//! [`crate::paths::webhook_secret_file()`]. On subsequent starts the same file
//! is read back so Forgejo and hive-c0re always share the same key without any
//! operator configuration.
//!
//! The secret is used in two places:
//! - **Registration**: passed as the `secret` config key when hive-c0re
//! creates (or re-creates) the Forgejo org/repo webhook.
//! - **Verification**: each incoming webhook POST is verified against the
//! `X-Hub-Signature-256` header Forgejo attaches (`sha256=<hex>`).
use anyhow::{Context as _, Result};
/// Load the webhook HMAC secret from disk; generate and persist it if absent.
///
/// Returns a hex-encoded 32-byte secret string (64 hex chars).
pub fn load_or_generate() -> Result<String> {
load_or_generate_at(&crate::paths::webhook_secret_file())
}
/// The path-taking half of [`load_or_generate`], split out so a test can
/// point it at a scratch file — same seam, for the same reason, as
/// `swarm-controller`'s `webhook::load_or_generate_at`.
fn load_or_generate_at(path: &std::path::Path) -> Result<String> {
if let Ok(raw) = std::fs::read_to_string(path) {
let trimmed = raw.trim().to_owned();
if trimmed.len() == 64 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
return Ok(trimmed);
}
// File exists but is malformed — regenerate.
tracing::warn!(
path = %path.display(),
"webhook-secret file malformed (wrong length/chars); regenerating"
);
}
let secret = generate_hex_secret()?;
std::fs::create_dir_all(path.parent().unwrap_or(path))
.with_context(|| format!("create dir for {}", path.display()))?;
std::fs::write(path, format!("{secret}\n"))
.with_context(|| format!("write webhook secret to {}", path.display()))?;
tracing::info!(path = %path.display(), "webhook secret generated and persisted");
Ok(secret)
}
/// Read 32 random bytes from `/dev/urandom` and hex-encode them.
fn generate_hex_secret() -> Result<String> {
use std::io::Read as _;
let mut buf = [0u8; 32];
let mut f =
std::fs::File::open("/dev/urandom").context("open /dev/urandom for secret generation")?;
f.read_exact(&mut buf)
.context("read 32 bytes from /dev/urandom")?;
Ok(hex_encode(&buf))
}
/// Hex-encode `bytes` as a lowercase string.
fn hex_encode(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push(char::from_digit(u32::from(b >> 4), 16).unwrap_or('0'));
out.push(char::from_digit(u32::from(b & 0xf), 16).unwrap_or('0'));
}
out
}
/// Verify a Forgejo `X-Hub-Signature-256` header against `body` using
/// `secret`. Returns `Ok(())` when the signature matches, or an error
/// describing the mismatch (safe to log; does not expose the secret).
///
/// Forgejo sends: `sha256=<hex>`.
pub fn verify_signature(secret: &str, body: &[u8], header: &str) -> Result<()> {
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
let sig_hex = header
.strip_prefix("sha256=")
.ok_or_else(|| anyhow::anyhow!("X-Hub-Signature-256 missing 'sha256=' prefix"))?;
let expected = hex_decode(sig_hex)
.ok_or_else(|| anyhow::anyhow!("X-Hub-Signature-256 contains non-hex chars"))?;
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
.map_err(|e| anyhow::anyhow!("HMAC key error: {e}"))?;
mac.update(body);
mac.verify_slice(&expected)
.map_err(|_| anyhow::anyhow!("X-Hub-Signature-256 mismatch"))
}
/// Decode a lowercase hex string into bytes; returns `None` on invalid input.
fn hex_decode(s: &str) -> Option<Vec<u8>> {
if !s.len().is_multiple_of(2) {
return None;
}
let mut out = Vec::with_capacity(s.len() / 2);
let mut chars = s.chars();
while let (Some(hi), Some(lo)) = (chars.next(), chars.next()) {
let hi = u8::try_from(hi.to_digit(16)?).ok()?;
let lo = u8::try_from(lo.to_digit(16)?).ok()?;
out.push((hi << 4) | lo);
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::{hex_encode, load_or_generate_at, verify_signature};
/// Compute the `sha256=<hex>` header Forgejo would send for `secret` +
/// `body`, so the "matches" test below isn't asserting against a
/// hand-picked string that happens to look like a signature.
fn sign(secret: &str, body: &[u8]) -> String {
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
mac.update(body);
format!("sha256={}", hex_encode(&mac.finalize().into_bytes()))
}
/// `verify_hmac` (`dashboard/webhook.rs`) delegates the actual HMAC
/// comparison here — this is the only gate on an inbound Forgejo
/// webhook reachable via the public gateway. A signature computed
/// with the right secret over the right body must verify.
#[test]
fn verify_signature_accepts_the_correct_hmac() {
let header = sign("s3cr3t", b"payload");
assert!(verify_signature("s3cr3t", b"payload", &header).is_ok());
}
/// If `mac.verify_slice`'s result were ever inverted (accept on
/// mismatch), an unauthenticated actor could queue an operator
/// approval through the public webhook endpoint. Cover both ways a
/// signature can stop matching: wrong secret, and a body that no
/// longer matches the one that was signed.
#[test]
fn verify_signature_rejects_a_mismatched_hmac() {
let header = sign("s3cr3t", b"payload");
assert!(verify_signature("different-secret", b"payload", &header).is_err());
assert!(verify_signature("s3cr3t", b"tampered-payload", &header).is_err());
}
/// A stored secret that already parses must be handed back exactly as
/// written, on every start. This is the property Forgejo's copy depends
/// on: the secret is registered *with Forgejo* once, so a load that
/// rotates a perfectly good value silently invalidates every webhook
/// delivery afterwards — and it surfaces as an outage, not as anything
/// security-shaped. The trailing newline is the one this module writes
/// itself (`format!("{secret}\n")`), so the trim is load-bearing rather
/// than defensive: without it the file this code just wrote reads back
/// as 65 chars and fails its own validity check on the next boot.
#[test]
fn a_valid_stored_secret_is_returned_verbatim_and_never_rotated() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("webhook-secret");
let seeded = "a".repeat(64);
std::fs::write(&path, format!("{seeded}\n")).expect("seed");
assert_eq!(
load_or_generate_at(&path).expect("load"),
seeded,
"a valid secret must be read back, not regenerated"
);
assert_eq!(
load_or_generate_at(&path).expect("second load"),
seeded,
"and still on the next start"
);
}
/// The regeneration branch has two halves and only one of them is in the
/// return value: the replacement must also be *persisted*, or every
/// restart mints a fresh secret and the registration in Forgejo is never
/// the one being verified against.
#[test]
fn a_malformed_secret_file_is_replaced_by_a_valid_persisted_one() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("webhook-secret");
std::fs::write(&path, "not-a-hex-secret\n").expect("seed");
let secret = load_or_generate_at(&path).expect("regenerates");
assert_eq!(secret.len(), 64, "hex-encoded 32 bytes");
assert!(secret.chars().all(|c| c.is_ascii_hexdigit()));
assert_eq!(
std::fs::read_to_string(&path).expect("read back").trim(),
secret,
"the regenerated secret must reach disk, not just the caller"
);
assert_eq!(
load_or_generate_at(&path).expect("second load"),
secret,
"and must then be stable across a restart"
);
}
/// The length + charset check is what stops a truncated or half-written
/// file from being adopted as the HMAC key. `Hmac::new_from_slice`
/// accepts a key of *any* length, empty included — so relaxing this
/// check does not fail anywhere, it just silently keys every signature
/// off a value an attacker can guess. Each near miss is listed
/// separately so a check that stops distinguishing one of them shows up
/// as that case rather than as a single opaque failure.
#[test]
fn a_near_miss_secret_file_is_not_adopted_as_the_key() {
for (label, seeded) in [
("empty file", String::new()),
("whitespace only", " \n".to_owned()),
("one hex digit short", "b".repeat(63)),
("one hex digit long", "b".repeat(65)),
("right length, non-hex char", format!("z{}", "b".repeat(63))),
] {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("webhook-secret");
std::fs::write(&path, &seeded).expect("seed");
let secret = load_or_generate_at(&path).expect("regenerates");
assert_ne!(secret, seeded.trim(), "{label} must not become the key");
assert_eq!(secret.len(), 64, "{label}: replacement is 64 hex chars");
assert!(
secret.chars().all(|c| c.is_ascii_hexdigit()),
"{label}: replacement is hex"
);
}
}
}