From 3848e62a77defeae75d97adcdbab98be42fb9550 Mon Sep 17 00:00:00 2001 From: Felix Hellborg Date: Sun, 12 Apr 2026 20:39:30 +0200 Subject: [PATCH] Remove Ed25519 admin signature because it was vulnerable to replay attacks, potentially granting unjustified host privilages. Now, hosts are authenticated via a simpler opaque random token. --- frontend/src/routes/login.tsx | 24 +----- .../src/signatures/e2e-tests/load.test.ts | 20 ++--- rustsystem-server/src/admin_auth.rs | 73 ++++--------------- rustsystem-server/src/api/host/new_voter.rs | 15 ++-- rustsystem-server/src/api/host/reset_login.rs | 8 +- rustsystem-server/src/api/login.rs | 17 +++-- rustsystem-server/tests/inprocess/mod.rs | 2 +- 7 files changed, 45 insertions(+), 114 deletions(-) diff --git a/frontend/src/routes/login.tsx b/frontend/src/routes/login.tsx index 38930e2..201d6f6 100644 --- a/frontend/src/routes/login.tsx +++ b/frontend/src/routes/login.tsx @@ -8,27 +8,16 @@ export const Route = createFileRoute("/login")({ validateSearch: (search: Record) => ({ muuid: (search.muuid as string) || "", uuuid: (search.uuuid as string) || "", - admin_msg: search.admin_msg as string | undefined, - admin_sig: search.admin_sig as string | undefined, + admin_token: search.admin_token as string | undefined, }), component: LoginPage, }); -// ─── Helpers ────────────────────────────────────────────────────────────────── - -function hexToBytes(hex: string): number[] { - const bytes: number[] = []; - for (let i = 0; i < hex.length; i += 2) { - bytes.push(parseInt(hex.substring(i, i + 2), 16)); - } - return bytes; -} - // ─── Page ───────────────────────────────────────────────────────────────────── function LoginPage() { - const { muuid, uuuid, admin_msg, admin_sig } = Route.useSearch(); - const claimsHost = admin_msg && admin_sig; + const { muuid, uuuid, admin_token } = Route.useSearch(); + const claimsHost = !!admin_token; const [error, setError] = useState(null); const nav = useNavigate(); @@ -47,16 +36,11 @@ function LoginPage() { return; } - const admin_cred = - admin_msg && admin_sig - ? { msg: hexToBytes(admin_msg), sig: admin_sig } - : undefined; - let res: Response; try { res = await apiFetch("/api/login", { method: "POST", - body: JSON.stringify({ uuuid, muuid, admin_cred }), + body: JSON.stringify({ uuuid, muuid, admin_token }), }); } catch { setError("Could not reach the server. Check your connection."); diff --git a/frontend/src/signatures/e2e-tests/load.test.ts b/frontend/src/signatures/e2e-tests/load.test.ts index 4265ed1..7ac2f0b 100644 --- a/frontend/src/signatures/e2e-tests/load.test.ts +++ b/frontend/src/signatures/e2e-tests/load.test.ts @@ -144,10 +144,10 @@ class ConcurrentClient { /** * Log in by following an invite link (voter or host). * - * Host invite links carry admin_msg (hex-encoded bytes) and admin_sig as - * extra query parameters. When present they are forwarded as admin_cred in - * the login body, causing the server to issue a host JWT (is_host=true). - * Without them a regular voter JWT is issued. Mirrors the logic in login.tsx. + * Host invite links carry an admin_token query parameter. When present it is + * forwarded in the login body, causing the server to issue a host JWT + * (is_host=true). Without it a regular voter JWT is issued. Mirrors the + * logic in login.tsx. */ async loginFromInviteLink( inviteLink: string, @@ -156,19 +156,11 @@ class ConcurrentClient { const url = new URL(inviteLink, BASE_URL); const muuid = url.searchParams.get("muuid")!; const uuuid = url.searchParams.get("uuuid")!; - const adminMsg = url.searchParams.get("admin_msg"); - const adminSig = url.searchParams.get("admin_sig"); - const admin_cred = - adminMsg && adminSig - ? { - msg: adminMsg.match(/.{2}/g)!.map((b) => parseInt(b, 16)), - sig: adminSig, - } - : undefined; + const admin_token = url.searchParams.get("admin_token") ?? undefined; const serverRes = await this.req(`${BASE_URL}/api/login`, { method: "POST", - body: JSON.stringify({ uuuid, muuid, admin_cred }), + body: JSON.stringify({ uuuid, muuid, admin_token }), }); if (!serverRes.ok) throw new Error(`voter server login HTTP ${serverRes.status}`); diff --git a/rustsystem-server/src/admin_auth.rs b/rustsystem-server/src/admin_auth.rs index 42672f5..958747a 100644 --- a/rustsystem-server/src/admin_auth.rs +++ b/rustsystem-server/src/admin_auth.rs @@ -1,77 +1,30 @@ use std::collections::HashSet; -use ed25519_dalek::{ - Signature, SignatureError, SigningKey, VerifyingKey, ed25519::signature::SignerMut, -}; use rand_core::{OsRng, RngCore}; -use serde::{Deserialize, Serialize}; - -pub const MSG_SIZE: usize = 32; pub struct AdminAuthority { - signing_key: SigningKey, - verifying_key: VerifyingKey, - expired_msgs: HashSet<[u8; MSG_SIZE]>, -} - -#[derive(Deserialize, Serialize)] -pub struct AdminCred { - msg: [u8; MSG_SIZE], - sig: String, -} -impl AdminCred { - pub fn new(msg: [u8; MSG_SIZE], sig: String) -> Self { - Self { msg, sig } - } - - pub fn get_msg(&self) -> &[u8; MSG_SIZE] { - &self.msg - } - - pub fn get_sig(&self) -> Result { - let bytes = hex::decode(&self.sig).map_err(|_| SignatureError::new())?; - if bytes.len() != 64 { - return Err(SignatureError::new()); - } - let mut sig_bytes = [0u8; 64]; - sig_bytes.copy_from_slice(&bytes); - Ok(Signature::from_bytes(&sig_bytes)) - } - - pub fn get_sig_str(&self) -> &str { - &self.sig - } + pending: HashSet<[u8; 16]>, } impl AdminAuthority { pub fn new() -> Self { - let signing_key = SigningKey::generate(&mut OsRng); - let verifying_key = VerifyingKey::from(&signing_key); - Self { - signing_key, - verifying_key, - expired_msgs: HashSet::new(), + pending: HashSet::new(), } } - pub fn new_token(&mut self) -> AdminCred { - let mut msg = [0u8; 32]; - OsRng.fill_bytes(&mut msg); - let signature = self.signing_key.sign(&msg); - AdminCred::new(msg, hex::encode(signature.to_bytes())) + /// Generate a new one-time admin token. Returns 16 random bytes that must + /// be redeemed exactly once via [`redeem_token`]. + pub fn new_token(&mut self) -> [u8; 16] { + let mut token = [0u8; 16]; + OsRng.fill_bytes(&mut token); + self.pending.insert(token); + token } - pub fn validate_token(&mut self, cred: AdminCred) -> bool { - if let Ok(sig) = cred.get_sig() - && !self.expired_msgs.contains(cred.get_msg()) - { - self.expired_msgs.insert(*cred.get_msg()); - return self - .verifying_key - .verify_strict(cred.get_msg(), &sig) - .is_ok(); - } - false + /// Consume a token. Returns `true` if the token was present (and removes + /// it), `false` if it was never issued or already redeemed. + pub fn redeem_token(&mut self, token: [u8; 16]) -> bool { + self.pending.remove(&token) } } diff --git a/rustsystem-server/src/api/host/new_voter.rs b/rustsystem-server/src/api/host/new_voter.rs index 04db9b4..7b29346 100644 --- a/rustsystem-server/src/api/host/new_voter.rs +++ b/rustsystem-server/src/api/host/new_voter.rs @@ -14,7 +14,6 @@ use tracing::info; use rustsystem_core::{APIError, APIErrorCode, APIHandler, Method}; use uuid::Uuid; -use crate::admin_auth::AdminCred; use crate::{API_ENDPOINT_SERVER, AppState, MUuid, UUuid, Voter}; use super::auth::AuthHost; @@ -69,7 +68,7 @@ impl APIHandler for NewVoter { return Err(APIError::from_error_code(APIErrorCode::InvalidState)); } - let admin_cred = { + let admin_token = { // Acquire voters.write() first, then admin_auth.write() if needed — // consistent with the voters → admin_auth lock ordering. let mut voters = meeting.voters.write().await; @@ -100,7 +99,7 @@ impl APIHandler for NewVoter { "Voter slot created" ); - let (qr_svg, invite_link) = gen_qr_code_with_link(auth.muuid, new_uuuid, admin_cred)?; + let (qr_svg, invite_link) = gen_qr_code_with_link(auth.muuid, new_uuuid, admin_token)?; Ok(Json(QrCodeResponse { qr_svg, invite_link, @@ -111,15 +110,11 @@ impl APIHandler for NewVoter { pub fn gen_qr_code_with_link( muuid: MUuid, uuuid: UUuid, - admin_cred: Option, + admin_token: Option<[u8; 16]>, ) -> Result<(String, String), APIError> { let mut url = format!("{API_ENDPOINT_SERVER}/login?muuid={muuid}&uuuid={uuuid}"); - if let Some(admin_cred) = admin_cred { - url.push_str(&format!( - "&admin_msg={}&admin_sig={}", - hex::encode(admin_cred.get_msg()), - admin_cred.get_sig_str() - )); + if let Some(token) = admin_token { + url.push_str(&format!("&admin_token={}", hex::encode(token))); } let code = QrCode::with_error_correction_level(url.as_bytes(), EcLevel::H) diff --git a/rustsystem-server/src/api/host/reset_login.rs b/rustsystem-server/src/api/host/reset_login.rs index 8e53ff8..d1fafb1 100644 --- a/rustsystem-server/src/api/host/reset_login.rs +++ b/rustsystem-server/src/api/host/reset_login.rs @@ -34,7 +34,7 @@ impl APIHandler for ResetLogin { let meeting = state.get_meeting(auth.muuid).await?; - let (new_uuuid, admin_cred, voter_name) = { + let (new_uuuid, admin_token, voter_name) = { let mut voters = meeting.voters.write().await; let mut user = voters .remove(&user_uuuid) @@ -44,7 +44,7 @@ impl APIHandler for ResetLogin { // Lock ordering: voters → admin_auth. We hold voters.write() and now acquire // admin_auth.write() — this ordering is consistent across the codebase. - let admin_cred = if user.is_host { + let admin_token = if user.is_host { Some(meeting.admin_auth.write().await.new_token()) } else { None @@ -52,7 +52,7 @@ impl APIHandler for ResetLogin { let new_uuuid = UUuid::new_v4(); voters.insert(new_uuuid, user); - (new_uuuid, admin_cred, voter_name) + (new_uuuid, admin_token, voter_name) }; info!( @@ -63,7 +63,7 @@ impl APIHandler for ResetLogin { "Voter login reset — new invite link generated" ); - let (qr_svg, invite_link) = gen_qr_code_with_link(auth.muuid, new_uuuid, admin_cred)?; + let (qr_svg, invite_link) = gen_qr_code_with_link(auth.muuid, new_uuuid, admin_token)?; Ok(Json(QrCodeResponse { qr_svg, invite_link, diff --git a/rustsystem-server/src/api/login.rs b/rustsystem-server/src/api/login.rs index b5effae..1b21a37 100644 --- a/rustsystem-server/src/api/login.rs +++ b/rustsystem-server/src/api/login.rs @@ -8,7 +8,6 @@ use rustsystem_core::{APIError, APIErrorCode, APIHandler, Method}; use crate::{ AppState, - admin_auth::AdminCred, tokens::{get_meeting_jwt, new_cookie}, }; @@ -16,7 +15,7 @@ use crate::{ pub struct LoginRequest { pub uuuid: String, pub muuid: String, - pub admin_cred: Option, + pub admin_token: Option, } /// Endpoint for logging in and claiming a UUID (voter) @@ -63,9 +62,17 @@ impl APIHandler for Login { // Signal the invite watcher with the voter's name. meeting.invite_auth.write().await.notify_login(voter_name.clone()); - // Validate optional admin credentials. - let is_host = if let Some(admin_cred) = body.admin_cred { - meeting.admin_auth.write().await.validate_token(admin_cred) + // Validate optional admin token. + let is_host = if let Some(token_hex) = body.admin_token { + if let Ok(bytes) = hex::decode(&token_hex) { + if let Ok(token) = <[u8; 16]>::try_from(bytes.as_slice()) { + meeting.admin_auth.write().await.redeem_token(token) + } else { + false + } + } else { + false + } } else { false }; diff --git a/rustsystem-server/tests/inprocess/mod.rs b/rustsystem-server/tests/inprocess/mod.rs index 1cd5ead..0bdcd82 100644 --- a/rustsystem-server/tests/inprocess/mod.rs +++ b/rustsystem-server/tests/inprocess/mod.rs @@ -93,7 +93,7 @@ async fn voter_login(app: &MockApp, res: Response) -> Response { serde_json::to_value(LoginRequest { uuuid, muuid, - admin_cred: None, + admin_token: None, }) .unwrap(), None,