Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 4 additions & 20 deletions frontend/src/routes/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,16 @@ export const Route = createFileRoute("/login")({
validateSearch: (search: Record<string, unknown>) => ({
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<string | null>(null);

const nav = useNavigate();
Expand All @@ -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.");
Expand Down
20 changes: 6 additions & 14 deletions frontend/src/signatures/e2e-tests/load.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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}`);
Expand Down
73 changes: 13 additions & 60 deletions rustsystem-server/src/admin_auth.rs
Original file line number Diff line number Diff line change
@@ -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<Signature, SignatureError> {
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)
}
}
15 changes: 5 additions & 10 deletions rustsystem-server/src/api/host/new_voter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -111,15 +110,11 @@ impl APIHandler for NewVoter {
pub fn gen_qr_code_with_link(
muuid: MUuid,
uuuid: UUuid,
admin_cred: Option<AdminCred>,
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)
Expand Down
8 changes: 4 additions & 4 deletions rustsystem-server/src/api/host/reset_login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -44,15 +44,15 @@ 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
};

let new_uuuid = UUuid::new_v4();
voters.insert(new_uuuid, user);
(new_uuuid, admin_cred, voter_name)
(new_uuuid, admin_token, voter_name)
};

info!(
Expand All @@ -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,
Expand Down
17 changes: 12 additions & 5 deletions rustsystem-server/src/api/login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,14 @@ use rustsystem_core::{APIError, APIErrorCode, APIHandler, Method};

use crate::{
AppState,
admin_auth::AdminCred,
tokens::{get_meeting_jwt, new_cookie},
};

#[derive(Deserialize, Serialize)]
pub struct LoginRequest {
pub uuuid: String,
pub muuid: String,
pub admin_cred: Option<AdminCred>,
pub admin_token: Option<String>,
}

/// Endpoint for logging in and claiming a UUID (voter)
Expand Down Expand Up @@ -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
};
Expand Down
2 changes: 1 addition & 1 deletion rustsystem-server/tests/inprocess/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading