hyperhive/hive-c0re/src/webhook_secret.rs
atlas d04c86e9ac chore(#2510): bump indicatif/tower-http/hmac/sha2 to latest majors
Bumps the feature (major) versions that update cleanly without breaking the
build: indicatif 0.17->0.18, tower-http 0.6->0.7, hmac 0.12->0.13,
sha2 0.10->0.11. Only adaptation needed: import hmac's KeyInit trait in
webhook_secret (new_from_slice moved from Mac to KeyInit in hmac 0.13).

Held back (require dedicated code-change PRs, out of scope for a
non-breaking bump):
- reqwest 0.13: renames the rustls-tls feature and conflicts with
  forgejo-api 0.11 + matrix-sdk 0.14 which pin reqwest 0.12.
- rusqlite 0.40: libsqlite3-sys 0.38 clashes with matrix-sdk-sqlite 0.14's
  0.35 (single links=sqlite3) — coupled to the matrix-sdk bump.
- rmcp 2.2, matrix-sdk 0.18: major API rewrites across the MCP/matrix crates.
2026-07-16 10:42:30 +02:00

99 lines
3.8 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> {
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<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)
}