invite_user_id mapped every 403 M_FORBIDDEN to Ok(()). The membership pre-check already skips invited/joined users, so the 403s that reach the POST are mostly real refusals (banned target, sender without power), including `hivectl matrix invite`. A 403 is now success only when a membership re-read shows the user invited or joined; otherwise it is an error carrying the status and body. admin_room_send_and_poll read the send response's event_id with unwrap_or_default() and, when it was missing, walked every recent event unanchored, so an older bot reply (an earlier reset password) could be returned as this command's result. A send response without an event_id is now an error. run_destroy_bookkeeping discarded fail_pending_for_agent's error; it now warns like its neighbouring steps. ensure_config_pr_webhook returned as soon as a hook with the target URL existed, so a regenerated webhook-secret never reached Forgejo and every config-PR delivery failed HMAC until the 5-minute poll caught up. Forgejo's edit-hook API ignores `secret` and never returns it, so the SHA-256 of the secret last registered is recorded at forge/config-pr-webhook-secret-sha256; when it doesn't match, the same-URL hook is deleted and recreated. The paths.rs doc claiming re-registration on change now describes this. Refs #4723
295 lines
12 KiB
Rust
295 lines
12 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)
|
|
}
|
|
|
|
/// 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<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, 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=<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"
|
|
);
|
|
}
|
|
}
|
|
}
|