//! 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 { let path = crate::paths::webhook_secret_file(); 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 { 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) }