Security key

This commit is contained in:
Jack Mechem 2026-05-01 14:14:58 -07:00
parent 6c6ee030cd
commit 56c555d699
5 changed files with 759 additions and 288 deletions

712
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -27,5 +27,6 @@ tokio = { version = "1.50.0", features = [
"process",
] }
zbus = "5.14.0"
totp-rs = { version = "5.7", features = ["gen_secret", "otpauth"] }
base32 = "0.5.1"
webauthn-rs = "0.5"
url = "2"
uuid = { version = "1", features = ["v4", "serde"] }

View file

@ -44,7 +44,7 @@
pname = "server-dash-api";
version = "0.1.0";
src = ./.;
cargoHash = "sha256-bOxtdDv0qtB61x9K/ik14DXCV1QfOBJQu7yQBx3L0wc=";
cargoLock.lockFile = ./Cargo.lock;
inherit nativeBuildInputs buildInputs;
OPENSSL_NO_VENDOR = 1;
PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig";
@ -101,7 +101,7 @@
systemd.tmpfiles.rules = [
"d /var/lib/server-dash-api 0750 server-dash-api server-dash-api -"
"d /var/lib/server-dash-api/google-auth 0755 server-dash-api server-dash-api -"
"d /var/lib/server-dash-api/webauthn-credentials 0750 server-dash-api server-dash-api -"
];
security.polkit.extraConfig = ''

View file

@ -1,21 +1,61 @@
use axum::body::Body;
use axum::extract::State;
use axum::{
http::HeaderMap, http::Request, http::StatusCode, middleware::Next, response::IntoResponse,
response::Json, response::Response,
http::HeaderMap,
http::Request,
http::StatusCode,
middleware::Next,
response::IntoResponse,
response::Json,
response::Response,
};
use base64::{Engine, engine::general_purpose};
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::OnceLock;
use std::time::{SystemTime, UNIX_EPOCH};
use totp_rs::{Algorithm, Secret, TOTP};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use url::Url;
use uuid::Uuid;
use webauthn_rs::prelude::*;
use yescrypt::{PasswordHash, PasswordVerifier, Yescrypt};
static JWT_SECRET: OnceLock<String> = OnceLock::new();
const ROTATION_DAYS: u64 = 7;
const TOTP_SECRET_PATH: &str = "/var/lib/server-dash-api/google-auth";
const CREDENTIAL_DIR: &str = "/var/lib/server-dash-api/webauthn-credentials";
const CHALLENGE_TTL: Duration = Duration::from_secs(300);
const RP_ID: &str = "jackmechem.dev";
const RP_ORIGIN: &str = "https://dashboard.jackmechem.dev";
#[derive(Serialize, Deserialize)]
struct StoredCredentials {
user_id: Uuid,
credentials: Vec<Passkey>,
}
pub struct AppState {
pub webauthn: Webauthn,
pending_auth: Mutex<HashMap<String, (PasskeyAuthentication, Instant, String)>>,
pending_reg: Mutex<HashMap<String, (PasskeyRegistration, Instant, String, Uuid)>>,
}
impl AppState {
pub fn new() -> Self {
let rp_origin = Url::parse(RP_ORIGIN).expect("Invalid RP origin");
let webauthn = WebauthnBuilder::new(RP_ID, &rp_origin)
.expect("Invalid WebAuthn config")
.rp_name("Server Dashboard")
.build()
.expect("Failed to build WebAuthn");
Self {
webauthn,
pending_auth: Mutex::new(HashMap::new()),
pending_reg: Mutex::new(HashMap::new()),
}
}
}
fn secret_path() -> PathBuf {
PathBuf::from("/var/lib/server-dash-api/jwt_secret")
@ -99,17 +139,13 @@ pub fn verify_token(headers: &HeaderMap) -> bool {
.is_ok()
}
pub fn decode_basic_auth(headers: &HeaderMap) -> Option<(String, String, String)> {
pub fn decode_basic_auth(headers: &HeaderMap) -> Option<(String, String)> {
let val = headers.get("Authorization")?.to_str().ok()?;
let encoded = val.strip_prefix("Basic ")?;
let decoded = general_purpose::STANDARD.decode(encoded).ok()?;
let s = String::from_utf8(decoded).ok()?;
let (user, rest) = s.split_once(':')?;
if rest.len() < 6 {
return None;
}
let (password, totp) = rest.split_at(rest.len() - 6);
Some((user.to_string(), password.to_string(), totp.to_string()))
let (user, password) = s.split_once(':')?;
Some((user.to_string(), password.to_string()))
}
fn verify_password(username: &str, password: &str) -> bool {
@ -147,39 +183,32 @@ fn verify_shadow_hash(password: &str, hash: &str) -> bool {
.is_ok()
}
fn verify_totp(username: &str, totp_code: &str) -> bool {
let path = PathBuf::from(TOTP_SECRET_PATH).join(username);
let secret_file = match std::fs::read_to_string(&path) {
Ok(f) => f,
Err(e) => {
println!("Failed to read TOTP secret: {}", e);
return false;
}
};
let secret_b32 = secret_file.lines().next().unwrap_or("").trim().to_string();
let totp = match TOTP::new(
Algorithm::SHA1,
6,
1,
30,
Secret::Encoded(secret_b32).to_bytes().unwrap(),
None,
username.to_string(),
) {
Ok(t) => t,
Err(e) => {
println!("Failed to create TOTP: {:?}", e);
return false;
}
};
totp.check_current(totp_code).unwrap_or(false)
fn load_credentials(username: &str) -> Option<StoredCredentials> {
let path = PathBuf::from(CREDENTIAL_DIR).join(format!("{}.json", username));
let data = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&data).ok()
}
pub fn verify_system_credentials(username: &str, password: &str, totp: &str) -> bool {
verify_password(username, password) && verify_totp(username, totp)
fn save_credentials(username: &str, creds: &StoredCredentials) -> Result<(), String> {
let dir = PathBuf::from(CREDENTIAL_DIR);
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let path = dir.join(format!("{}.json", username));
let data = serde_json::to_string(creds).map_err(|e| e.to_string())?;
std::fs::write(&path, &data).map_err(|e| e.to_string())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).ok();
}
Ok(())
}
fn generate_session_id() -> String {
format!(
"{:016x}{:016x}",
rand::random::<u64>(),
rand::random::<u64>()
)
}
pub async fn require_auth(headers: HeaderMap, request: Request<Body>, next: Next) -> Response {
@ -190,23 +219,205 @@ pub async fn require_auth(headers: HeaderMap, request: Request<Body>, next: Next
}
}
// POST /auth/login
pub async fn post_login(headers: HeaderMap) -> impl IntoResponse {
let (username, password, totp) = match decode_basic_auth(&headers) {
// POST /auth/login — verifies password, returns a WebAuthn challenge for the YubiKey
pub async fn post_login(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> impl IntoResponse {
let (username, password) = match decode_basic_auth(&headers) {
Some(c) => c,
None => {
return (
StatusCode::UNAUTHORIZED,
"Missing or invalid Authorization header",
)
.into_response();
return (StatusCode::UNAUTHORIZED, "Missing or invalid Authorization header")
.into_response()
}
};
if !verify_system_credentials(&username, &password, &totp) {
if !verify_password(&username, &password) {
return (StatusCode::UNAUTHORIZED, "Invalid credentials").into_response();
}
let stored = match load_credentials(&username) {
Some(s) => s,
None => {
return (StatusCode::UNAUTHORIZED, "No YubiKey registered for this user")
.into_response()
}
};
let (rcr, auth_state) = match state
.webauthn
.start_passkey_authentication(&stored.credentials)
{
Ok(r) => r,
Err(e) => {
println!("WebAuthn start auth error: {:?}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "WebAuthn error").into_response();
}
};
let session_id = generate_session_id();
{
let mut pending = state.pending_auth.lock().unwrap();
pending.retain(|_, (_, created, _)| created.elapsed() < CHALLENGE_TTL);
pending.insert(session_id.clone(), (auth_state, Instant::now(), username));
}
(
StatusCode::OK,
Json(serde_json::json!({
"session_id": session_id,
"challenge": rcr,
})),
)
.into_response()
}
#[derive(Deserialize)]
pub struct VerifyRequest {
session_id: String,
credential: PublicKeyCredential,
}
// POST /auth/verify — verifies the YubiKey assertion and returns a JWT
pub async fn post_verify(
State(state): State<Arc<AppState>>,
Json(body): Json<VerifyRequest>,
) -> impl IntoResponse {
let (auth_state, username) = {
let mut pending = state.pending_auth.lock().unwrap();
match pending.remove(&body.session_id) {
Some((s, created, u)) if created.elapsed() < CHALLENGE_TTL => (s, u),
Some(_) => return (StatusCode::UNAUTHORIZED, "Challenge expired").into_response(),
None => return (StatusCode::UNAUTHORIZED, "Invalid session").into_response(),
}
};
let auth_result = match state
.webauthn
.finish_passkey_authentication(&body.credential, &auth_state)
{
Ok(r) => r,
Err(e) => {
println!("WebAuthn finish auth error: {:?}", e);
return (StatusCode::UNAUTHORIZED, "WebAuthn verification failed").into_response();
}
};
// Persist updated credential counter
if let Some(mut stored) = load_credentials(&username) {
for cred in &mut stored.credentials {
cred.update_credential(&auth_result);
}
save_credentials(&username, &stored).ok();
}
let token = create_token(&username);
(StatusCode::OK, Json(serde_json::json!({ "token": token }))).into_response()
}
// POST /auth/register/start — verifies password, returns a WebAuthn registration challenge
pub async fn post_register_start(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> impl IntoResponse {
let (username, password) = match decode_basic_auth(&headers) {
Some(c) => c,
None => {
return (StatusCode::UNAUTHORIZED, "Missing or invalid Authorization header")
.into_response()
}
};
if !verify_password(&username, &password) {
return (StatusCode::UNAUTHORIZED, "Invalid credentials").into_response();
}
let stored = load_credentials(&username);
let user_id = stored.as_ref().map(|s| s.user_id).unwrap_or_else(Uuid::new_v4);
let exclude: Option<Vec<CredentialID>> = stored.as_ref().map(|s| {
s.credentials
.iter()
.map(|c| c.cred_id().clone())
.collect()
});
let (ccr, reg_state) = match state
.webauthn
.start_passkey_registration(user_id, &username, &username, exclude)
{
Ok(r) => r,
Err(e) => {
println!("WebAuthn start reg error: {:?}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "WebAuthn error").into_response();
}
};
let session_id = generate_session_id();
{
let mut pending = state.pending_reg.lock().unwrap();
pending.retain(|_, (_, created, _, _)| created.elapsed() < CHALLENGE_TTL);
pending.insert(
session_id.clone(),
(reg_state, Instant::now(), username, user_id),
);
}
(
StatusCode::OK,
Json(serde_json::json!({
"session_id": session_id,
"challenge": ccr,
})),
)
.into_response()
}
#[derive(Deserialize)]
pub struct RegisterFinishRequest {
session_id: String,
credential: RegisterPublicKeyCredential,
}
// POST /auth/register/finish — completes YubiKey enrollment and saves the credential
pub async fn post_register_finish(
State(state): State<Arc<AppState>>,
Json(body): Json<RegisterFinishRequest>,
) -> impl IntoResponse {
let (reg_state, username, user_id) = {
let mut pending = state.pending_reg.lock().unwrap();
match pending.remove(&body.session_id) {
Some((s, created, u, id)) if created.elapsed() < CHALLENGE_TTL => (s, u, id),
Some(_) => return (StatusCode::UNAUTHORIZED, "Challenge expired").into_response(),
None => return (StatusCode::UNAUTHORIZED, "Invalid session").into_response(),
}
};
let passkey = match state
.webauthn
.finish_passkey_registration(&body.credential, &reg_state)
{
Ok(p) => p,
Err(e) => {
println!("WebAuthn finish reg error: {:?}", e);
return (StatusCode::BAD_REQUEST, "WebAuthn registration failed").into_response();
}
};
let mut stored = load_credentials(&username).unwrap_or(StoredCredentials {
user_id,
credentials: vec![],
});
stored.credentials.push(passkey);
if let Err(e) = save_credentials(&username, &stored) {
println!("Failed to save credentials: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to save credential").into_response();
}
(
StatusCode::OK,
Json(serde_json::json!({ "message": "YubiKey registered successfully" })),
)
.into_response()
}

View file

@ -1,6 +1,7 @@
use axum::middleware;
use axum::response::Redirect;
use axum::{Router, routing::get, routing::post};
use std::sync::Arc;
mod auth;
mod config;
@ -9,6 +10,8 @@ mod routes;
#[tokio::main]
async fn main() {
let state = Arc::new(auth::AppState::new());
let protected = Router::new()
.route("/stats", get(routes::stats::get_stats))
.route(
@ -34,7 +37,11 @@ async fn main() {
let app = Router::new()
.route("/", get(|| async { Redirect::permanent("/stats") }))
.route("/auth/login", post(auth::post_login))
.merge(protected);
.route("/auth/verify", post(auth::post_verify))
.route("/auth/register/start", post(auth::post_register_start))
.route("/auth/register/finish", post(auth::post_register_finish))
.merge(protected)
.with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3001")
.await