Use axum-extra TypedHeader, pre-resolve HMAC key, and add TraceLayer
- Implement headers::Credentials for HmacSha256Sig and use TypedHeader
in the HmacSha256Auth extractor, consistent with quire-server's bearer
token pattern
- Resolve webhook_secret bytes once in QuireCi::new() so the handler
accesses hmac_key() directly with no per-request reveal() call;
removes SecretUnavailable from WebhookError
- Add tower-http TraceLayer with a span per request (method +
matched_path) for structured HTTP observability
- Add #[serde(rename = "ref")] to PushRef.ref_name so the wire format
uses "ref" while the internal Rust name stays ref_name
https://claude.ai/code/session_01YXVu67hVnQSAY8wzmfmRHw
diff --git a/Cargo.lock b/Cargo.lock
index 2d0d6ae..340a2a2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2627,6 +2627,7 @@ name = "quire-ci"
version = "0.1.0"
dependencies = [
"axum",
+ "axum-extra",
"facet",
"figue",
"fs-err",
@@ -2651,6 +2652,7 @@ dependencies = [
"thiserror",
"tokio",
"tower",
+ "tower-http",
"tracing",
"tracing-opentelemetry",
"tracing-subscriber",
@@ -3728,6 +3730,7 @@ dependencies = [
"tower",
"tower-layer",
"tower-service",
+ "tracing",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index d5ea1c2..6b57c3a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,6 +4,8 @@ resolver = "3"
[workspace.dependencies]
axum = "*"
+axum-extra = { version = "*", features = ["typed-header"] }
+tower-http = { version = "*", features = ["trace"] }
clap = { version = "*", features = ["derive", "env"] }
facet = "*"
fs-err = "*"
diff --git a/quire-ci/Cargo.toml b/quire-ci/Cargo.toml
index 689fd96..6e2abb7 100644
--- a/quire-ci/Cargo.toml
+++ b/quire-ci/Cargo.toml
@@ -5,6 +5,8 @@ edition = "2024"
[dependencies]
axum = { workspace = true }
+axum-extra = { workspace = true }
+tower-http = { workspace = true }
facet = { workspace = true }
figue = "*"
fs-err = { workspace = true }
diff --git a/quire-ci/src/quire.rs b/quire-ci/src/quire.rs
index 360c0a1..2521b4d 100644
--- a/quire-ci/src/quire.rs
+++ b/quire-ci/src/quire.rs
@@ -30,6 +30,7 @@ pub use quire_core::telemetry::SentryConfig;
pub struct QuireCi {
config: GlobalConfig,
db: Db,
+ hmac_key: Vec<u8>,
}
impl QuireCi {
@@ -40,8 +41,18 @@ impl QuireCi {
}
let fennel = Fennel::new().into_diagnostic()?;
let config: GlobalConfig = fennel.load_file(&config_path).into_diagnostic()?;
+ let hmac_key = config
+ .webhook_secret
+ .reveal()
+ .into_diagnostic()?
+ .as_bytes()
+ .to_vec();
let db = Db::open(&base_dir.join("quire-ci.db")).into_diagnostic()?;
- Ok(Self { config, db })
+ Ok(Self {
+ config,
+ db,
+ hmac_key,
+ })
}
pub fn config(&self) -> &GlobalConfig {
@@ -51,6 +62,10 @@ impl QuireCi {
pub fn db(&self) -> &Db {
&self.db
}
+
+ pub fn hmac_key(&self) -> &[u8] {
+ &self.hmac_key
+ }
}
#[cfg(test)]
diff --git a/quire-ci/src/server.rs b/quire-ci/src/server.rs
index cd5364b..7434738 100644
--- a/quire-ci/src/server.rs
+++ b/quire-ci/src/server.rs
@@ -1,14 +1,19 @@
use std::net::SocketAddr;
use axum::Router;
-use axum::extract::State;
-use axum::http::{HeaderMap, StatusCode};
+use axum::extract::{MatchedPath, State};
+use axum::http::{HeaderMap, Request, StatusCode};
use axum::response::IntoResponse;
use axum::routing::{get, post};
+use axum_extra::TypedHeader;
+use axum_extra::headers::Authorization;
+use axum_extra::headers::authorization::Credentials;
use hmac::{Hmac, KeyInit, Mac};
use quire_core::event::PushEvent;
use quire_core::telemetry::{self, FmtMode};
use sha2::Sha256;
+use tower_http::trace::TraceLayer;
+use tracing::info_span;
use crate::quire::QuireCi;
@@ -31,17 +36,9 @@ enum WebhookError {
#[error(transparent)]
InvalidPayload(#[from] serde_json::Error),
#[error(transparent)]
- SecretUnavailable(#[from] quire_core::secret::Error),
- #[error(transparent)]
Db(#[from] rusqlite::Error),
}
-impl From<hex::FromHexError> for WebhookError {
- fn from(_: hex::FromHexError) -> Self {
- Self::InvalidSignature
- }
-}
-
impl From<hmac::digest::MacError> for WebhookError {
fn from(_: hmac::digest::MacError) -> Self {
Self::InvalidSignature
@@ -55,14 +52,28 @@ impl IntoResponse for WebhookError {
StatusCode::UNAUTHORIZED
}
WebhookError::InvalidPayload(_) => StatusCode::BAD_REQUEST,
- WebhookError::SecretUnavailable(_) | WebhookError::Db(_) => {
- StatusCode::INTERNAL_SERVER_ERROR
- }
+ WebhookError::Db(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
.into_response()
}
}
+struct HmacSha256Sig(Vec<u8>);
+
+impl Credentials for HmacSha256Sig {
+ const SCHEME: &'static str = "HMAC-SHA256";
+
+ fn decode(value: &axum::http::HeaderValue) -> Option<Self> {
+ let hex_str = value.to_str().ok()?.strip_prefix("HMAC-SHA256 ")?;
+ hex::decode(hex_str).ok().map(Self)
+ }
+
+ fn encode(&self) -> axum::http::HeaderValue {
+ axum::http::HeaderValue::from_str(&format!("HMAC-SHA256 {}", hex::encode(&self.0)))
+ .expect("hex is always a valid header value")
+ }
+}
+
struct HmacSha256Auth(Vec<u8>);
impl<S: Send + Sync> axum::extract::FromRequestParts<S> for HmacSha256Auth {
@@ -70,15 +81,20 @@ impl<S: Send + Sync> axum::extract::FromRequestParts<S> for HmacSha256Auth {
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
- _state: &S,
+ state: &S,
) -> std::result::Result<Self, WebhookError> {
- let hex_str = parts
- .headers
- .get(axum::http::header::AUTHORIZATION)
- .and_then(|v| v.to_str().ok())
- .and_then(|s| s.strip_prefix("HMAC-SHA256 "))
- .ok_or(WebhookError::MissingSignature)?;
- Ok(Self(hex::decode(hex_str)?))
+ use axum::extract::FromRequestParts;
+
+ let Some(TypedHeader(Authorization(sig))) =
+ <TypedHeader<Authorization<HmacSha256Sig>> as FromRequestParts<S>>::from_request_parts(
+ parts, state,
+ )
+ .await
+ .ok()
+ else {
+ return Err(WebhookError::MissingSignature);
+ };
+ Ok(Self(sig.0))
}
}
@@ -88,16 +104,8 @@ async fn webhook(
headers: HeaderMap,
body: axum::body::Bytes,
) -> std::result::Result<StatusCode, WebhookError> {
- let secret_bytes = quire
- .config()
- .webhook_secret
- .reveal()
- .inspect_err(|e| tracing::error!(error = %e, "failed to resolve webhook secret"))?
- .as_bytes()
- .to_vec();
-
let mut mac =
- Hmac::<Sha256>::new_from_slice(&secret_bytes).expect("HMAC accepts any key length");
+ Hmac::<Sha256>::new_from_slice(quire.hmac_key()).expect("HMAC accepts any key length");
mac.update(&body);
mac.verify_slice(&provided_bytes)?;
@@ -161,6 +169,15 @@ pub async fn run(quire: QuireCi) -> Result<()> {
.route("/health", get(health))
.route("/", get(index))
.route("/webhook", post(webhook))
+ .layer(
+ TraceLayer::new_for_http().make_span_with(|request: &Request<_>| {
+ let matched_path = request
+ .extensions()
+ .get::<MatchedPath>()
+ .map(MatchedPath::as_str);
+ info_span!("http_request", method = ?request.method(), matched_path)
+ }),
+ )
.with_state(quire);
let addr = SocketAddr::from(([0, 0, 0, 0], port));
@@ -205,7 +222,7 @@ mod tests {
"pushed_at": "2026-05-01T00:00:00Z",
"refs": [
{
- "ref_name": "refs/heads/main",
+ "ref": "refs/heads/main",
"old_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"new_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
}
diff --git a/quire-core/src/event.rs b/quire-core/src/event.rs
index 55adfcd..66d4752 100644
--- a/quire-core/src/event.rs
+++ b/quire-core/src/event.rs
@@ -1,6 +1,7 @@
/// A single ref update from a push.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)]
pub struct PushRef {
+ #[serde(rename = "ref")]
pub ref_name: String,
pub old_sha: String,
pub new_sha: String,