From 9be303cddb8d5fa9fc92fc10435e396751f5bc8a Mon Sep 17 00:00:00 2001 From: Matt Gibson Date: Thu, 15 Jan 2026 04:10:01 -0800 Subject: [PATCH] Add authentication to publisher endpoints The plan is to limit access to the publisher through a firewall, but this further limits access in a belt-and-suspenders fashion. --- akd/Cargo.lock | 1 + akd/crates/publisher/Cargo.toml | 1 + akd/crates/publisher/src/config.rs | 16 +++++++++++ akd/crates/publisher/src/lib.rs | 9 ++++-- akd/crates/publisher/src/routes/mod.rs | 40 +++++++++++++++++++++++++- 5 files changed, 64 insertions(+), 3 deletions(-) diff --git a/akd/Cargo.lock b/akd/Cargo.lock index d41761adc2..11ec513e72 100644 --- a/akd/Cargo.lock +++ b/akd/Cargo.lock @@ -2147,6 +2147,7 @@ dependencies = [ "common", "config", "serde", + "subtle", "thiserror 2.0.17", "tiberius", "tokio", diff --git a/akd/crates/publisher/Cargo.toml b/akd/crates/publisher/Cargo.toml index d9fe46254e..b3947c4c80 100644 --- a/akd/crates/publisher/Cargo.toml +++ b/akd/crates/publisher/Cargo.toml @@ -24,6 +24,7 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } uuid = { workspace = true } async-std = { version = "1.13.2", features = ["attributes"] } +subtle = "2.6.1" [target.'cfg(target_os = "macos")'.dependencies] tiberius = { version = "0.12.3", default-features = false, features = [ diff --git a/akd/crates/publisher/src/config.rs b/akd/crates/publisher/src/config.rs index d65fcc1d44..6be2ccbabc 100644 --- a/akd/crates/publisher/src/config.rs +++ b/akd/crates/publisher/src/config.rs @@ -1,10 +1,12 @@ use akd_storage::akd_storage_config::AkdStorageConfig; use config::{Config, ConfigError, Environment, File}; use serde::Deserialize; +use subtle::ConstantTimeEq; use uuid::Uuid; const DEFAULT_EPOCH_DURATION_MS: u64 = 30000; // 30 seconds +/// Application configuration for the AKD Publisher #[derive(Clone, Debug, Deserialize)] pub struct ApplicationConfig { pub storage: AkdStorageConfig, @@ -15,6 +17,12 @@ pub struct ApplicationConfig { /// The address the web server will bind to. Defaults to "127.0.0.1:3000". #[serde(default = "default_web_server_bind_address")] web_server_bind_address: String, + /// The API key required to access the web server endpoints. + /// + /// NOTE: constant-time comparison is used, but mismatched string length cause immediate failure. + /// For this reason, timing attacks can be used to at least determine the valid key length and a + /// sufficiently long key should be used to mitigate this risk. + pub web_server_api_key: String, // web_server: WebServerConfig, } @@ -22,6 +30,7 @@ fn default_web_server_bind_address() -> String { "127.0.0.1:3000".to_string() } +/// Configuration for how the AKD updates #[derive(Clone, Debug, Deserialize)] pub struct PublisherConfig { /// The duration of each publishing epoch in milliseconds. Defaults to 30 seconds. @@ -90,6 +99,13 @@ impl ApplicationConfig { .parse() .expect("Invalid web server bind address") } + + pub fn api_key_valid(&self, api_key: &str) -> bool { + self.web_server_api_key + .as_bytes() + .ct_eq(api_key.as_bytes()) + .into() + } } impl PublisherConfig { diff --git a/akd/crates/publisher/src/lib.rs b/akd/crates/publisher/src/lib.rs index 9ce54fbe85..c4ab711523 100644 --- a/akd/crates/publisher/src/lib.rs +++ b/akd/crates/publisher/src/lib.rs @@ -1,6 +1,6 @@ use akd_storage::{PublishQueue, PublishQueueType}; use anyhow::{Context, Result}; -use axum::Router; +use axum::{middleware::from_fn_with_state, Router}; use bitwarden_akd_configuration::BitwardenV1Configuration; use common::BitAkdDirectory; use tokio::{net::TcpListener, sync::broadcast::Receiver}; @@ -10,6 +10,7 @@ mod config; mod routes; pub use crate::config::ApplicationConfig; +use crate::routes::auth; pub struct AppHandles { pub write_handle: tokio::task::JoinHandle<()>, @@ -127,9 +128,13 @@ async fn start_web( config: &ApplicationConfig, mut shutdown_rx: Receiver<()>, ) -> Result<()> { - let app_state = routes::AppState { publish_queue }; + let app_state = routes::AppState { + publish_queue, + app_config: config.clone(), + }; let app = Router::new() .merge(routes::api_routes()) + .route_layer(from_fn_with_state(app_state.clone(), auth)) .with_state(app_state); let listener = TcpListener::bind(&config.socket_address()) diff --git a/akd/crates/publisher/src/routes/mod.rs b/akd/crates/publisher/src/routes/mod.rs index b11dce1d23..2dca060507 100644 --- a/akd/crates/publisher/src/routes/mod.rs +++ b/akd/crates/publisher/src/routes/mod.rs @@ -1,11 +1,20 @@ use akd_storage::PublishQueueType; -use axum::routing::{get, post}; +use axum::{ + extract::{Request, State}, + http::StatusCode, + middleware::Next, + response::Response, + routing::{get, post}, +}; + +use crate::ApplicationConfig; mod health; mod publish; #[derive(Clone)] pub(crate) struct AppState { + pub app_config: ApplicationConfig, pub publish_queue: PublishQueueType, } @@ -14,3 +23,32 @@ pub fn api_routes() -> axum::Router { .route("/health", get(health::health_handler)) .route("/publish", post(publish::publish_handler)) } + +pub async fn auth( + State(AppState { app_config, .. }): State, + req: Request, + next: Next, +) -> Result { + let auth_header = req + .headers() + .get("x-api-key") + .and_then(|header| header.to_str().ok()); + + let auth_header = if let Some(auth_header) = auth_header { + auth_header + } else { + return Err(StatusCode::UNAUTHORIZED); + }; + + tracing::trace!( + auth_header, + key = app_config.web_server_api_key, + "Authenticating request with provided API key" + ); + if app_config.api_key_valid(auth_header) { + // API key matches, proceed to the next handler + Ok(next.run(req).await) + } else { + Err(StatusCode::UNAUTHORIZED) + } +}