//! 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=`). 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 { 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 { 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) } /// Whether `secret` is the one the config-PR hook was last registered with, /// per [`crate::paths::forge_config_pr_webhook_fingerprint()`]. A missing or /// unreadable record counts as "not registered". pub fn is_registered(secret: &str) -> bool { is_registered_at(&crate::paths::forge_config_pr_webhook_fingerprint(), secret) } /// Record `secret` as the one the config-PR hook is now registered with. pub fn record_registered(secret: &str) -> Result<()> { record_registered_at(&crate::paths::forge_config_pr_webhook_fingerprint(), secret) } fn is_registered_at(path: &std::path::Path, secret: &str) -> bool { std::fs::read_to_string(path).is_ok_and(|raw| raw.trim() == fingerprint(secret)) } fn record_registered_at(path: &std::path::Path, secret: &str) -> Result<()> { std::fs::create_dir_all(path.parent().unwrap_or(path)) .with_context(|| format!("create dir for {}", path.display()))?; std::fs::write(path, format!("{}\n", fingerprint(secret))) .with_context(|| format!("write webhook secret fingerprint to {}", path.display())) } /// Hex SHA-256 of `secret` — stored in its place so the record on disk is /// not a second copy of the key. fn fingerprint(secret: &str) -> String { use sha2::{Digest as _, Sha256}; hex_encode(&Sha256::digest(secret.as_bytes())) } /// Read 32 random bytes from `/dev/urandom` and hex-encode them. fn generate_hex_secret() -> Result { 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=`. 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::::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> { 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, is_registered_at, load_or_generate_at, record_registered_at, verify_signature, }; /// No record is the state of every hive before its first replacement, /// and must read as "not registered" so that hook gets replaced once. #[test] fn a_secret_with_no_fingerprint_on_record_is_not_registered() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("forge").join("fingerprint"); assert!(!is_registered_at(&path, &"a".repeat(64))); } #[test] fn a_recorded_secret_is_registered_and_a_changed_one_is_not() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("forge").join("fingerprint"); let old = "a".repeat(64); let new = "b".repeat(64); record_registered_at(&path, &old).expect("record"); assert!( is_registered_at(&path, &old), "unchanged secret: keep the hook" ); assert!( !is_registered_at(&path, &new), "changed secret: replace the hook" ); assert!( !std::fs::read_to_string(&path) .expect("read back") .contains(&old), "the record must not be a copy of the secret" ); record_registered_at(&path, &new).expect("re-record"); assert!(is_registered_at(&path, &new)); assert!(!is_registered_at(&path, &old)); } /// Compute the `sha256=` 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::::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" ); } } }