diff --git a/Client/tauri-client/src-tauri/src/commands.rs b/Client/tauri-client/src-tauri/src/commands.rs index bbfd0fd1..e734a34b 100644 --- a/Client/tauri-client/src-tauri/src/commands.rs +++ b/Client/tauri-client/src-tauri/src/commands.rs @@ -1,7 +1,7 @@ use serde_json::Value; use tauri_plugin_store::StoreExt; -use crate::constants::{CERTS_STORE, SETTINGS_STORE}; +use crate::constants::{CERTS_STORE, IDENTITY_PINS_STORE, SETTINGS_STORE}; /// Maximum length for a settings key to prevent denial-of-service. const MAX_SETTINGS_KEY_LEN: usize = 128; @@ -146,6 +146,102 @@ pub fn get_cert_fingerprint( Ok(value) } +// --------------------------------------------------------------------------- +// Voice E2EE identity-key pin commands (TOFU) +// --------------------------------------------------------------------------- +// +// Near-verbatim mirror of the cert-fingerprint commands above, but the store is +// keyed on `{host}:{userId}` and the value is a peer's base64 identity public +// key (opaque here — the JS side parses it) instead of a SHA-256 fingerprint. + +/// Max length for a base64 identity public key (DoS guard). A raw P-256 key is +/// 65 bytes (~88 base64 chars); an SPKI-wrapped one ~124. 512 is generous. +const MAX_IDENTITY_PIN_LEN: usize = 512; + +/// Store key for a peer's identity pin. A mismatch here (wrong separator, etc.) +/// would make pins silently fail to match and accept a MITM'd key, so it is a +/// pure, testable helper shared by both commands. +fn identity_pin_key(host: &str, user_id: &str) -> String { + format!("{host}:{user_id}") +} + +#[tauri::command] +pub fn store_identity_pin( + app: tauri::AppHandle, + host: String, + user_id: String, + pin: String, +) -> Result<(), String> { + if host.is_empty() || host.len() > 253 { + return Err("host must be 1-253 characters".into()); + } + // Validate host format: alphanumeric, dots, hyphens, colons (port), brackets (IPv6) + if !host.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']')) { + return Err("host contains invalid characters".into()); + } + if user_id.is_empty() || user_id.len() > 64 { + return Err("user_id must be 1-64 characters".into()); + } + if !user_id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')) { + return Err("user_id contains invalid characters".into()); + } + if pin.is_empty() || pin.len() > MAX_IDENTITY_PIN_LEN { + return Err("pin must be 1-512 characters".into()); + } + // Base64 charset (standard + url-safe + padding). Guards against garbage/DoS; + // the actual key parsing/verification happens on the JS side. + if !pin.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '=' | '-' | '_')) { + return Err("pin contains invalid characters".into()); + } + + let store = app + .store(IDENTITY_PINS_STORE) + .map_err(|e| format!("failed to open identity pins store: {e}"))?; + + let store_key = identity_pin_key(&host, &user_id); + // Capture old value before mutating so we can restore it if save fails. + let old_value = store.get(&store_key); + store.set(&store_key, Value::String(pin)); + if let Err(e) = store.save() { + // Restore previous in-memory state so a failed save during a re-pin + // doesn't silently drop the previously trusted identity key. + match old_value { + Some(v) => { store.set(&store_key, v); } + None => { let _ = store.delete(&store_key); } + } + return Err(format!("failed to persist identity pin: {e}")); + } + Ok(()) +} + +#[tauri::command] +pub fn get_identity_pin( + app: tauri::AppHandle, + host: String, + user_id: String, +) -> Result, String> { + if host.is_empty() { + return Err("host must not be empty".into()); + } + if user_id.is_empty() { + return Err("user_id must not be empty".into()); + } + + let store = app + .store(IDENTITY_PINS_STORE) + .map_err(|e| format!("failed to open identity pins store: {e}"))?; + + let value = store.get(&identity_pin_key(&host, &user_id)).and_then(|v| { + if let Value::String(s) = v { + Some(s) + } else { + None + } + }); + + Ok(value) +} + // --------------------------------------------------------------------------- // DevTools command // --------------------------------------------------------------------------- @@ -224,4 +320,10 @@ mod tests { let short = "aa:bb:cc"; assert_ne!(short.len(), 95); } + + #[test] + fn identity_pin_key_combines_host_and_user() { + assert_eq!(identity_pin_key("chat.example.com", "42"), "chat.example.com:42"); + assert_eq!(identity_pin_key("192.168.1.10:8443", "u_7"), "192.168.1.10:8443:u_7"); + } } diff --git a/Client/tauri-client/src-tauri/src/constants.rs b/Client/tauri-client/src-tauri/src/constants.rs index 95a32016..434daa8f 100644 --- a/Client/tauri-client/src-tauri/src/constants.rs +++ b/Client/tauri-client/src-tauri/src/constants.rs @@ -1,5 +1,8 @@ /// Tauri store file for persisted certificate fingerprints (TOFU pinning). pub const CERTS_STORE: &str = "certs.json"; +/// Tauri store file for pinned peer voice-E2EE identity public keys (TOFU). +pub const IDENTITY_PINS_STORE: &str = "identity_pins.json"; + /// Tauri store file for user settings and preferences. pub const SETTINGS_STORE: &str = "settings.json"; diff --git a/Client/tauri-client/src-tauri/src/credentials.rs b/Client/tauri-client/src-tauri/src/credentials.rs index b19261f8..06d7f855 100644 --- a/Client/tauri-client/src-tauri/src/credentials.rs +++ b/Client/tauri-client/src-tauri/src/credentials.rs @@ -129,6 +129,76 @@ pub fn delete_credential(host: String) -> Result<(), String> { } } +// --------------------------------------------------------------------------- +// Identity-key commands (F3: voice E2EE TOFU long-term identity keypair) +// --------------------------------------------------------------------------- +// +// Mirrors save/load/delete_credential, but the secret is a single opaque +// key blob (base64 PKCS8 private key) rather than a JSON credential struct, +// and it is stored under account `identity:{host}` to keep it distinct from +// the login credential entry (account `{host}`) in the same keyring service. + +/// Save the long-term identity private key for `host` to the system credential +/// store, under account `identity:{host}`. +#[tauri::command] +pub fn save_identity_key(host: String, key: String) -> Result<(), String> { + if host.is_empty() { + return Err("host must not be empty".into()); + } + if key.is_empty() { + return Err("key must not be empty".into()); + } + + let account = format!("identity:{host}"); + let entry = + Entry::new(SERVICE, &account).map_err(|e| format!("keyring entry error: {e}"))?; + entry + .set_password(&key) + .map_err(|e| format!("save_identity_key failed: {e}"))?; + + Ok(()) +} + +/// Load the identity private key for `host`. +/// +/// Returns `None` when no identity key exists for the given host. +#[tauri::command] +pub fn load_identity_key(host: String) -> Result, String> { + if host.is_empty() { + return Err("host must not be empty".into()); + } + + let account = format!("identity:{host}"); + let entry = + Entry::new(SERVICE, &account).map_err(|e| format!("keyring entry error: {e}"))?; + + match entry.get_password() { + Ok(s) => Ok(Some(s)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(format!("load_identity_key failed: {e}")), + } +} + +/// Delete the identity private key for `host`. +/// +/// Deleting a non-existent key is not treated as an error. +#[tauri::command] +pub fn delete_identity_key(host: String) -> Result<(), String> { + if host.is_empty() { + return Err("host must not be empty".into()); + } + + let account = format!("identity:{host}"); + let entry = + Entry::new(SERVICE, &account).map_err(|e| format!("keyring entry error: {e}"))?; + + match entry.delete_credential() { + Ok(()) => Ok(()), + Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(format!("delete_identity_key failed: {e}")), + } +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -172,6 +242,34 @@ mod tests { assert!(result.unwrap_err().contains("host must not be empty")); } + #[test] + fn save_identity_key_rejects_empty_host() { + let result = save_identity_key("".into(), "key".into()); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("host must not be empty")); + } + + #[test] + fn save_identity_key_rejects_empty_key() { + let result = save_identity_key("host".into(), "".into()); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("key must not be empty")); + } + + #[test] + fn load_identity_key_rejects_empty_host() { + let result = load_identity_key("".into()); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("host must not be empty")); + } + + #[test] + fn delete_identity_key_rejects_empty_host() { + let result = delete_identity_key("".into()); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("host must not be empty")); + } + #[test] fn credential_data_debug_redacts_sensitive_fields() { let data = CredentialData { diff --git a/Client/tauri-client/src-tauri/src/lib.rs b/Client/tauri-client/src-tauri/src/lib.rs index f6bb7435..4821bd80 100644 --- a/Client/tauri-client/src-tauri/src/lib.rs +++ b/Client/tauri-client/src-tauri/src/lib.rs @@ -28,6 +28,8 @@ pub fn run() { commands::save_settings, commands::store_cert_fingerprint, commands::get_cert_fingerprint, + commands::store_identity_pin, + commands::get_identity_pin, ws_proxy::ws_connect, ws_proxy::ws_send, ws_proxy::ws_disconnect, @@ -35,6 +37,9 @@ pub fn run() { credentials::save_credential, credentials::load_credential, credentials::delete_credential, + credentials::save_identity_key, + credentials::load_identity_key, + credentials::delete_identity_key, update_commands::check_client_update, update_commands::download_and_install_update, ptt::ptt_start, diff --git a/Client/tauri-client/src/components/CertMismatchModal.ts b/Client/tauri-client/src/components/CertMismatchModal.ts index d2b62617..9cbb278c 100644 --- a/Client/tauri-client/src/components/CertMismatchModal.ts +++ b/Client/tauri-client/src/components/CertMismatchModal.ts @@ -201,6 +201,106 @@ export function createCertFirstUseModal(options: CertFirstUseModalOptions): Moun return { mount, destroy }; } +export interface IdentityMismatchModalOptions { + readonly username: string; + /** The peer's newly-delivered identity-key fingerprint (safety number) for + * out-of-band verification before re-pinning; null when it can't be computed. */ + readonly fingerprint: string | null; + readonly onAccept: () => void; + readonly onReject: () => void; +} + +/** + * createIdentityMismatchModal — the E2EE-identity analogue of the cert-mismatch + * modal (F3 TOFU). A peer's voice identity key no longer matches the pinned one: + * either a legitimate key rotation (reinstall / new device / wiped keyring) or a + * server MITM swapping the key. Accepting re-pins the new key (recovery), the + * identity-key analogue of "Accept New Certificate". Reuses the .cert-* CSS and + * buildRow helper so the two TOFU trust-prompts stay visually identical. + */ +export function createIdentityMismatchModal( + options: IdentityMismatchModalOptions, +): MountableComponent { + const { username, fingerprint, onAccept, onReject } = options; + let overlay: HTMLDivElement | null = null; + const ac = new AbortController(); + + function mount(container: Element): void { + overlay = createElement("div", { class: "modal-overlay visible" }); + const modal = createElement("div", { class: "modal" }); + + const header = createElement("div", { class: "modal-header" }); + const title = createElement("h3", {}, "Identity Warning"); + const closeBtn = createElement("button", { class: "modal-close", type: "button" }); + closeBtn.textContent = ""; + closeBtn.appendChild(createIcon("x", 14)); + closeBtn.addEventListener("click", onReject, { signal: ac.signal }); + appendChildren(header, title, closeBtn); + + const body = createElement("div", { class: "modal-body" }); + + const warning = createElement("div", { class: "cert-warning" }); + warning.appendChild(createIcon("shield-alert", 24)); + + const certTitle = createElement("div", { class: "cert-title" }); + setText(certTitle, "Identity Key Changed"); + + const desc = createElement("div", { class: "cert-desc" }); + setText( + desc, + "This participant's end-to-end encryption identity key no longer matches " + + "the one pinned on first contact. This usually means they reinstalled or " + + "switched device, but it could also indicate that the server swapped their " + + "key. Verify the new key out-of-band before trusting it.", + ); + + const details = createElement("div", { class: "cert-details" }); + details.appendChild(buildRow("Participant", username, false)); + // Only when the new key's fingerprint is available — a null one would render + // a misleading blank "Unknown" row and defeats the out-of-band check. + if (fingerprint !== null) { + details.appendChild(buildRow("New key", fingerprint, true)); + } + + appendChildren(body, warning, certTitle, desc, details); + + const footer = createElement("div", { class: "modal-footer" }); + + const rejectBtn = createElement("button", { class: "btn-ghost", type: "button" }); + setText(rejectBtn, "Cancel"); + rejectBtn.addEventListener("click", onReject, { signal: ac.signal }); + + const acceptBtn = createElement("button", { class: "btn-danger", type: "button" }); + setText(acceptBtn, "Trust New Key"); + acceptBtn.addEventListener("click", onAccept, { signal: ac.signal }); + + appendChildren(footer, rejectBtn, acceptBtn); + + appendChildren(modal, header, body, footer); + overlay.appendChild(modal); + + overlay.addEventListener( + "click", + (e) => { + if (e.target === overlay) onReject(); + }, + { signal: ac.signal }, + ); + + container.appendChild(overlay); + } + + function destroy(): void { + ac.abort(); + if (overlay !== null) { + overlay.remove(); + overlay = null; + } + } + + return { mount, destroy }; +} + function buildRow(label: string, value: string, isFingerprint: boolean): HTMLDivElement { const row = createElement("div", { class: "cert-row" }); const labelEl = createElement("span", { class: "cert-label" }); diff --git a/Client/tauri-client/src/components/ChannelSidebar.ts b/Client/tauri-client/src/components/ChannelSidebar.ts index 734b73ac..9da27914 100644 --- a/Client/tauri-client/src/components/ChannelSidebar.ts +++ b/Client/tauri-client/src/components/ChannelSidebar.ts @@ -5,7 +5,7 @@ */ import { createElement, setText, clearChildren, appendChildren } from "@lib/dom"; -import { createIcon } from "@lib/icons"; +import { createIcon, type IconName } from "@lib/icons"; import type { MountableComponent } from "@lib/safe-render"; import { channelsStore, @@ -16,12 +16,113 @@ import { import type { Channel } from "@stores/channels.store"; import { authStore, getCurrentUser } from "@stores/auth.store"; import { uiStore, toggleCategory, isCategoryCollapsed } from "@stores/ui.store"; -import { voiceStore, getChannelVoiceUsers } from "@stores/voice.store"; +import { voiceStore, getChannelVoiceUsers, getPeerVerification } from "@stores/voice.store"; +import type { PeerVerification } from "@stores/voice.store"; import { SCREENSHARE_TILE_ID_OFFSET } from "@lib/constants"; import { attachStreamPreview, attachScrollCollapse } from "@lib/streamPreview"; import { showUserVolumeMenu } from "./channel-sidebar/volume-menu"; import { attachChannelContextMenu } from "./channel-sidebar/context-menu"; import { attachDragHandlers, releaseGlobalDragListeners } from "./channel-sidebar/drag-reorder"; +import { rePinPeerIdentity } from "@lib/livekitSession"; +import { createIdentityMismatchModal } from "./CertMismatchModal"; +import { createLogger } from "@lib/logger"; +import { membersStore } from "@stores/members.store"; +import { importIdentityPublicKey, computeKeyFingerprint } from "@lib/e2eeCrypto"; + +const log = createLogger("ChannelSidebar"); + +/** Icon, color, and tooltip for a peer's E2EE identity verification badge + * (F3 TOFU). The three states mirror the voice store's PeerVerification: + * a green shield-check when the announce signature verified against the pinned + * key, a muted shield when the peer published no key (legacy), and a red + * shield-alert when the delivered key differs from the pinned one. */ +function verifyPresentation(v: PeerVerification): { + icon: IconName; + color: string; + title: string; +} { + if (v.status === "verified") { + return { + icon: "shield-check", + color: "var(--green, #23a559)", + title: + v.safetyNumber !== null + ? `Identity verified · Safety number: ${v.safetyNumber}` + : "Identity verified", + }; + } + if (v.status === "mismatch") { + return { + icon: "shield-alert", + color: "var(--red, #f23f43)", + title: "Identity key changed — click to review and re-pin", + }; + } + // "unverified" — the remaining status: peer published no identity key (legacy). + return { + icon: "shield", + color: "var(--text-muted, #949ba4)", + title: "Identity not verified — this participant published no key", + }; +} + +// Identity-mismatch re-pin modal (F3 TOFU). One instance at a time, mounted on +// document.body; torn down on re-open and when the owning sidebar aborts. +// ponytail: module-level singleton mirrors ./channel-sidebar/volume-menu — there +// is only ever one sidebar. Extract to its own submodule if that ever changes. +let activeIdentityModal: MountableComponent | null = null; + +function closeIdentityModal(): void { + if (activeIdentityModal !== null) { + activeIdentityModal.destroy?.(); + activeIdentityModal = null; + } +} + +async function openIdentityMismatchModal( + userId: number, + username: string, + signal: AbortSignal, +): Promise { + closeIdentityModal(); + // Compute the newly-delivered key's fingerprint so the user can verify it + // out-of-band before trusting — the whole purpose of the mismatch prompt (the + // same importIdentityPublicKey→computeKeyFingerprint round-trip verifyPeerAnnounce + // runs on the verified path). Without it the modal's "verify out-of-band" + // instruction is unfollowable and "Trust New Key" is a blind accept. + let fingerprint: string | null = null; + const publishedKey = membersStore.getState().members.get(userId)?.identityPublicKey ?? null; + if (publishedKey !== null) { + try { + fingerprint = await computeKeyFingerprint(await importIdentityPublicKey(publishedKey)); + } catch (err) { + log.warn("E2EE: could not compute changed-key fingerprint for re-pin modal", err); + } + } + // The sidebar (or a newer open) may have superseded us during the async compute. + if (signal.aborted) return; + closeIdentityModal(); + const modal = createIdentityMismatchModal({ + username, + fingerprint, + onAccept: () => { + closeIdentityModal(); + // Surface keyring/IO failures instead of dropping them — this re-pins a + // trust anchor, so a silent failure would leave the user believing they + // recovered when they did not. + void rePinPeerIdentity(userId).catch((err: unknown) => { + log.error("E2EE: failed to re-pin peer identity", err); + }); + }, + onReject: () => { + closeIdentityModal(); + }, + }); + modal.mount(document.body); + activeIdentityModal = modal; + // Close if the owning sidebar is destroyed while the modal is still open. + signal.addEventListener("abort", closeIdentityModal, { once: true }); +} export interface ChannelReorderData { readonly channelId: number; @@ -197,6 +298,33 @@ function renderVoiceChannelItem( row.appendChild(muteIcon); } + // E2EE identity verification badge (F3 TOFU). Absent until the peer's + // announce resolves; the local user is never in peerVerifications. + const verification = getPeerVerification(user.userId); + if (verification !== null) { + const { + icon: badgeIcon, + color: badgeColor, + title: badgeTitle, + } = verifyPresentation(verification); + const badge = createElement("span", { class: `vu-verify ${verification.status}` }); + badge.style.color = badgeColor; + badge.title = badgeTitle; + badge.appendChild(createIcon(badgeIcon, 14)); + if (verification.status === "mismatch") { + badge.style.cursor = "pointer"; + badge.addEventListener( + "click", + (e) => { + e.stopPropagation(); + void openIdentityMismatchModal(user.userId, user.username || "Unknown", signal); + }, + { signal }, + ); + } + row.appendChild(badge); + } + // Right-click for per-user volume (skip for own user) const currentUser = getCurrentUser(); if (currentUser === null || currentUser.id !== user.userId) { @@ -532,7 +660,10 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC for (const [chId, users] of state.voiceUsers) { structSig += `|${chId}`; for (const [uid, u] of users) { - structSig += `:${uid}${u.muted ? "m" : ""}${u.deafened ? "d" : ""}${u.camera ? "c" : ""}${u.screenshare ? "s" : ""}`; + // Include the E2EE verification status so a verified↔unverified↔mismatch + // flip re-renders the badge (it lives outside voiceUsers, in peerVerifications). + const verif = state.peerVerifications?.get(uid); + structSig += `:${uid}${u.muted ? "m" : ""}${u.deafened ? "d" : ""}${u.camera ? "c" : ""}${u.screenshare ? "s" : ""}${verif ? `@${verif.status}` : ""}`; } } if (structSig !== prevVoiceStructureSig) { diff --git a/Client/tauri-client/src/lib/api.ts b/Client/tauri-client/src/lib/api.ts index fade98e7..1e74f416 100644 --- a/Client/tauri-client/src/lib/api.ts +++ b/Client/tauri-client/src/lib/api.ts @@ -273,7 +273,7 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?: }, updateProfile( - data: { username?: string; avatar?: string }, + data: { username?: string; avatar?: string; identity_public_key?: string }, signal?: AbortSignal, ): Promise { return request("PATCH", "/users/me", data, signal); diff --git a/Client/tauri-client/src/lib/dispatcher.ts b/Client/tauri-client/src/lib/dispatcher.ts index 389bbc7e..9754f446 100644 --- a/Client/tauri-client/src/lib/dispatcher.ts +++ b/Client/tauri-client/src/lib/dispatcher.ts @@ -63,6 +63,7 @@ import { isVoiceConnected, } from "@lib/livekitSession"; import { notifyIncomingMessage } from "./notifications"; +import { ensureIdentityKeyPublished } from "@lib/identity"; import { createLogger } from "./logger"; import { ServerMessageType as S } from "./protocolTypes"; @@ -107,7 +108,7 @@ export function wireConnectionStatus(ws: Pick): () => */ export function wireDispatcher( ws: WsClient, - api?: Pick, + api?: Pick & Partial>, ): DispatcherCleanup { const unsubs: Array<() => void> = []; @@ -149,6 +150,22 @@ export function wireDispatcher( leaveVoiceChannel(); } + // F3: publish our long-term identity public key so peers can pin+verify + // us in voice. Idempotent (no PATCH when the server copy already matches) + // and fire-and-forget — never block the ready flow. Username is required + // by the server's profile update, so it rides along with the key. + const self = payload.members.find((m) => m.id === currentUserId); + const host = api?.getConfig?.().host; + if (self !== undefined && currentUserId !== 0 && host && api?.updateProfile) { + const updateProfile = api.updateProfile; + void ensureIdentityKeyPublished( + host, + self.username, + self.identity_public_key ?? null, + (data) => updateProfile(data), + ); + } + // Auto-select the first text channel if none is active const currentActive = channelsStore.select((s) => s.activeChannelId); if (currentActive === null && payload.channels.length > 0) { @@ -359,7 +376,12 @@ export function wireDispatcher( unsubs.push( ws.on(S.USER_UPDATE, (payload) => { log.info("User profile updated", { userId: payload.user_id, username: payload.username }); - updateMemberProfile(payload.user_id, payload.username, payload.avatar); + updateMemberProfile( + payload.user_id, + payload.username, + payload.avatar, + payload.identity_public_key, + ); // Update auth store if the current user changed their own profile. const currentUser = authStore.getState().user; @@ -428,7 +450,7 @@ export function wireDispatcher( unsubs.push( ws.on(S.VOICE_E2EE_ANNOUNCE, (payload) => { - void handleE2EEAnnounce(payload.user_id, payload.public_key); + void handleE2EEAnnounce(payload.user_id, payload.public_key, payload.signature); }), ); diff --git a/Client/tauri-client/src/lib/e2eeCrypto.ts b/Client/tauri-client/src/lib/e2eeCrypto.ts index cb593edc..15a32dd0 100644 --- a/Client/tauri-client/src/lib/e2eeCrypto.ts +++ b/Client/tauri-client/src/lib/e2eeCrypto.ts @@ -34,6 +34,16 @@ const HKDF_SALT = new Uint8Array([ const HKDF_INFO = new Uint8Array([114, 111, 111, 109, 45, 107, 101, 121, 45, 119, 114, 97, 112]); const ROOM_KEY_BYTES = 32; // 256-bit AES key for LiveKit SFrame +// ── Long-term identity keys (F3: voice E2EE TOFU) ────────────────────────── +// ECDSA P-256 (same curve family as the ECDH exchange; works in all three +// webviews — Ed25519 is unreliable on WKWebView/WebKitGTK; zero new deps). +const ECDSA_CURVE = "P-256"; +// Domain-separation prefix signed with the identity key when announcing an +// ephemeral key: UTF-8 bytes of "owncord-voice-e2ee-announce-v1". Binding the +// prefix + userId stops the server re-attributing a valid announce to a +// different user or reusing the signature in another context. +const ANNOUNCE_DOMAIN = new TextEncoder().encode("owncord-voice-e2ee-announce-v1"); + // ── Key pair generation ───────────────────────────────────────────────────── /** Generate an ephemeral ECDH P-256 keypair. */ @@ -59,6 +69,11 @@ export async function importPublicKey(base64: string): Promise { * Compute a human-readable fingerprint of a public key for out-of-band * verification (safety numbers). Returns a hex string of the SHA-256 hash * of the raw key bytes, formatted as "AB12 CD34 …" groups. + * + * For the F3 safety number, feed the *stable* identity public key (the ECDSA + * key that persists across calls), NOT the per-call ephemeral ECDH key — the + * fingerprint only makes sense out-of-band if it stays constant for a peer. + * The raw-byte hash is algorithm-agnostic, so it works on either key type. */ export async function computeKeyFingerprint(publicKey: CryptoKey): Promise { const raw = await crypto.subtle.exportKey("raw", publicKey); @@ -71,6 +86,110 @@ export async function computeKeyFingerprint(publicKey: CryptoKey): Promise { + return crypto.subtle.generateKey({ name: "ECDSA", namedCurve: ECDSA_CURVE }, true, [ + "sign", + "verify", + ]); +} + +/** + * Sign an ephemeral-key announce with the long-term identity private key. + * The signed message is ANNOUNCE_DOMAIN ‖ myUserId ‖ ephemeralPubRaw, so a + * receiver knows this exact ephemeral key was announced by this exact user. + * Returns the base64 signature to carry in the `voice_e2ee_announce` payload. + */ +export async function signEphemeralKey( + identityPrivateKey: CryptoKey, + myUserId: string | number, + ephemeralPubRaw: Uint8Array, +): Promise { + const message = buildAnnounceMessage(myUserId, ephemeralPubRaw); + const sig = await crypto.subtle.sign( + { name: "ECDSA", hash: "SHA-256" }, + identityPrivateKey, + message, + ); + return uint8ToBase64(new Uint8Array(sig)); +} + +/** + * Verify an ephemeral-key announce against a peer's pinned identity public key. + * Returns false (never throws) on any tamper — bad base64, wrong userId, wrong + * ephemeral key, or wrong/forged signature — so callers can reject a MITM. + */ +export async function verifyEphemeralKeySignature( + identityPublicKey: CryptoKey, + userId: string | number, + ephemeralPubRaw: Uint8Array, + signatureBase64: string, +): Promise { + let signature: Uint8Array; + try { + signature = base64ToUint8(signatureBase64); + } catch { + return false; + } + const message = buildAnnounceMessage(userId, ephemeralPubRaw); + try { + return await crypto.subtle.verify( + { name: "ECDSA", hash: "SHA-256" }, + identityPublicKey, + signature, + message, + ); + } catch { + return false; + } +} + +/** Import a base64 raw P-256 identity public key for signature verification. */ +export async function importIdentityPublicKey(base64: string): Promise { + const raw = base64ToUint8(base64); + return crypto.subtle.importKey("raw", raw, { name: "ECDSA", namedCurve: ECDSA_CURVE }, true, [ + "verify", + ]); +} + +/** + * Serialize an identity keypair for OS-keyring storage. Exports the private + * key as JWK (base64-encoded JSON) — the JWK carries both the private scalar + * `d` and the public point `x`/`y`, so both keys are recoverable on load. + */ +export async function exportIdentityKeyPair(privateKey: CryptoKey): Promise { + const jwk = await crypto.subtle.exportKey("jwk", privateKey); + return btoa(JSON.stringify(jwk)); +} + +/** Inverse of exportIdentityKeyPair: recover both keys from the keyring blob. */ +export async function importIdentityKeyPair(blobBase64: string): Promise { + const jwk = JSON.parse(atob(blobBase64)) as JsonWebKey; + const alg = { name: "ECDSA", namedCurve: ECDSA_CURVE }; + const privateKey = await crypto.subtle.importKey("jwk", jwk, alg, true, ["sign"]); + // Strip the private scalar to import the matching public key. + const pubJwk: JsonWebKey = { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y }; + const publicKey = await crypto.subtle.importKey("jwk", pubJwk, alg, true, ["verify"]); + return { privateKey, publicKey }; +} + +/** Build the byte string signed/verified for an ephemeral-key announce. */ +function buildAnnounceMessage( + userId: string | number, + ephemeralPubRaw: Uint8Array, +): Uint8Array { + const userIdBytes = new TextEncoder().encode(String(userId)); + const message = new Uint8Array( + ANNOUNCE_DOMAIN.length + userIdBytes.length + ephemeralPubRaw.length, + ); + message.set(ANNOUNCE_DOMAIN, 0); + message.set(userIdBytes, ANNOUNCE_DOMAIN.length); + message.set(ephemeralPubRaw, ANNOUNCE_DOMAIN.length + userIdBytes.length); + return message; +} + // ── Room key generation ───────────────────────────────────────────────────── /** Generate a random 256-bit room key. */ diff --git a/Client/tauri-client/src/lib/icons.ts b/Client/tauri-client/src/lib/icons.ts index 0db8c5ca..4a3430c0 100644 --- a/Client/tauri-client/src/lib/icons.ts +++ b/Client/tauri-client/src/lib/icons.ts @@ -61,6 +61,9 @@ export type IconName = | "image" | "signal" | "log-out" + | "shield" + | "shield-check" + | "shield-alert" | "zap"; // --------------------------------------------------------------------------- @@ -204,6 +207,11 @@ const ICON_PATHS: Record = { // Lightning bolt (auto-login indicator) zap: ``, + + // Security shields — E2EE identity verification badges (F3 TOFU voice panel). + shield: ``, + "shield-check": ``, + "shield-alert": ``, }; // --------------------------------------------------------------------------- diff --git a/Client/tauri-client/src/lib/identity.ts b/Client/tauri-client/src/lib/identity.ts new file mode 100644 index 00000000..a89fc510 --- /dev/null +++ b/Client/tauri-client/src/lib/identity.ts @@ -0,0 +1,187 @@ +/** + * Identity-key storage — Tauri IPC wrappers for the voice-E2EE TOFU identity + * layer (F3). Mirrors credentials.ts: dynamically imports Tauri `invoke` and + * no-ops in non-Tauri environments (tests, browser). + * + * Two backing stores, both keyed by connection host: + * - OS keyring (save/load/delete_identity_key, account `identity:{host}`): + * the client's own long-term identity PRIVATE key (base64 JWK blob). + * - identity_pins.json (store/get_identity_pin, key `{host}:{userId}`): + * peers' pinned identity PUBLIC keys (base64), for TOFU verification. + */ + +import { createLogger } from "./logger"; +import { + exportIdentityKeyPair, + exportPublicKey, + generateIdentityKeyPair, + importIdentityKeyPair, +} from "./e2eeCrypto"; + +const log = createLogger("identity"); + +/** Dynamically import Tauri invoke to avoid errors in test/browser. */ +async function getInvoke(): Promise< + ((cmd: string, args?: Record) => Promise) | null +> { + try { + const { invoke } = await import("@tauri-apps/api/core"); + return invoke; + } catch { + return null; + } +} + +// ── Identity private key (OS keyring) ────────────────────────────────────── + +/** Save the identity private-key blob for a host to the OS keyring. */ +export async function saveIdentityKey(host: string, key: string): Promise { + const invoke = await getInvoke(); + if (!invoke) { + log.warn("Tauri not available — identity key not saved"); + return false; + } + try { + await invoke("save_identity_key", { host, key }); + return true; + } catch (err) { + log.error("Failed to save identity key", { host, error: String(err) }); + return false; + } +} + +/** Load the identity private-key blob for a host, or null if absent/unavailable. */ +export async function loadIdentityKey(host: string): Promise { + const invoke = await getInvoke(); + if (!invoke) { + return null; + } + try { + const result = await invoke("load_identity_key", { host }); + return typeof result === "string" ? result : null; + } catch (err) { + log.error("Failed to load identity key", { host, error: String(err) }); + return null; + } +} + +/** Delete the identity private key for a host from the OS keyring. */ +export async function deleteIdentityKey(host: string): Promise { + const invoke = await getInvoke(); + if (!invoke) { + return false; + } + try { + await invoke("delete_identity_key", { host }); + return true; + } catch (err) { + log.error("Failed to delete identity key", { host, error: String(err) }); + return false; + } +} + +// ── Peer identity pins (identity_pins.json, TOFU) ────────────────────────── + +/** Pin a peer's identity public key (base64) under `{host}:{userId}`. */ +export async function storeIdentityPin( + host: string, + userId: string, + pin: string, +): Promise { + const invoke = await getInvoke(); + if (!invoke) { + log.warn("Tauri not available — identity pin not stored"); + return false; + } + try { + await invoke("store_identity_pin", { host, userId, pin }); + return true; + } catch (err) { + log.error("Failed to store identity pin", { host, userId, error: String(err) }); + return false; + } +} + +/** Load a peer's pinned identity public key, or null if never pinned. */ +export async function getIdentityPin(host: string, userId: string): Promise { + const invoke = await getInvoke(); + if (!invoke) { + return null; + } + try { + const result = await invoke("get_identity_pin", { host, userId }); + return typeof result === "string" ? result : null; + } catch (err) { + log.error("Failed to load identity pin", { host, userId, error: String(err) }); + return null; + } +} + +// ── High-level lifecycle ─────────────────────────────────────────────────── + +/** + * Load this host's identity keypair from the keyring, generating and saving a + * fresh one on first login (or when the stored blob is corrupt). In non-Tauri + * environments the keypair is in-memory only (not persisted). + */ +export async function getOrCreateIdentityKeyPair(host: string): Promise { + const stored = await loadIdentityKey(host); + if (stored) { + try { + return await importIdentityKeyPair(stored); + } catch (err) { + log.error("Stored identity key is corrupt — regenerating", { host, error: String(err) }); + } + } + const keyPair = await generateIdentityKeyPair(); + await saveIdentityKey(host, await exportIdentityKeyPair(keyPair.privateKey)); + return keyPair; +} + +/** + * Publish the local identity public key via the REST profile update, but only + * when the server's stored copy is absent or different — idempotent so it runs + * at most once per key (no PATCH on every login). Returns true if it published. + * + * `serverCopy` is the server's current `identity_public_key` for this user + * (from the ready/member payload); `updateProfile` is `api.updateProfile`. + */ +export async function publishIdentityKey( + updateProfile: (data: { identity_public_key: string }) => Promise, + serverCopy: string | null | undefined, + publicKey: CryptoKey, +): Promise { + const localBase64 = await exportPublicKey(publicKey); + if (serverCopy === localBase64) { + return false; + } + await updateProfile({ identity_public_key: localBase64 }); + return true; +} + +/** + * Login/ready hook: ensure the server holds this client's identity public key. + * Loads (or generates) the host keypair and publishes it via the REST profile + * update when the server's stored copy is absent or stale — idempotent, so it + * runs at most once per key. The server's PATCH /users/me requires a username, + * so `username` is sent alongside the key. Fire-and-forget: errors are logged + * and swallowed (returns false) so the connect/voice flow is never blocked. + */ +export async function ensureIdentityKeyPublished( + host: string, + username: string, + serverCopy: string | null | undefined, + updateProfile: (data: { username: string; identity_public_key: string }) => Promise, +): Promise { + try { + const keyPair = await getOrCreateIdentityKeyPair(host); + return await publishIdentityKey( + (data) => updateProfile({ username, ...data }), + serverCopy, + keyPair.publicKey, + ); + } catch (err) { + log.error("Failed to publish identity key", { host, error: String(err) }); + return false; + } +} diff --git a/Client/tauri-client/src/lib/livekitSession.ts b/Client/tauri-client/src/lib/livekitSession.ts index 533f4dcb..187f6442 100644 --- a/Client/tauri-client/src/lib/livekitSession.ts +++ b/Client/tauri-client/src/lib/livekitSession.ts @@ -25,7 +25,18 @@ import { roomKeyToBase64, wrapRoomKey, unwrapRoomKey, + signEphemeralKey, + verifyEphemeralKeySignature, + importIdentityPublicKey, + computeKeyFingerprint, } from "@lib/e2eeCrypto"; +import { getOrCreateIdentityKeyPair, getIdentityPin, storeIdentityPin } from "@lib/identity"; +import { membersStore } from "@stores/members.store"; +import { + setPeerVerification, + clearPeerVerification, + clearPeerVerifications, +} from "@stores/voice.store"; import { DeviceManager } from "@lib/deviceManager"; import { type VideoTrackDeps, @@ -148,6 +159,9 @@ export class LiveKitSession { private _roomKey: Uint8Array | null = null; /** Peer ECDH public keys indexed by userId. */ private _peerPublicKeys: Map = new Map(); + /** This client's long-term ECDSA identity keypair (F3 TOFU), used to sign our + * ephemeral announces. Loaded lazily from the OS keyring, cached per session. */ + private _identityKeyPair: CryptoKeyPair | null = null; /** True if this client is the key holder (longest-present participant). */ private _isKeyHolder = false; /** Resolver/rejector for non-key-holders waiting to receive the room key via offer. */ @@ -159,7 +173,11 @@ export class LiveKitSession { * epoch before async work and discards the result if epoch changed (stale offer). */ private _e2eeEpoch = 0; /** Announces that arrived before our ECDH keypair was ready. Drained after keypair init. */ - private _pendingAnnounces: Array<{ userId: number; publicKeyBase64: string }> = []; + private _pendingAnnounces: Array<{ + userId: number; + publicKeyBase64: string; + signatureBase64?: string; + }> = []; /** Periodic key rotation timer — fires every KEY_ROTATION_INTERVAL_MS when key holder. */ private _keyRotationTimer: ReturnType | null = null; /** Interval between periodic key rotations (5 minutes). */ @@ -458,16 +476,16 @@ export class LiveKitSession { // oxlint-disable-next-line no-await-in-loop -- must set up E2EE before connect this._ecdhKeyPair = await generateECDHKeyPair(); this._peerPublicKeys.clear(); + clearPeerVerifications(); if (this._roomKey) { // oxlint-disable-next-line no-await-in-loop -- must set key before connect await this._e2eeKeyProvider.setKey(roomKeyToBase64(this._roomKey)); } // oxlint-disable-next-line no-await-in-loop -- must export before connect const reconnectPubKey = await exportPublicKey(this._ecdhKeyPair.publicKey); - this.ws?.send({ - type: "voice_e2ee_announce", - payload: { public_key: reconnectPubKey }, - }); + // oxlint-disable-next-line no-await-in-loop -- must sign the announce before connect + const reconnectAnnounce = await this.buildAnnouncePayload(reconnectPubKey); + this.ws?.send({ type: "voice_e2ee_announce", payload: reconnectAnnounce }); // oxlint-disable-next-line no-await-in-loop -- sequential reconnect: must connect before restoring state await newRoom.connect(resolvedUrl, token); @@ -764,6 +782,11 @@ export class LiveKitSession { this.ws = client; } setServerHost(host: string): void { + // Identity keys are host-scoped — drop the cached keypair when the host + // changes so we never sign an announce with another host's identity key. + if (host !== this.serverHost) { + this._identityKeyPair = null; + } this.serverHost = host; } setOnError(cb: (message: string) => void): void { @@ -844,21 +867,27 @@ export class LiveKitSession { // Generate a fresh ECDH keypair for this session. this._ecdhKeyPair = await generateECDHKeyPair(); this._peerPublicKeys.clear(); + clearPeerVerifications(); const myPubKeyBase64 = await exportPublicKey(this._ecdhKeyPair.publicKey); - - // Drain any announces that arrived before our keypair was ready. - // These are from existing participants whose public keys the server - // relayed during voice_join sync. - const queued = this._pendingAnnounces.splice(0); - for (const { userId: qId, publicKeyBase64: qKey } of queued) { - const peerKey = await importPublicKey(qKey); - this._peerPublicKeys.set(qId, peerKey); - log.info("E2EE: drained queued announce", { userId: qId }); - } + // Build the signed announce up front — this loads the identity key from + // the keyring once, so the added identity round-trip does NOT stack on + // the non-key-holder's 10s key-exchange stall below (F3). + const announcePayload = await this.buildAnnouncePayload(myPubKeyBase64); // Use server-authoritative is_key_holder from voice_token payload. this._isKeyHolder = isKeyHolder ?? false; + // Drain any announces that arrived before our keypair was ready. These + // are existing participants whose keys the server relayed during + // voice_join sync — run them through the normal verifying receive path + // so a server-substituted peer key is caught here too. + const queued = this._pendingAnnounces.splice(0); + for (const { userId: qId, publicKeyBase64: qKey, signatureBase64: qSig } of queued) { + // oxlint-disable-next-line no-await-in-loop -- sequential drain: verify each queued announce + await this.handleE2EEAnnounce(qId, qKey, qSig); + log.info("E2EE: drained queued announce", { userId: qId }); + } + if (this._isKeyHolder) { // We're the first participant — generate the room key. this._e2eeEpoch++; @@ -866,6 +895,8 @@ export class LiveKitSession { await this._e2eeKeyProvider.setKey(roomKeyToBase64(this._roomKey)); log.info("E2EE: key holder — generated room key", { channelId }); this.startKeyRotationTimer(); + // Announce our (signed) key so existing participants can see us. + this.ws?.send({ type: "voice_e2ee_announce", payload: announcePayload }); } else { // Wait for the key holder to send us the room key via voice_e2ee_offer. // This promise resolves when handleE2EEOffer() sets _roomKey. @@ -874,6 +905,10 @@ export class LiveKitSession { this._roomKeyResolver = resolve; this._roomKeyRejector = reject; }); + // Announce BEFORE waiting (moved earlier per F3) so the key holder can + // offer immediately. The resolver is set above, so an immediate offer + // won't be missed. + this.ws?.send({ type: "voice_e2ee_announce", payload: announcePayload }); // Wait up to 10s for the key holder to send an offer. If the first // attempt times out, re-announce our public key (the offer may have been // lost if the key holder disconnected mid-send) and wait 5s more. @@ -888,10 +923,7 @@ export class LiveKitSession { // First attempt timed out — re-announce and retry once. if (timeoutId !== null) clearTimeout(timeoutId); log.warn("E2EE: first key exchange attempt timed out, re-announcing", { channelId }); - this.ws?.send({ - type: "voice_e2ee_announce", - payload: { public_key: myPubKeyBase64 }, - }); + this.ws?.send({ type: "voice_e2ee_announce", payload: announcePayload }); try { await Promise.race([roomKeyPromise, makeTimeout(5_000)]); } catch { @@ -910,14 +942,6 @@ export class LiveKitSession { this._roomKeyRejector = null; } - // Announce our public key so existing participants (and the key holder) - // can see us. This must happen AFTER we set up the roomKeyResolver so - // we don't miss an immediate offer response. - this.ws?.send({ - type: "voice_e2ee_announce", - payload: { public_key: myPubKeyBase64 }, - }); - for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { try { // oxlint-disable-next-line no-await-in-loop -- sequential retry: must attempt connect before checking result @@ -1160,21 +1184,170 @@ export class LiveKitSession { } } + // ── Identity signing (F3 TOFU) ────────────────────────────────────────── + + /** Decode a base64 raw-key string to bytes for sign/verify. Throws on bad + * input (callers verifying a peer key already run inside try/catch). */ + private rawFromBase64(base64: string): Uint8Array { + return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); + } + + /** Load (once per session) this client's long-term identity keypair from the + * OS keyring so we can sign ephemeral announces. Returns null when there is + * no server host (identity is host-scoped) — the announce then goes out + * unsigned and peers treat us as a legacy/unverified client. */ + private async ensureIdentityKeyPair(): Promise { + if (this._identityKeyPair) return this._identityKeyPair; + if (this.serverHost === null) return null; + this._identityKeyPair = await getOrCreateIdentityKeyPair(this.serverHost); + return this._identityKeyPair; + } + + /** Build the voice_e2ee_announce payload, signing the ephemeral public key + * with our identity key (F3). Signing failures degrade to an unsigned + * announce rather than blocking the join. */ + private async buildAnnouncePayload( + ephemeralPubBase64: string, + ): Promise<{ public_key: string; signature?: string }> { + try { + const idKeyPair = await this.ensureIdentityKeyPair(); + if (idKeyPair) { + const myUserId = authStore.getState().user?.id ?? 0; + const ephemeralRaw = this.rawFromBase64(ephemeralPubBase64); + const signature = await signEphemeralKey(idKeyPair.privateKey, myUserId, ephemeralRaw); + return { public_key: ephemeralPubBase64, signature }; + } + } catch (err) { + log.error("E2EE: failed to sign announce — sending unsigned", err); + } + return { public_key: ephemeralPubBase64 }; + } + + /** + * F3 TOFU: resolve a peer's identity key and verify their ephemeral-announce + * signature. Pins the identity key on first sight; on a later change it emits + * an identity-tofu "mismatch" (via the voice store) and blocks the peer until + * the user re-pins. Returns true when the announce may be accepted (verified, + * or a legacy peer with no identity key), false to reject/block. The store + * write is the surfaced verification state the voice panel reads. + * + * Compatibility posture (transition): + * - peer HAS a published identity key, signature missing/invalid → reject + * (fail closed); + * - peer has NO identity key (legacy client) → accept, mark unverified + * (pin-pending). + */ + private async verifyPeerAnnounce( + userId: number, + publicKeyBase64: string, + signatureBase64?: string, + ): Promise { + const publishedIdentity = + membersStore.getState().members.get(userId)?.identityPublicKey ?? null; + const host = this.serverHost; + + // Resolve the persisted pin FIRST — before any legacy shortcut. A server + // must not be able to strip a pinned peer's published key (or swap it) to + // force it back onto the legacy accept path (finding #2: TOFU pin bypass). + const pin = host ? await getIdentityPin(host, String(userId)) : null; + + // Pinned peer whose delivered key is absent or differs from the pin — + // possible server MITM. Block until the user re-pins. + if (pin !== null && publishedIdentity !== pin) { + setPeerVerification({ userId, status: "mismatch", safetyNumber: null }); + log.error("E2EE: pinned peer identity key missing/changed — blocking (identity-tofu)", { + userId, + }); + return false; + } + + // Genuine legacy peer: never pinned AND no published identity key — accept + // but mark unverified (pin-pending). This is the only case the compatibility + // posture keeps open. + if (!publishedIdentity) { + setPeerVerification({ userId, status: "unverified", safetyNumber: null }); + log.warn("E2EE: peer has no identity key — accepting as unverified (legacy)", { userId }); + return true; + } + + // Verify the ephemeral-key signature against the trusted identity key + // (the pin when we have one, else the first-sight published key). + const anchorBase64 = pin ?? publishedIdentity; + const identityKey = await importIdentityPublicKey(anchorBase64); + const ephemeralRaw = this.rawFromBase64(publicKeyBase64); + const ok = signatureBase64 + ? await verifyEphemeralKeySignature(identityKey, userId, ephemeralRaw, signatureBase64) + : false; + if (!ok) { + // Fail closed: peer has an identity key but no valid signature (MITM). + setPeerVerification({ userId, status: "mismatch", safetyNumber: null }); + log.error("E2EE: peer announce signature invalid — rejecting (MITM?)", { userId }); + return false; + } + + // First sight with a valid signature — pin the identity key now. + if (pin === null && host) { + await storeIdentityPin(host, String(userId), publishedIdentity); + log.info("E2EE: pinned peer identity key on first sight", { userId }); + } + const safetyNumber = await computeKeyFingerprint(identityKey); + setPeerVerification({ userId, status: "verified", safetyNumber }); + return true; + } + + /** + * F3 TOFU re-pin recovery (finding #4). Accept the peer's CURRENT published + * identity key, overwriting the stored pin for {host,userId} and clearing the + * mismatch block — the identity-key analogue of accepting a changed TLS cert. + * A legitimate key rotation (reinstall / new device / wiped keyring) is thus + * recoverable instead of a permanent lockout; the next announce re-verifies + * against the new pin. Returns false when there is no host or no published key + * to pin. The voice-panel mismatch confirm should call this. + */ + async rePinPeerIdentity(userId: number): Promise { + const host = this.serverHost; + const publishedIdentity = + membersStore.getState().members.get(userId)?.identityPublicKey ?? null; + if (!host || !publishedIdentity) { + log.warn("E2EE: cannot re-pin peer without a host and published identity key", { userId }); + return false; + } + await storeIdentityPin(host, String(userId), publishedIdentity); + clearPeerVerification(userId); + log.info("E2EE: re-pinned peer identity key (TOFU recovery)", { userId }); + return true; + } + // ── Client-side E2EE handlers (ECDH key exchange) ─────────────────────── /** * Handle a voice_e2ee_announce from the server — another participant has - * announced their ECDH public key. If we are the key holder, wrap and send - * the room key to them. + * announced their ECDH public key. Before trusting it we verify the peer's + * identity-key signature (F3 TOFU): resolve the peer's identity key (pinning + * it on first sight), reject on mismatch/invalid signature, and only then + * store the ECDH key + (if key holder) wrap the room key for them. Peers with + * no published identity key (legacy) are accepted but marked unverified. */ - async handleE2EEAnnounce(userId: number, publicKeyBase64: string): Promise { + async handleE2EEAnnounce( + userId: number, + publicKeyBase64: string, + signatureBase64?: string, + ): Promise { // Queue if our keypair isn't ready yet (announce arrived during connectAndSetup). if (!this._ecdhKeyPair) { - this._pendingAnnounces.push({ userId, publicKeyBase64 }); + this._pendingAnnounces.push({ userId, publicKeyBase64, signatureBase64 }); log.info("E2EE: queued announce (keypair not ready)", { userId }); return; } try { + // ── F3 TOFU verification gate ────────────────────────────────────── + // Resolve the peer's identity key and verify the announce signature + // BEFORE storing the ECDH key or wrapping the room key. A malicious + // server that swaps user_id↔ephemeral-key or forges keys fails here. + if (!(await this.verifyPeerAnnounce(userId, publicKeyBase64, signatureBase64))) { + return; // rejected/blocked — do not store or wrap + } + // Deduplicate: if the key is identical, skip the import but still // re-send the room key offer (the peer may be re-requesting after a // missed offer or reconnect). @@ -1289,6 +1462,7 @@ export class LiveKitSession { */ async handleParticipantLeft(userId: number): Promise { this._peerPublicKeys.delete(userId); + clearPeerVerification(userId); const channelId = this._currentChannelId; if (!channelId) return; @@ -1434,11 +1608,14 @@ export class LiveKitSession { this.startKeyRotationTimer(); } - /** Clear all E2EE state (called on voice leave). */ + /** Clear all E2EE state (called on voice leave). The long-term identity + * keypair is intentionally NOT cleared here — it persists across calls to + * the same host (cleared only on host change / cleanupAll). */ private clearE2EEState(): void { this._ecdhKeyPair = null; this._roomKey = null; this._peerPublicKeys.clear(); + clearPeerVerifications(); this._isKeyHolder = false; this._rotatingKey = false; this._e2eeEpoch = 0; @@ -1528,6 +1705,7 @@ export class LiveKitSession { this.ws = null; this.serverHost = null; this.liveKitProxyPort = null; + this._identityKeyPair = null; // Stop the Rust-side TLS proxy (fire-and-forget). invoke("stop_livekit_proxy").catch((err) => log.warn("Failed to stop LiveKit proxy", err)); } @@ -1693,6 +1871,7 @@ export const clearOnRemoteVideo = session.clearOnRemoteVideo.bind(session); export const handleVoiceToken = session.handleVoiceToken.bind(session); export const handleE2EEAnnounce = session.handleE2EEAnnounce.bind(session); export const handleE2EEOffer = session.handleE2EEOffer.bind(session); +export const rePinPeerIdentity = session.rePinPeerIdentity.bind(session); export const handleParticipantLeft = session.handleParticipantLeft.bind(session); export const leaveVoice = session.leaveVoice.bind(session); export const retryMicPermission = session.retryMicPermission.bind(session); diff --git a/Client/tauri-client/src/lib/types.ts b/Client/tauri-client/src/lib/types.ts index 7383e30d..f57729d8 100644 --- a/Client/tauri-client/src/lib/types.ts +++ b/Client/tauri-client/src/lib/types.ts @@ -61,6 +61,9 @@ export interface MessageUser { export interface UserWithRole extends MessageUser { readonly role: string; readonly totp_enabled?: boolean; + /** Long-term E2EE identity public key (base64), pinned by peers on first + * sight (F3 TOFU). Omitted/null when the user has not published one. */ + readonly identity_public_key?: string | null; } /** Attachment on a chat message. */ @@ -110,6 +113,8 @@ export interface ReadyMember { readonly avatar: string | null; readonly role: string; readonly status: UserStatus; + /** Long-term E2EE identity public key (base64) for voice TOFU (F3). */ + readonly identity_public_key?: string | null; } /** Voice state object in the ready payload. */ @@ -298,6 +303,9 @@ export interface VoiceTokenPayload { export interface VoiceE2EEAnnouncePayload { readonly user_id: number; readonly public_key: string; + /** Sender's identity-key signature over the ephemeral key (F3 TOFU). + * Omitted for legacy clients that have not published an identity key. */ + readonly signature?: string; } /** Server→Client relay of an encrypted room key from the key holder. */ @@ -324,6 +332,9 @@ export interface UserUpdatePayload { readonly user_id: number; readonly username: string; readonly avatar: string | null; + /** Updated E2EE identity public key (base64) — lets peers detect an + * identity-key change (TOFU mismatch) as it happens (F3). */ + readonly identity_public_key?: string | null; } export interface MemberBanPayload { @@ -501,7 +512,9 @@ export type ClientMessage = | (WsEnvelope & { readonly type: "voice_camera" }) | (WsEnvelope & { readonly type: "voice_screenshare" }) | (WsEnvelope> & { readonly type: "voice_token_refresh" }) - | (WsEnvelope<{ public_key: string }> & { readonly type: "voice_e2ee_announce" }) + | (WsEnvelope<{ public_key: string; signature?: string }> & { + readonly type: "voice_e2ee_announce"; + }) | (WsEnvelope<{ target_user_id: number; encrypted_key: string; iv: string }> & { readonly type: "voice_e2ee_offer"; }); diff --git a/Client/tauri-client/src/stores/members.store.ts b/Client/tauri-client/src/stores/members.store.ts index a22f7686..9eaf5606 100644 --- a/Client/tauri-client/src/stores/members.store.ts +++ b/Client/tauri-client/src/stores/members.store.ts @@ -12,6 +12,10 @@ export interface Member { readonly avatar: string | null; readonly role: string; readonly status: UserStatus; + /** Long-term E2EE identity public key (base64) for voice TOFU (F3). The store + * always sets it (null when the user has not published one); optional only so + * the many inline Member test fixtures need not restate it. */ + readonly identityPublicKey?: string | null; } export interface MembersState { @@ -45,6 +49,7 @@ export function setMembers(members: readonly ReadyMember[]): void { avatar: m.avatar, role: m.role, status: m.status, + identityPublicKey: m.identity_public_key ?? null, }); } // Clear all outstanding typing timers @@ -68,6 +73,7 @@ export function addMember(payload: MemberJoinPayload): void { avatar: payload.user.avatar, role: payload.user.role, status: "online" as UserStatus, + identityPublicKey: payload.user.identity_public_key ?? null, }); return { ...prev, members: next }; }); @@ -93,13 +99,26 @@ export function updateMemberRole(userId: number, role: string): void { }); } -/** Update a member's profile (username, avatar) from a user_update event. */ -export function updateMemberProfile(userId: number, username: string, avatar: string | null): void { +/** Update a member's profile (username, avatar, identity key) from a + * user_update event. `identityPublicKey` is only applied when provided, so a + * profile update that omits it doesn't clobber a pinned key. */ +export function updateMemberProfile( + userId: number, + username: string, + avatar: string | null, + identityPublicKey?: string | null, +): void { membersStore.setState((prev) => { const existing = prev.members.get(userId); if (!existing) return prev; const next = new Map(prev.members); - next.set(userId, { ...existing, username, avatar }); + next.set(userId, { + ...existing, + username, + avatar, + identityPublicKey: + identityPublicKey === undefined ? existing.identityPublicKey : identityPublicKey, + }); return { ...prev, members: next }; }); } diff --git a/Client/tauri-client/src/stores/voice.store.ts b/Client/tauri-client/src/stores/voice.store.ts index 2dbb28f4..416c94ce 100644 --- a/Client/tauri-client/src/stores/voice.store.ts +++ b/Client/tauri-client/src/stores/voice.store.ts @@ -39,6 +39,21 @@ export interface VoiceConfig { readonly max_users: number; } +/** Per-peer E2EE identity verification result (F3 TOFU), surfaced so the voice + * panel can show a verified/unverified badge and the out-of-band safety number. + * Written from livekitSession.ts as each peer's announce is verified. + * - "verified": announce signature checked against the peer's pinned key. + * - "unverified": peer published no identity key (legacy) — pin-pending. + * - "mismatch": the delivered identity key differs from the pinned one + * (possible server MITM); the peer is blocked until re-pin. */ +export interface PeerVerification { + readonly userId: number; + readonly status: "verified" | "unverified" | "mismatch"; + /** Safety number (identity-key fingerprint) for out-of-band verification; + * null for legacy/unverified/mismatch peers. */ + readonly safetyNumber: string | null; +} + export interface VoiceState { readonly currentChannelId: number | null; readonly voiceUsers: ReadonlyMap>; // channelId -> userId -> VoiceUser @@ -54,6 +69,10 @@ export interface VoiceState { /** Voice-session lifecycle status (drives the widget's connecting/securing/ * secured indicators). Written from livekitSession.ts. */ readonly voiceStatus: VoiceStatus; + /** Per-peer E2EE identity verification (F3 TOFU), keyed by userId. The store + * always sets it; optional only so the many inline VoiceState test fixtures + * need not restate it. */ + readonly peerVerifications?: ReadonlyMap; } const INITIAL_STATE: VoiceState = { @@ -67,6 +86,7 @@ const INITIAL_STATE: VoiceState = { joinedAt: null, listenOnly: false, voiceStatus: "idle", + peerVerifications: new Map(), }; export const voiceStore = createStore(INITIAL_STATE); @@ -84,6 +104,7 @@ export function resetVoiceStore(): void { joinedAt: null, listenOnly: false, voiceStatus: "idle", + peerVerifications: new Map(), })); } @@ -330,6 +351,38 @@ export function setSpeakers(payload: VoiceSpeakersPayload): void { }); } +/** Record a peer's E2EE identity verification result (F3 TOFU). Written from + * livekitSession.ts as each peer's ephemeral-key announce is verified. */ +export function setPeerVerification(v: PeerVerification): void { + voiceStore.setState((prev) => { + const next = new Map(prev.peerVerifications); + next.set(v.userId, v); + return { ...prev, peerVerifications: next }; + }); +} + +/** Drop a single peer's verification (e.g. when they leave the channel). */ +export function clearPeerVerification(userId: number): void { + voiceStore.setState((prev) => { + if (!prev.peerVerifications?.has(userId)) return prev; + const next = new Map(prev.peerVerifications); + next.delete(userId); + return { ...prev, peerVerifications: next }; + }); +} + +/** Drop all peer verifications (on voice leave). */ +export function clearPeerVerifications(): void { + voiceStore.setState((prev) => + (prev.peerVerifications?.size ?? 0) === 0 ? prev : { ...prev, peerVerifications: new Map() }, + ); +} + +/** Selector: a peer's verification result, or null if not yet resolved. */ +export function getPeerVerification(userId: number): PeerVerification | null { + return voiceStore.select((s) => s.peerVerifications?.get(userId) ?? null); +} + /** Selector: get all voice users in a specific channel. */ export function getChannelVoiceUsers(channelId: number): readonly VoiceUser[] { return voiceStore.select((s) => { diff --git a/Client/tauri-client/src/styles/app.css b/Client/tauri-client/src/styles/app.css index 894d98ef..28f47028 100644 --- a/Client/tauri-client/src/styles/app.css +++ b/Client/tauri-client/src/styles/app.css @@ -154,6 +154,9 @@ .voice-user-item .vu-muted { color: var(--red); } .voice-user-item .vu-name + .vu-status, .voice-user-item .vu-name + .vu-muted { margin-left: auto; } +/* E2EE identity verification badge (F3 TOFU); color + cursor set inline per state. */ +.voice-user-item .vu-verify { margin-left: 2px; display: flex; align-items: center; flex-shrink: 0; } +.voice-user-item .vu-name + .vu-verify { margin-left: auto; } /* ── LIVE badge (voice channel sidebar) ── */ .vu-live-badge { diff --git a/Client/tauri-client/tests/unit/channel-sidebar.test.ts b/Client/tauri-client/tests/unit/channel-sidebar.test.ts index e1197e5c..9109e5a6 100644 --- a/Client/tauri-client/tests/unit/channel-sidebar.test.ts +++ b/Client/tauri-client/tests/unit/channel-sidebar.test.ts @@ -1,10 +1,15 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -// Mock livekitSession (required by streamPreview) +// Mock livekitSession (required by streamPreview). rePinPeerIdentity is hoisted +// so the mock factory can reference it and tests can assert re-pin was invoked. +const { mockRePinPeerIdentity } = vi.hoisted(() => ({ + mockRePinPeerIdentity: vi.fn(() => Promise.resolve(true)), +})); vi.mock("@lib/livekitSession", () => ({ setUserVolume: vi.fn(), getUserVolume: vi.fn(() => 1), getRemoteVideoStream: vi.fn(() => null), + rePinPeerIdentity: mockRePinPeerIdentity, })); // Mock streamPreview to isolate sidebar tests from preview DOM logic @@ -15,11 +20,23 @@ vi.mock("@lib/streamPreview", () => ({ attachScrollCollapse: (...args: unknown[]) => mockAttachScrollCollapse(...args), })); +// Stub the identity-key crypto so the mismatch modal's fingerprint compute is +// deterministic in jsdom (real WebCrypto key import needs a valid SPKI blob). +vi.mock("@lib/e2eeCrypto", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + importIdentityPublicKey: vi.fn(() => Promise.resolve({} as CryptoKey)), + computeKeyFingerprint: vi.fn(() => Promise.resolve("FEED FACE 1234 5678")), + }; +}); + import { createChannelSidebar } from "../../src/components/ChannelSidebar"; import { channelsStore, setChannels, setActiveChannel } from "../../src/stores/channels.store"; import { authStore } from "../../src/stores/auth.store"; import { uiStore, toggleCategory } from "../../src/stores/ui.store"; import { voiceStore, updateVoiceState } from "../../src/stores/voice.store"; +import type { PeerVerification } from "../../src/stores/voice.store"; import { membersStore } from "../../src/stores/members.store"; import type { ReadyChannel } from "../../src/lib/types"; @@ -62,6 +79,7 @@ function resetStores(): void { joinedAt: null, listenOnly: false, voiceStatus: "idle", + peerVerifications: new Map(), })); membersStore.setState(() => ({ members: new Map(), @@ -69,6 +87,33 @@ function resetStores(): void { })); } +/** Add a connected voice user to a channel (via the same store path the WS uses). */ +function addVoiceUser(channelId: number, userId: number, username: string): void { + updateVoiceState({ + channel_id: channelId, + user_id: userId, + username, + muted: false, + deafened: false, + speaking: false, + camera: false, + screenshare: false, + }); +} + +/** Record a peer's E2EE identity verification result in the voice store. */ +function setPeerVerif( + userId: number, + status: PeerVerification["status"], + safetyNumber: string | null = null, +): void { + voiceStore.setState((prev) => { + const peerVerifications = new Map(prev.peerVerifications ?? []); + peerVerifications.set(userId, { userId, status, safetyNumber }); + return { ...prev, peerVerifications }; + }); +} + const testChannels: ReadyChannel[] = [ { id: 1, @@ -1326,3 +1371,151 @@ describe("ChannelSidebar", () => { expect(mockAttachScrollCollapse).toHaveBeenCalled(); }); }); + +// ── E2EE identity verification badge on voice user rows (F3 TOFU) ── + +describe("ChannelSidebar voice identity badge", () => { + let container: HTMLDivElement; + let sidebar: ReturnType; + + const VOICE_CH = 3; // "voice-lobby" in testChannels + + beforeEach(() => { + resetStores(); + setChannels(testChannels); + container = document.createElement("div"); + document.body.appendChild(container); + sidebar = createChannelSidebar({ onVoiceJoin: vi.fn(), onVoiceLeave: vi.fn() }); + }); + + afterEach(() => { + sidebar.destroy?.(); + container.remove(); + document.querySelectorAll(".modal-overlay").forEach((el) => el.remove()); + mockRePinPeerIdentity.mockClear(); + }); + + function badgeFor(userId: number): HTMLElement | null { + return container.querySelector(`.voice-user-item[data-voice-uid="${userId}"] .vu-verify`); + } + + it("shows a verified badge carrying the safety number in its title", () => { + addVoiceUser(VOICE_CH, 10, "Alice"); + setPeerVerif(10, "verified", "AB12 CD34 EF56 7890"); + sidebar.mount(container); + + const badge = badgeFor(10); + expect(badge).not.toBeNull(); + expect(badge!.classList.contains("verified")).toBe(true); + expect(badge!.getAttribute("title")).toContain("AB12 CD34 EF56 7890"); + }); + + it("shows an unverified badge for a legacy peer", () => { + addVoiceUser(VOICE_CH, 10, "Alice"); + setPeerVerif(10, "unverified", null); + sidebar.mount(container); + + const badge = badgeFor(10); + expect(badge).not.toBeNull(); + expect(badge!.classList.contains("unverified")).toBe(true); + }); + + it("shows a mismatch badge for a peer whose identity key changed", () => { + addVoiceUser(VOICE_CH, 10, "Alice"); + setPeerVerif(10, "mismatch", null); + sidebar.mount(container); + + const badge = badgeFor(10); + expect(badge).not.toBeNull(); + expect(badge!.classList.contains("mismatch")).toBe(true); + }); + + it("shows no badge when the peer's verification is unresolved", () => { + addVoiceUser(VOICE_CH, 10, "Alice"); + sidebar.mount(container); + + expect(badgeFor(10)).toBeNull(); + }); + + it("re-renders the badge when a peer's verification changes after mount", () => { + addVoiceUser(VOICE_CH, 10, "Alice"); + setPeerVerif(10, "unverified", null); + sidebar.mount(container); + expect(badgeFor(10)!.classList.contains("unverified")).toBe(true); + + setPeerVerif(10, "verified", "AB12 CD34"); + voiceStore.flush(); + + const badge = badgeFor(10); + expect(badge).not.toBeNull(); + expect(badge!.classList.contains("verified")).toBe(true); + }); + + it("opens the identity-mismatch modal when the mismatch badge is clicked", async () => { + addVoiceUser(VOICE_CH, 10, "Alice"); + setPeerVerif(10, "mismatch", null); + sidebar.mount(container); + + // Opening is async (computes the changed key's fingerprint before mounting). + (badgeFor(10) as HTMLElement).click(); + await vi.waitFor(() => { + expect(document.body.querySelector(".modal-overlay")).not.toBeNull(); + }); + }); + + it("shows the changed key's fingerprint in the mismatch modal for out-of-band verification", async () => { + addVoiceUser(VOICE_CH, 10, "Alice"); + // The peer must have a published identity key for its fingerprint to be shown. + membersStore.setState((prev) => { + const members = new Map(prev.members); + members.set(10, { + id: 10, + username: "Alice", + avatar: null, + role: "member", + status: "online", + identityPublicKey: "alice-published-key-b64", + }); + return { ...prev, members }; + }); + setPeerVerif(10, "mismatch", null); + sidebar.mount(container); + + (badgeFor(10) as HTMLElement).click(); + await vi.waitFor(() => { + const fp = document.body.querySelector(".modal-overlay .cert-fingerprint"); + expect(fp?.textContent).toBe("FEED FACE 1234 5678"); + }); + }); + + it("re-pins the peer when the mismatch modal's Trust button is clicked", async () => { + addVoiceUser(VOICE_CH, 10, "Alice"); + setPeerVerif(10, "mismatch", null); + sidebar.mount(container); + + (badgeFor(10) as HTMLElement).click(); + const trustBtn = await vi.waitFor(() => { + const btn = document.body.querySelector(".modal-overlay .btn-danger") as HTMLButtonElement; + expect(btn).not.toBeNull(); + return btn; + }); + trustBtn.click(); + + expect(mockRePinPeerIdentity).toHaveBeenCalledWith(10); + expect(document.body.querySelector(".modal-overlay")).toBeNull(); + }); + + it("closes an open mismatch modal on sidebar destroy", async () => { + addVoiceUser(VOICE_CH, 10, "Alice"); + setPeerVerif(10, "mismatch", null); + sidebar.mount(container); + + (badgeFor(10) as HTMLElement).click(); + await vi.waitFor(() => { + expect(document.body.querySelector(".modal-overlay")).not.toBeNull(); + }); + + sidebar.destroy?.(); + expect(document.body.querySelector(".modal-overlay")).toBeNull(); + }); +}); diff --git a/Client/tauri-client/tests/unit/dispatcher.test.ts b/Client/tauri-client/tests/unit/dispatcher.test.ts index 02b7f789..2d0ceef7 100644 --- a/Client/tauri-client/tests/unit/dispatcher.test.ts +++ b/Client/tauri-client/tests/unit/dispatcher.test.ts @@ -30,9 +30,16 @@ vi.mock("@lib/livekitSession", () => ({ cleanupAll: vi.fn(), isVoiceConnected: vi.fn(() => false), })); +// F3: the ready handler publishes our identity key. Mock the orchestrator so +// the wiring is asserted without real keygen/keyring. +vi.mock("@lib/identity", () => ({ + ensureIdentityKeyPublished: vi.fn(async () => true), +})); import { isVoiceConnected as _isVoiceConnected } from "../../src/lib/livekitSession"; +import { ensureIdentityKeyPublished as _ensureIdentityKeyPublished } from "../../src/lib/identity"; const mockIsVoiceConnected = vi.mocked(_isVoiceConnected); +const mockEnsurePublished = vi.mocked(_ensureIdentityKeyPublished); // Suppress console output vi.spyOn(console, "info").mockImplementation(() => {}); @@ -883,6 +890,55 @@ describe("WS Dispatcher", () => { expect(blocksStore.getState().blockedByThem.size).toBe(0); }); + it("on ready publishes the client's identity key when the server copy is stale", async () => { + cleanup(); // tear down the no-api dispatcher wired in beforeEach + mockEnsurePublished.mockClear(); + const updateProfile = vi.fn().mockResolvedValue({}); + const getConfig = vi.fn(() => ({ + host: "chat.example", + token: "[redacted]" as string | undefined, + })); + const listBlocks = vi.fn().mockResolvedValue({ blocked_user_ids: [] }); + cleanup = wireDispatcher(mock.ws, { listBlocks, updateProfile, getConfig }); + + // We are user 7, "alex"; the server holds no identity key for us yet. + authStore.setState((prev) => ({ + ...prev, + user: { id: 7, username: "alex", avatar: null, role: "member" }, + })); + + mock.dispatch("ready", { + channels: [], + members: [ + { + id: 7, + username: "alex", + avatar: null, + role: "member", + status: "online", + identity_public_key: null, + }, + ], + voice_states: [], + roles: [], + }); + + await Promise.resolve(); + expect(mockEnsurePublished).toHaveBeenCalledWith( + "chat.example", + "alex", + null, + expect.any(Function), + ); + // The publish closure must route through api.updateProfile (server requires + // the username, injected by the orchestrator's caller). + const closure = mockEnsurePublished.mock.calls[0]![3] as (d: { + identity_public_key: string; + }) => Promise; + await closure({ identity_public_key: "k" }); + expect(updateProfile).toHaveBeenCalledWith({ identity_public_key: "k" }); + }); + it("on ready clears being-blocked state and refreshes blocked-by-me via api", async () => { cleanup(); // tear down the no-api dispatcher wired in beforeEach const listBlocks = vi.fn().mockResolvedValue({ blocked_user_ids: [11, 22] }); diff --git a/Client/tauri-client/tests/unit/e2eeCrypto.test.ts b/Client/tauri-client/tests/unit/e2eeCrypto.test.ts index 3e62275c..68e75804 100644 --- a/Client/tauri-client/tests/unit/e2eeCrypto.test.ts +++ b/Client/tauri-client/tests/unit/e2eeCrypto.test.ts @@ -8,6 +8,12 @@ import { unwrapRoomKey, computeKeyFingerprint, roomKeyToBase64, + generateIdentityKeyPair, + signEphemeralKey, + verifyEphemeralKeySignature, + importIdentityPublicKey, + exportIdentityKeyPair, + importIdentityKeyPair, } from "@lib/e2eeCrypto"; vi.mock("@lib/logger", () => ({ @@ -145,4 +151,134 @@ describe("e2eeCrypto", () => { expect(() => atob(encoded)).not.toThrow(); }); }); + + // ── Identity sign / verify (F3 TOFU) ─────────────────────────────────────── + + describe("signEphemeralKey / verifyEphemeralKeySignature", () => { + const userId = 42; + + async function fixture() { + const identity = await generateIdentityKeyPair(); + const ephemeral = await generateECDHKeyPair(); + const ephemeralRaw = new Uint8Array( + await crypto.subtle.exportKey("raw", ephemeral.publicKey), + ); + const signature = await signEphemeralKey(identity.privateKey, userId, ephemeralRaw); + return { identity, ephemeralRaw, signature }; + } + + it("round-trips: a valid signature verifies against the identity public key", async () => { + const { identity, ephemeralRaw, signature } = await fixture(); + const ok = await verifyEphemeralKeySignature( + identity.publicKey, + userId, + ephemeralRaw, + signature, + ); + expect(ok).toBe(true); + }); + + it("verifies against a public key re-imported from its base64 raw form", async () => { + const { identity, ephemeralRaw, signature } = await fixture(); + const pubBase64 = await exportPublicKey(identity.publicKey); + const reimported = await importIdentityPublicKey(pubBase64); + const ok = await verifyEphemeralKeySignature(reimported, userId, ephemeralRaw, signature); + expect(ok).toBe(true); + }); + + it("fails when the userId is tampered (server re-attribution)", async () => { + const { identity, ephemeralRaw, signature } = await fixture(); + const ok = await verifyEphemeralKeySignature( + identity.publicKey, + userId + 1, + ephemeralRaw, + signature, + ); + expect(ok).toBe(false); + }); + + it("fails when the ephemeral key is substituted (server MITM)", async () => { + const { identity, signature } = await fixture(); + const other = await generateECDHKeyPair(); + const otherRaw = new Uint8Array(await crypto.subtle.exportKey("raw", other.publicKey)); + const ok = await verifyEphemeralKeySignature(identity.publicKey, userId, otherRaw, signature); + expect(ok).toBe(false); + }); + + it("fails when the signature bytes are tampered", async () => { + const { identity, ephemeralRaw, signature } = await fixture(); + const bytes = Uint8Array.from(atob(signature), (c) => c.charCodeAt(0)); + bytes[0] = bytes[0]! ^ 0xff; + const tampered = btoa(String.fromCharCode(...bytes)); + const ok = await verifyEphemeralKeySignature( + identity.publicKey, + userId, + ephemeralRaw, + tampered, + ); + expect(ok).toBe(false); + }); + + it("returns false (not throw) on malformed base64 signature", async () => { + const { identity, ephemeralRaw } = await fixture(); + const ok = await verifyEphemeralKeySignature( + identity.publicKey, + userId, + ephemeralRaw, + "not valid base64 !!!", + ); + expect(ok).toBe(false); + }); + + it("fails against a different identity key (wrong signer)", async () => { + const { ephemeralRaw, signature } = await fixture(); + const attacker = await generateIdentityKeyPair(); + const ok = await verifyEphemeralKeySignature( + attacker.publicKey, + userId, + ephemeralRaw, + signature, + ); + expect(ok).toBe(false); + }); + }); + + // ── Identity keypair persistence (keyring blob round-trip) ────────────────── + + describe("exportIdentityKeyPair / importIdentityKeyPair", () => { + it("round-trips a keypair through the JWK blob and can still sign+verify", async () => { + const original = await generateIdentityKeyPair(); + const blob = await exportIdentityKeyPair(original.privateKey); + const restored = await importIdentityKeyPair(blob); + + const ephemeral = await generateECDHKeyPair(); + const ephemeralRaw = new Uint8Array( + await crypto.subtle.exportKey("raw", ephemeral.publicKey), + ); + + // Sign with the restored private key, verify with the restored public key. + const sig = await signEphemeralKey(restored.privateKey, 7, ephemeralRaw); + expect(await verifyEphemeralKeySignature(restored.publicKey, 7, ephemeralRaw, sig)).toBe( + true, + ); + + // Public key survives the round-trip identically (safety-number stability). + const fpOriginal = await computeKeyFingerprint(original.publicKey); + const fpRestored = await computeKeyFingerprint(restored.publicKey); + expect(fpRestored).toBe(fpOriginal); + }); + }); + + // ── Identity fingerprint stability (safety number repoint) ────────────────── + + describe("computeKeyFingerprint on identity keys", () => { + it("is stable across export/import of the identity public key", async () => { + const identity = await generateIdentityKeyPair(); + const base64 = await exportPublicKey(identity.publicKey); + const reimported = await importIdentityPublicKey(base64); + expect(await computeKeyFingerprint(reimported)).toBe( + await computeKeyFingerprint(identity.publicKey), + ); + }); + }); }); diff --git a/Client/tauri-client/tests/unit/icons.test.ts b/Client/tauri-client/tests/unit/icons.test.ts index 92793672..ce45909b 100644 --- a/Client/tauri-client/tests/unit/icons.test.ts +++ b/Client/tauri-client/tests/unit/icons.test.ts @@ -40,6 +40,9 @@ const ALL_ICON_NAMES: IconName[] = [ "arrow-right", "hash", "triangle-alert", + "shield", + "shield-check", + "shield-alert", ]; describe("createIcon", () => { diff --git a/Client/tauri-client/tests/unit/identity-mismatch-modal.test.ts b/Client/tauri-client/tests/unit/identity-mismatch-modal.test.ts new file mode 100644 index 00000000..d043dea1 --- /dev/null +++ b/Client/tauri-client/tests/unit/identity-mismatch-modal.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createIdentityMismatchModal } from "../../src/components/CertMismatchModal"; + +// --------------------------------------------------------------------------- +// IdentityMismatchModal — the E2EE-identity analogue of the cert-mismatch +// modal (F3 TOFU). Surfaces a peer whose voice identity key no longer matches +// the pinned one, and offers re-pin recovery for legitimate key rotation. +// --------------------------------------------------------------------------- + +describe("IdentityMismatchModal", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + function mountModal(overrides?: Partial[0]>) { + const onAccept = vi.fn(); + const onReject = vi.fn(); + const modal = createIdentityMismatchModal({ + username: "Alice", + fingerprint: "AB12 CD34 EF56 7890", + onAccept, + onReject, + ...overrides, + }); + modal.mount(container); + return { modal, onAccept, onReject }; + } + + it("renders a visible modal overlay", () => { + mountModal(); + const overlay = container.querySelector(".modal-overlay"); + expect(overlay).not.toBeNull(); + expect(overlay!.classList.contains("visible")).toBe(true); + }); + + it("displays the peer username in the details", () => { + mountModal(); + const values = container.querySelectorAll(".cert-value"); + const texts = Array.from(values).map((el) => el.textContent); + expect(texts).toContain("Alice"); + }); + + it("displays the new identity-key fingerprint when provided", () => { + mountModal(); + const fps = container.querySelectorAll(".cert-fingerprint"); + const texts = Array.from(fps).map((el) => el.textContent); + expect(texts).toContain("AB12 CD34 EF56 7890"); + }); + + it("omits the fingerprint row when fingerprint is null", () => { + mountModal({ fingerprint: null }); + expect(container.querySelectorAll(".cert-fingerprint").length).toBe(0); + }); + + it("calls onAccept when the trust button is clicked", () => { + const { onAccept } = mountModal(); + const btn = container.querySelector(".btn-danger") as HTMLButtonElement; + expect(btn).not.toBeNull(); + btn.click(); + expect(onAccept).toHaveBeenCalledOnce(); + }); + + it("calls onReject when the cancel button is clicked", () => { + const { onReject } = mountModal(); + const btn = container.querySelector(".btn-ghost") as HTMLButtonElement; + expect(btn).not.toBeNull(); + btn.click(); + expect(onReject).toHaveBeenCalledOnce(); + }); + + it("calls onReject when the close X button is clicked", () => { + const { onReject } = mountModal(); + const btn = container.querySelector(".modal-close") as HTMLButtonElement; + expect(btn).not.toBeNull(); + btn.click(); + expect(onReject).toHaveBeenCalledOnce(); + }); + + it("calls onReject when the backdrop is clicked", () => { + const { onReject } = mountModal(); + const overlay = container.querySelector(".modal-overlay") as HTMLDivElement; + overlay.click(); + expect(onReject).toHaveBeenCalledOnce(); + }); + + it("does not call onReject when the modal body is clicked", () => { + const { onReject } = mountModal(); + const modal = container.querySelector(".modal") as HTMLDivElement; + modal.click(); + expect(onReject).not.toHaveBeenCalled(); + }); + + it("destroy removes the modal from the DOM", () => { + const { modal } = mountModal(); + expect(container.querySelector(".modal-overlay")).not.toBeNull(); + modal.destroy?.(); + expect(container.querySelector(".modal-overlay")).toBeNull(); + }); + + it("displays the title 'Identity Warning'", () => { + mountModal(); + const title = container.querySelector(".modal-header h3"); + expect(title?.textContent).toBe("Identity Warning"); + }); + + it("displays the cert title 'Identity Key Changed'", () => { + mountModal(); + const title = container.querySelector(".cert-title"); + expect(title?.textContent).toBe("Identity Key Changed"); + }); +}); diff --git a/Client/tauri-client/tests/unit/identity.test.ts b/Client/tauri-client/tests/unit/identity.test.ts new file mode 100644 index 00000000..7d1409c0 --- /dev/null +++ b/Client/tauri-client/tests/unit/identity.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() })); + +vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock })); +vi.mock("@lib/logger", () => ({ + createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }), +})); + +import { + saveIdentityKey, + loadIdentityKey, + deleteIdentityKey, + storeIdentityPin, + getIdentityPin, + getOrCreateIdentityKeyPair, + publishIdentityKey, + ensureIdentityKeyPublished, +} from "@lib/identity"; +import { generateIdentityKeyPair, exportPublicKey } from "@lib/e2eeCrypto"; + +beforeEach(() => { + invokeMock.mockReset(); +}); + +describe("identity keyring wrappers", () => { + it("saveIdentityKey invokes save_identity_key with { host, key }", async () => { + invokeMock.mockResolvedValue(undefined); + const ok = await saveIdentityKey("chat.example", "blob"); + expect(ok).toBe(true); + expect(invokeMock).toHaveBeenCalledWith("save_identity_key", { + host: "chat.example", + key: "blob", + }); + }); + + it("loadIdentityKey returns the stored string, or null when absent", async () => { + invokeMock.mockResolvedValueOnce("blob"); + expect(await loadIdentityKey("chat.example")).toBe("blob"); + invokeMock.mockResolvedValueOnce(null); + expect(await loadIdentityKey("chat.example")).toBeNull(); + }); + + it("deleteIdentityKey invokes delete_identity_key with { host }", async () => { + invokeMock.mockResolvedValue(undefined); + expect(await deleteIdentityKey("chat.example")).toBe(true); + expect(invokeMock).toHaveBeenCalledWith("delete_identity_key", { host: "chat.example" }); + }); + + it("returns false/null and swallows errors when a command rejects", async () => { + invokeMock.mockRejectedValue(new Error("keyring boom")); + expect(await saveIdentityKey("h", "k")).toBe(false); + expect(await loadIdentityKey("h")).toBeNull(); + expect(await deleteIdentityKey("h")).toBe(false); + }); +}); + +describe("identity pin wrappers", () => { + it("storeIdentityPin invokes store_identity_pin with { host, userId, pin }", async () => { + invokeMock.mockResolvedValue(undefined); + const ok = await storeIdentityPin("chat.example", "42", "pubkey"); + expect(ok).toBe(true); + expect(invokeMock).toHaveBeenCalledWith("store_identity_pin", { + host: "chat.example", + userId: "42", + pin: "pubkey", + }); + }); + + it("getIdentityPin returns the pinned key, or null when never pinned", async () => { + invokeMock.mockResolvedValueOnce("pubkey"); + expect(await getIdentityPin("chat.example", "42")).toBe("pubkey"); + invokeMock.mockResolvedValueOnce(null); + expect(await getIdentityPin("chat.example", "42")).toBeNull(); + expect(invokeMock).toHaveBeenCalledWith("get_identity_pin", { + host: "chat.example", + userId: "42", + }); + }); +}); + +describe("getOrCreateIdentityKeyPair", () => { + it("generates + saves a fresh keypair on first login (nothing stored)", async () => { + invokeMock.mockImplementation((cmd: string) => { + if (cmd === "load_identity_key") return Promise.resolve(null); + return Promise.resolve(undefined); + }); + + const kp = await getOrCreateIdentityKeyPair("chat.example"); + expect(kp.privateKey).toBeDefined(); + expect(kp.publicKey).toBeDefined(); + + const saveCall = invokeMock.mock.calls.find((c) => c[0] === "save_identity_key"); + expect(saveCall).toBeDefined(); + expect((saveCall![1] as { host: string }).host).toBe("chat.example"); + }); + + it("reloads the persisted keypair on subsequent logins (no regenerate)", async () => { + // First login: capture the blob that gets saved. + let savedBlob: string | undefined; + invokeMock.mockImplementation((cmd: string, args?: Record) => { + if (cmd === "load_identity_key") return Promise.resolve(null); + if (cmd === "save_identity_key") { + savedBlob = args!.key as string; + return Promise.resolve(undefined); + } + return Promise.resolve(undefined); + }); + const first = await getOrCreateIdentityKeyPair("chat.example"); + const firstPub = await exportPublicKey(first.publicKey); + + // Second login: keyring returns the saved blob → same public key, no save. + invokeMock.mockReset(); + invokeMock.mockImplementation((cmd: string) => { + if (cmd === "load_identity_key") return Promise.resolve(savedBlob); + return Promise.resolve(undefined); + }); + const second = await getOrCreateIdentityKeyPair("chat.example"); + expect(await exportPublicKey(second.publicKey)).toBe(firstPub); + expect(invokeMock.mock.calls.some((c) => c[0] === "save_identity_key")).toBe(false); + }); + + it("regenerates when the stored blob is corrupt", async () => { + invokeMock.mockImplementation((cmd: string) => { + if (cmd === "load_identity_key") return Promise.resolve("!!not-valid-jwk!!"); + return Promise.resolve(undefined); + }); + const kp = await getOrCreateIdentityKeyPair("chat.example"); + expect(kp.publicKey).toBeDefined(); + expect(invokeMock.mock.calls.some((c) => c[0] === "save_identity_key")).toBe(true); + }); +}); + +describe("publishIdentityKey", () => { + it("publishes when the server copy is absent", async () => { + const { publicKey } = await generateIdentityKeyPair(); + const updateProfile = vi.fn().mockResolvedValue({}); + const published = await publishIdentityKey(updateProfile, null, publicKey); + expect(published).toBe(true); + const expected = await exportPublicKey(publicKey); + expect(updateProfile).toHaveBeenCalledWith({ identity_public_key: expected }); + }); + + it("publishes when the server copy differs", async () => { + const { publicKey } = await generateIdentityKeyPair(); + const updateProfile = vi.fn().mockResolvedValue({}); + expect(await publishIdentityKey(updateProfile, "some-other-key", publicKey)).toBe(true); + expect(updateProfile).toHaveBeenCalledOnce(); + }); + + it("no-ops when the server copy already matches (idempotent)", async () => { + const { publicKey } = await generateIdentityKeyPair(); + const current = await exportPublicKey(publicKey); + const updateProfile = vi.fn().mockResolvedValue({}); + expect(await publishIdentityKey(updateProfile, current, publicKey)).toBe(false); + expect(updateProfile).not.toHaveBeenCalled(); + }); +}); + +describe("ensureIdentityKeyPublished (login/ready publish flow)", () => { + it("publishes username + identity key when the server copy is absent", async () => { + // First-login keyring: nothing stored → a fresh keypair is generated. + invokeMock.mockImplementation((cmd: string) => { + if (cmd === "load_identity_key") return Promise.resolve(null); + return Promise.resolve(undefined); + }); + const updateProfile = vi.fn().mockResolvedValue({}); + + const published = await ensureIdentityKeyPublished("chat.example", "alex", null, updateProfile); + + expect(published).toBe(true); + expect(updateProfile).toHaveBeenCalledTimes(1); + const arg = updateProfile.mock.calls[0]![0] as { + username: string; + identity_public_key: string; + }; + // Server requires a username alongside identity_public_key — both present. + expect(arg.username).toBe("alex"); + expect(typeof arg.identity_public_key).toBe("string"); + expect(arg.identity_public_key.length).toBeGreaterThan(0); + }); + + it("no-ops when the server copy already matches the local key (idempotent)", async () => { + // Keyring persists the generated blob across calls → same public key. + let savedBlob: string | undefined; + invokeMock.mockImplementation((cmd: string, args?: Record) => { + if (cmd === "load_identity_key") return Promise.resolve(savedBlob ?? null); + if (cmd === "save_identity_key") { + savedBlob = args!.key as string; + return Promise.resolve(undefined); + } + return Promise.resolve(undefined); + }); + + const first = vi.fn().mockResolvedValue({}); + await ensureIdentityKeyPublished("chat.example", "alex", null, first); + const serverCopy = (first.mock.calls[0]![0] as { identity_public_key: string }) + .identity_public_key; + + const second = vi.fn().mockResolvedValue({}); + const published = await ensureIdentityKeyPublished("chat.example", "alex", serverCopy, second); + + expect(published).toBe(false); + expect(second).not.toHaveBeenCalled(); + }); + + it("swallows a failing profile update (fire-and-forget, never throws)", async () => { + invokeMock.mockImplementation((cmd: string) => { + if (cmd === "load_identity_key") return Promise.resolve(null); + return Promise.resolve(undefined); + }); + const updateProfile = vi.fn().mockRejectedValue(new Error("network down")); + await expect( + ensureIdentityKeyPublished("chat.example", "alex", null, updateProfile), + ).resolves.toBe(false); + }); +}); diff --git a/Client/tauri-client/tests/unit/livekit-session.test.ts b/Client/tauri-client/tests/unit/livekit-session.test.ts index e370ba15..1d39cb50 100644 --- a/Client/tauri-client/tests/unit/livekit-session.test.ts +++ b/Client/tauri-client/tests/unit/livekit-session.test.ts @@ -86,6 +86,9 @@ vi.mock("@stores/voice.store", () => ({ leaveVoiceChannel: vi.fn(), setListenOnly: vi.fn(), setVoiceStatus: vi.fn(), + setPeerVerification: vi.fn(), + clearPeerVerification: vi.fn(), + clearPeerVerifications: vi.fn(), })); const mockInvoke = vi.hoisted(() => @@ -128,14 +131,33 @@ const mockKeyPair = vi.hoisted(() => ({ privateKey: { type: "private" } as unknown as CryptoKey, })); +const mockIdentityKeyPair = vi.hoisted(() => ({ + publicKey: { type: "id-public" } as unknown as CryptoKey, + privateKey: { type: "id-private" } as unknown as CryptoKey, +})); + vi.mock("@lib/e2eeCrypto", () => ({ generateECDHKeyPair: vi.fn(async () => mockKeyPair), - exportPublicKey: vi.fn(async () => "mock-pub-key-base64"), + exportPublicKey: vi.fn(async () => "bW9ja2VwaGVtZXJhbA=="), importPublicKey: vi.fn(async () => ({ type: "public" }) as unknown as CryptoKey), generateRoomKey: vi.fn(() => new Uint8Array(32)), roomKeyToBase64: vi.fn(() => "mock-room-key-base64"), wrapRoomKey: vi.fn(async () => ({ encryptedKey: "enc", iv: "iv" })), unwrapRoomKey: vi.fn(async () => new Uint8Array(32)), + // F3 TOFU identity signing/verification + signEphemeralKey: vi.fn(async () => "mock-signature"), + verifyEphemeralKeySignature: vi.fn(async () => true), + importIdentityPublicKey: vi.fn( + async () => ({ type: "id-public-imported" }) as unknown as CryptoKey, + ), + computeKeyFingerprint: vi.fn(async () => "AB12 CD34 EF56 7890"), +})); + +// F3 TOFU: identity keyring + peer pin store (Tauri-backed; mocked here). +vi.mock("@lib/identity", () => ({ + getOrCreateIdentityKeyPair: vi.fn(async () => mockIdentityKeyPair), + getIdentityPin: vi.fn(async () => null), + storeIdentityPin: vi.fn(async () => true), })); // Stub Worker for E2EE web worker (not available in Node/vitest) @@ -151,7 +173,13 @@ import { setListenOnly, leaveVoiceChannel, setVoiceStatus, + setPeerVerification, + clearPeerVerifications, } from "@stores/voice.store"; +import { getIdentityPin, storeIdentityPin } from "@lib/identity"; +import { verifyEphemeralKeySignature } from "@lib/e2eeCrypto"; +import { setMembers } from "@stores/members.store"; +import type { ReadyMember } from "../../src/lib/types"; import { isVoiceConnected, leaveVoice as boundLeaveVoice, @@ -2068,4 +2096,204 @@ describe("LiveKitSession", () => { ); }); }); + + // ----------------------------------------------------------------------- + // F3: Voice E2EE identity-key signing + TOFU verification (receive path) + // ----------------------------------------------------------------------- + + describe("E2EE announce verification (F3 TOFU)", () => { + const HOST = "localhost:7880"; + const PEER_ID = 42; + + function seedPeer(identityPublicKey: string | null): void { + const peer: ReadyMember = { + id: PEER_ID, + username: "peer", + avatar: null, + role: "member", + status: "online", + identity_public_key: identityPublicKey, + }; + setMembers([peer]); + } + + async function joinAsKeyHolder(ws: { send: ReturnType }): Promise { + session.setServerHost(HOST); + session.setWsClient(ws as any); + await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880", true); + } + + function offerSends(ws: { send: ReturnType }): unknown[] { + return ws.send.mock.calls.map((c) => c[0]).filter((m: any) => m?.type === "voice_e2ee_offer"); + } + + beforeEach(() => { + // Restore TOFU mock defaults — persistent overrides survive clearAllMocks. + (getIdentityPin as any).mockResolvedValue(null); + (storeIdentityPin as any).mockResolvedValue(true); + (verifyEphemeralKeySignature as any).mockResolvedValue(true); + }); + + it("signs the ephemeral announce sent on join", async () => { + seedPeer("peer-identity-b64"); + const ws = { send: vi.fn() }; + await joinAsKeyHolder(ws); + + const announce = ws.send.mock.calls + .map((c) => c[0]) + .find((m: any) => m?.type === "voice_e2ee_announce"); + expect(announce).toBeDefined(); + expect((announce as any).payload.signature).toBe("mock-signature"); + }); + + it("rejects a server-substituted peer ephemeral key (signature verify fails)", async () => { + seedPeer("peer-identity-b64"); + (verifyEphemeralKeySignature as any).mockResolvedValue(false); + const ws = { send: vi.fn() }; + await joinAsKeyHolder(ws); + ws.send.mockClear(); + + await session.handleE2EEAnnounce(PEER_ID, "cGVlcg==", "sig"); + + // No room-key offer wrapped for an unverifiable peer, key not stored. + expect(offerSends(ws)).toHaveLength(0); + expect((session as any)._peerPublicKeys.has(PEER_ID)).toBe(false); + expect(setPeerVerification).toHaveBeenCalledWith( + expect.objectContaining({ userId: PEER_ID, status: "mismatch" }), + ); + expect(storeIdentityPin).not.toHaveBeenCalled(); + }); + + it("pins the peer identity key on first sight and marks it verified", async () => { + seedPeer("peer-identity-b64"); + (getIdentityPin as any).mockResolvedValue(null); + const ws = { send: vi.fn() }; + await joinAsKeyHolder(ws); + ws.send.mockClear(); + + await session.handleE2EEAnnounce(PEER_ID, "cGVlcg==", "sig"); + + expect(storeIdentityPin).toHaveBeenCalledWith(HOST, String(PEER_ID), "peer-identity-b64"); + expect(setPeerVerification).toHaveBeenCalledWith( + expect.objectContaining({ + userId: PEER_ID, + status: "verified", + safetyNumber: "AB12 CD34 EF56 7890", + }), + ); + // Verified peer is stored and (we are key holder) receives a room-key offer. + expect((session as any)._peerPublicKeys.has(PEER_ID)).toBe(true); + expect(offerSends(ws)).toHaveLength(1); + }); + + it("blocks and emits identity-tofu when the pinned identity key changed", async () => { + seedPeer("new-identity-b64"); + (getIdentityPin as any).mockResolvedValue("old-identity-b64"); + const ws = { send: vi.fn() }; + await joinAsKeyHolder(ws); + ws.send.mockClear(); + + await session.handleE2EEAnnounce(PEER_ID, "cGVlcg==", "sig"); + + expect(setPeerVerification).toHaveBeenCalledWith( + expect.objectContaining({ userId: PEER_ID, status: "mismatch" }), + ); + // Blocked before verify — no pin overwrite, no signature check, no offer. + expect(storeIdentityPin).not.toHaveBeenCalled(); + expect(verifyEphemeralKeySignature).not.toHaveBeenCalled(); + expect(offerSends(ws)).toHaveLength(0); + expect((session as any)._peerPublicKeys.has(PEER_ID)).toBe(false); + }); + + it("blocks a pinned peer when the server strips its published identity key", async () => { + // Peer was pinned before; the server now omits identity_public_key to + // shove the peer onto the legacy accept path (finding #2). A pinned peer + // must never fall back to legacy — this is an identity mismatch. + seedPeer(null); + (getIdentityPin as any).mockResolvedValue("old-identity-b64"); + const ws = { send: vi.fn() }; + await joinAsKeyHolder(ws); + ws.send.mockClear(); + + await session.handleE2EEAnnounce(PEER_ID, "cGVlcg==", "sig"); + + expect(setPeerVerification).toHaveBeenCalledWith( + expect.objectContaining({ userId: PEER_ID, status: "mismatch" }), + ); + // Blocked: not accepted as legacy/unverified, key not stored, no offer. + expect(setPeerVerification).not.toHaveBeenCalledWith( + expect.objectContaining({ userId: PEER_ID, status: "unverified" }), + ); + expect((session as any)._peerPublicKeys.has(PEER_ID)).toBe(false); + expect(offerSends(ws)).toHaveLength(0); + expect(storeIdentityPin).not.toHaveBeenCalled(); + expect(verifyEphemeralKeySignature).not.toHaveBeenCalled(); + }); + + it("accepts a legacy peer with no identity key but marks it unverified", async () => { + seedPeer(null); + const ws = { send: vi.fn() }; + await joinAsKeyHolder(ws); + ws.send.mockClear(); + + await session.handleE2EEAnnounce(PEER_ID, "cGVlcg==", undefined); + + expect(setPeerVerification).toHaveBeenCalledWith( + expect.objectContaining({ userId: PEER_ID, status: "unverified", safetyNumber: null }), + ); + // Legacy peer still works: stored + wrapped, without verify or pin. + expect((session as any)._peerPublicKeys.has(PEER_ID)).toBe(true); + expect(offerSends(ws)).toHaveLength(1); + expect(verifyEphemeralKeySignature).not.toHaveBeenCalled(); + expect(storeIdentityPin).not.toHaveBeenCalled(); + }); + + it("re-pin recovers a mismatched peer so a later valid announce verifies", async () => { + // Peer legitimately rotated its identity key (reinstall / new device). + // Its pinned key mismatches the new published one → blocked. + seedPeer("new-identity-b64"); + (getIdentityPin as any).mockResolvedValue("old-identity-b64"); + const ws = { send: vi.fn() }; + await joinAsKeyHolder(ws); + ws.send.mockClear(); + + await session.handleE2EEAnnounce(PEER_ID, "cGVlcg==", "sig"); + expect(setPeerVerification).toHaveBeenLastCalledWith( + expect.objectContaining({ userId: PEER_ID, status: "mismatch" }), + ); + + // User accepts the new key (analogous to accepting a changed TLS cert): + // re-pin overwrites the stored pin and clears the mismatch block. + const recovered = await session.rePinPeerIdentity(PEER_ID); + expect(recovered).toBe(true); + expect(storeIdentityPin).toHaveBeenCalledWith(HOST, String(PEER_ID), "new-identity-b64"); + + // Store now holds the new pin; a fresh valid announce verifies. + (getIdentityPin as any).mockResolvedValue("new-identity-b64"); + (storeIdentityPin as any).mockClear(); + ws.send.mockClear(); + await session.handleE2EEAnnounce(PEER_ID, "cGVlcg==", "sig"); + + expect(setPeerVerification).toHaveBeenLastCalledWith( + expect.objectContaining({ userId: PEER_ID, status: "verified" }), + ); + expect((session as any)._peerPublicKeys.has(PEER_ID)).toBe(true); + expect(offerSends(ws)).toHaveLength(1); + }); + + it("verifies a server-substituted key when drained from the pending queue", async () => { + seedPeer("peer-identity-b64"); + (verifyEphemeralKeySignature as any).mockResolvedValue(false); + const ws = { send: vi.fn() }; + // Announce arrives BEFORE the keypair is ready → queued, drained on join. + await session.handleE2EEAnnounce(PEER_ID, "cGVlcg==", "sig"); + expect((session as any)._pendingAnnounces).toHaveLength(1); + + await joinAsKeyHolder(ws); + + // Drain ran through the verifying path → substituted key rejected. + expect((session as any)._peerPublicKeys.has(PEER_ID)).toBe(false); + expect(offerSends(ws)).toHaveLength(0); + }); + }); }); diff --git a/Client/tauri-client/tests/unit/members.store.test.ts b/Client/tauri-client/tests/unit/members.store.test.ts index 453cfac5..222cd3a3 100644 --- a/Client/tauri-client/tests/unit/members.store.test.ts +++ b/Client/tauri-client/tests/unit/members.store.test.ts @@ -76,6 +76,7 @@ describe("members store", () => { avatar: "alice.png", role: "admin", status: "online", + identityPublicKey: null, }); }); @@ -109,6 +110,7 @@ describe("members store", () => { avatar: null, role: "member", status: "online", + identityPublicKey: null, }); }); diff --git a/Client/tauri-client/tests/unit/voice.store.test.ts b/Client/tauri-client/tests/unit/voice.store.test.ts index 0f22daf0..9bfc0adc 100644 --- a/Client/tauri-client/tests/unit/voice.store.test.ts +++ b/Client/tauri-client/tests/unit/voice.store.test.ts @@ -33,6 +33,7 @@ function resetStore(): void { joinedAt: null, listenOnly: false, voiceStatus: "idle", + peerVerifications: new Map(), })); } diff --git a/Server/admin/api_test.go b/Server/admin/api_test.go index db67ef68..5e167d7c 100644 --- a/Server/admin/api_test.go +++ b/Server/admin/api_test.go @@ -55,7 +55,8 @@ CREATE TABLE IF NOT EXISTS users ( last_seen TEXT, banned INTEGER NOT NULL DEFAULT 0, ban_reason TEXT, - ban_expires TEXT + ban_expires TEXT, + identity_public_key TEXT ); CREATE TABLE IF NOT EXISTS sessions ( diff --git a/Server/admin/middleware_and_spawn_test.go b/Server/admin/middleware_and_spawn_test.go index 7ee7869a..feae08b4 100644 --- a/Server/admin/middleware_and_spawn_test.go +++ b/Server/admin/middleware_and_spawn_test.go @@ -54,7 +54,8 @@ CREATE TABLE IF NOT EXISTS users ( last_seen TEXT, banned INTEGER NOT NULL DEFAULT 0, ban_reason TEXT, - ban_expires TEXT + ban_expires TEXT, + identity_public_key TEXT ); CREATE TABLE IF NOT EXISTS sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/Server/admin/update_handlers.go b/Server/admin/update_handlers.go index 65368a8b..2fc11a30 100644 --- a/Server/admin/update_handlers.go +++ b/Server/admin/update_handlers.go @@ -78,23 +78,17 @@ func handleApplyUpdate(u *updater.Updater, hub HubBroadcaster, _ string) http.Ha ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) defer cancel() - if err := u.DownloadAndVerify(ctx, info.Latest, info.DownloadURL, info.ChecksumURL, info.SignatureURL, info.ManifestURL, info.ManifestSignatureURL, newPath); err != nil { + // DownloadAndVerify stages the binary and returns its trusted hash + // (bound to the signed release manifest). The apply goroutine below + // re-verifies the staged file against this hash through an open + // handle — never by path — before the rename+spawn. + stagedHash, err := u.DownloadAndVerify(ctx, info.Latest, info.DownloadURL, info.ChecksumURL, info.SignatureURL, info.ManifestURL, info.ManifestSignatureURL, newPath) + if err != nil { slog.Error("update download/verify failed", "err", err) writeErr(w, http.StatusBadGateway, "DOWNLOAD_FAILED", "download or verification failed — see server logs") return } - // Snapshot the hash of the just-verified staged binary. It is re-checked - // immediately before rename+spawn to close the TOCTOU window between - // verification here and the swap in the background goroutine below. - stagedHash, err := updater.FileSHA256(newPath) - if err != nil { - slog.Error("update: failed to hash staged binary", "err", err) - _ = os.Remove(newPath) - writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to stage update") - return - } - // Respond to the client before shutting down. writeJSON(w, http.StatusOK, map[string]string{ "status": "applying", @@ -108,23 +102,27 @@ func handleApplyUpdate(u *updater.Updater, hub HubBroadcaster, _ string) http.Ha } time.Sleep(5 * time.Second) - // TOCTOU guard: re-verify the staged binary is byte-for-byte the one - // we verified before responding. If it was swapped between then and - // now, abort without renaming or spawning it. - if err := u.VerifyChecksum(newPath, stagedHash); err != nil { + // TOCTOU guard: open the staged binary once, verify its hash + // through that handle, and commit (rename) that exact file. + // Commit fails if the path was swapped after verification, so + // the bytes verified are the bytes spawned. + staged, err := updater.OpenVerifiedBinary(newPath, stagedHash) + if err != nil { slog.Error("update: staged binary re-verification failed, aborting update", "error", err) return } + defer staged.Close() //nolint:errcheck - // Rename: current -> .old, .new -> current + // Rename: current -> .old, verified staged binary -> current _ = os.Remove(oldPath) // remove any stale .old if err := os.Rename(exePath, oldPath); err != nil { slog.Error("update: rename current to old failed", "error", err) return } - if err := os.Rename(newPath, exePath); err != nil { - slog.Error("update: rename new to current failed", "error", err) - // Try to restore the original binary. + if err := staged.Commit(exePath); err != nil { + slog.Error("update: committing staged binary failed, restoring original binary", "error", err) + // Whatever is at exePath now (if anything) is not the verified + // binary; restoring .old replaces it. if restoreErr := os.Rename(oldPath, exePath); restoreErr != nil { slog.Error("update: CRITICAL — recovery rename also failed, server binary may be missing", "restore_error", restoreErr, "original_error", err, diff --git a/Server/api/auth_handler.go b/Server/api/auth_handler.go index d69ca10d..1392e01d 100644 --- a/Server/api/auth_handler.go +++ b/Server/api/auth_handler.go @@ -253,6 +253,7 @@ func handleRegister(database *db.DB) http.HandlerFunc { // handleLogin processes POST /api/v1/auth/login. func handleLogin(database *db.DB, limiter *auth.RateLimiter, partialStore *auth.PartialAuthStore, trustedProxies []string) http.HandlerFunc { + proxyNets := parseCIDRList(trustedProxies) // W3-3a: parse once at construction return func(w http.ResponseWriter, r *http.Request) { var req loginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -275,7 +276,7 @@ func handleLogin(database *db.DB, limiter *auth.RateLimiter, partialStore *auth. return } - ip := clientIPWithProxies(r, trustedProxies) + ip := clientIPWithProxies(r, proxyNets) // Check per-IP lockout first. lockKey := "login_lock:" + ip diff --git a/Server/api/channel_handler.go b/Server/api/channel_handler.go index 7ab96263..33063b93 100644 --- a/Server/api/channel_handler.go +++ b/Server/api/channel_handler.go @@ -31,9 +31,10 @@ func isInvalidSearchQueryError(err error) bool { } func searchRateLimitMiddleware(limiter *auth.RateLimiter, limit int, window time.Duration, trustedProxies []string) func(http.Handler) http.Handler { + proxyNets := parseCIDRList(trustedProxies) // W3-3a: parse once at construction return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ip := clientIPWithProxies(r, trustedProxies) + ip := clientIPWithProxies(r, proxyNets) if !limiter.Allow("search:"+ip, limit, window) { w.Header().Set("Retry-After", strconv.Itoa(int(window.Seconds()))) writeJSON(w, http.StatusTooManyRequests, errorResponse{ diff --git a/Server/api/channel_handler_test.go b/Server/api/channel_handler_test.go index 388b921e..e9334f5d 100644 --- a/Server/api/channel_handler_test.go +++ b/Server/api/channel_handler_test.go @@ -45,7 +45,8 @@ CREATE TABLE IF NOT EXISTS users ( last_seen TEXT, banned INTEGER NOT NULL DEFAULT 0, ban_reason TEXT, - ban_expires TEXT + ban_expires TEXT, + identity_public_key TEXT ); CREATE TABLE IF NOT EXISTS sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/Server/api/clientip_test.go b/Server/api/clientip_test.go index 9126684f..9c4d8702 100644 --- a/Server/api/clientip_test.go +++ b/Server/api/clientip_test.go @@ -1,6 +1,7 @@ package api -// White-box tests for clientIP and isTrustedProxy. +// White-box tests for clientIP and the trusted-proxy CIDR matching +// (parseCIDRList + ipInNets — the W3-3a replacement for isTrustedProxy). // These live in package api (not api_test) so they can reach unexported symbols. import ( @@ -9,88 +10,81 @@ import ( "testing" ) -// ─── isTrustedProxy ─────────────────────────────────────────────────────────── +// ─── parseCIDRList + ipInNets ──────────────────────────────────────────────── -func TestIsTrustedProxy_EmptyList_ReturnsFalse(t *testing.T) { - trusted, err := isTrustedProxy("10.0.0.1", nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if trusted { - t.Error("isTrustedProxy(empty list) = true, want false") +// inCIDRs is the test shorthand for the old isTrustedProxy semantics: does ip +// fall inside any of the (string) CIDRs? +func inCIDRs(ip string, cidrs []string) bool { + return ipInNets(ip, parseCIDRList(cidrs)) +} + +func TestIPInNets_EmptyList_ReturnsFalse(t *testing.T) { + if inCIDRs("10.0.0.1", nil) { + t.Error("ipInNets(empty list) = true, want false") } } -func TestIsTrustedProxy_ExactIPMatch(t *testing.T) { - trusted, err := isTrustedProxy("10.0.0.1", []string{"10.0.0.1/32"}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !trusted { - t.Error("isTrustedProxy exact match = false, want true") +func TestIPInNets_ExactIPMatch(t *testing.T) { + if !inCIDRs("10.0.0.1", []string{"10.0.0.1/32"}) { + t.Error("ipInNets exact match = false, want true") } } -func TestIsTrustedProxy_CIDRMatch(t *testing.T) { - trusted, err := isTrustedProxy("192.168.1.50", []string{"192.168.1.0/24"}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !trusted { - t.Error("isTrustedProxy CIDR match = false, want true") +func TestIPInNets_CIDRMatch(t *testing.T) { + if !inCIDRs("192.168.1.50", []string{"192.168.1.0/24"}) { + t.Error("ipInNets CIDR match = false, want true") } } -func TestIsTrustedProxy_CIDRNoMatch(t *testing.T) { - trusted, err := isTrustedProxy("10.9.9.9", []string{"192.168.1.0/24"}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if trusted { - t.Error("isTrustedProxy CIDR non-match = true, want false") +func TestIPInNets_CIDRNoMatch(t *testing.T) { + if inCIDRs("10.9.9.9", []string{"192.168.1.0/24"}) { + t.Error("ipInNets CIDR non-match = true, want false") } } -func TestIsTrustedProxy_MultipleCIDRs_FirstMatches(t *testing.T) { - trusted, err := isTrustedProxy("10.0.0.5", []string{"172.16.0.0/12", "10.0.0.0/8"}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !trusted { - t.Error("isTrustedProxy multi-CIDR first match = false, want true") +func TestIPInNets_MultipleCIDRs_FirstMatches(t *testing.T) { + if !inCIDRs("10.0.0.5", []string{"172.16.0.0/12", "10.0.0.0/8"}) { + t.Error("ipInNets multi-CIDR first match = false, want true") } } -func TestIsTrustedProxy_MultipleCIDRs_NoneMatch(t *testing.T) { - trusted, err := isTrustedProxy("8.8.8.8", []string{"10.0.0.0/8", "192.168.0.0/16"}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if trusted { - t.Error("isTrustedProxy multi-CIDR no match = true, want false") +func TestIPInNets_MultipleCIDRs_NoneMatch(t *testing.T) { + if inCIDRs("8.8.8.8", []string{"10.0.0.0/8", "192.168.0.0/16"}) { + t.Error("ipInNets multi-CIDR no match = true, want false") } } -func TestIsTrustedProxy_InvalidCIDR_ReturnsError(t *testing.T) { - _, err := isTrustedProxy("10.0.0.1", []string{"not-a-cidr"}) - if err == nil { - t.Error("isTrustedProxy invalid CIDR should return error, got nil") +func TestParseCIDRList_InvalidCIDR_Skipped(t *testing.T) { + // Invalid entries are skipped (with a startup warning) — they never match, + // so a fully invalid list grants nothing (fail closed at the call sites). + if nets := parseCIDRList([]string{"not-a-cidr"}); len(nets) != 0 { + t.Errorf("parseCIDRList(invalid) = %d nets, want 0", len(nets)) + } + if inCIDRs("10.0.0.1", []string{"not-a-cidr"}) { + t.Error("invalid CIDR matched an IP, want no match") } } -func TestIsTrustedProxy_BarePlainIP_TreatedAsCIDR32(t *testing.T) { - // Bare IP without mask — should not panic; behaviour is to return error or - // treat as /32 depending on implementation. We just verify it doesn't panic. - _, _ = isTrustedProxy("10.0.0.1", []string{"10.0.0.1"}) +func TestParseCIDRList_BarePlainIP_SkippedNotPanic(t *testing.T) { + // Bare IP without mask is not valid CIDR notation — skipped, no panic. + if nets := parseCIDRList([]string{"10.0.0.1"}); len(nets) != 0 { + t.Errorf("parseCIDRList(bare IP) = %d nets, want 0", len(nets)) + } } -func TestIsTrustedProxy_IPv6Match(t *testing.T) { - trusted, err := isTrustedProxy("::1", []string{"::1/128"}) - if err != nil { - t.Fatalf("unexpected error: %v", err) +func TestParseCIDRList_MixedValidInvalid_KeepsValid(t *testing.T) { + nets := parseCIDRList([]string{"not-a-cidr", "10.0.0.0/8"}) + if len(nets) != 1 { + t.Fatalf("parseCIDRList(mixed) = %d nets, want 1", len(nets)) } - if !trusted { - t.Error("isTrustedProxy IPv6 exact match = false, want true") + if !ipInNets("10.1.2.3", nets) { + t.Error("valid entry from mixed list did not match") + } +} + +func TestIPInNets_IPv6Match(t *testing.T) { + if !inCIDRs("::1", []string{"::1/128"}) { + t.Error("ipInNets IPv6 exact match = false, want true") } } @@ -113,7 +107,7 @@ func TestClientIP_TrustedProxy_UsesXRealIP(t *testing.T) { req.RemoteAddr = "10.0.0.1:9999" req.Header.Set("X-Real-IP", "203.0.113.42") - ip := clientIPWithProxies(req, []string{"10.0.0.0/8"}) + ip := clientIPWithProxies(req, parseCIDRList([]string{"10.0.0.0/8"})) if ip != "203.0.113.42" { t.Errorf("clientIP trusted proxy = %q, want %q", ip, "203.0.113.42") } @@ -124,7 +118,7 @@ func TestClientIP_TrustedProxy_NoXRealIP_FallsBackToRemoteAddr(t *testing.T) { req.RemoteAddr = "10.0.0.1:9999" // No X-Real-IP header set. - ip := clientIPWithProxies(req, []string{"10.0.0.0/8"}) + ip := clientIPWithProxies(req, parseCIDRList([]string{"10.0.0.0/8"})) if ip != "10.0.0.1" { t.Errorf("clientIP trusted proxy no header = %q, want %q", ip, "10.0.0.1") } @@ -135,7 +129,7 @@ func TestClientIP_UntrustedSource_IgnoresXRealIP(t *testing.T) { req.RemoteAddr = "8.8.8.8:12345" req.Header.Set("X-Real-IP", "192.168.1.1") // attacker-supplied - ip := clientIPWithProxies(req, []string{"10.0.0.0/8"}) + ip := clientIPWithProxies(req, parseCIDRList([]string{"10.0.0.0/8"})) // Must use RemoteAddr, not the forged X-Real-IP. if ip != "8.8.8.8" { t.Errorf("clientIP untrusted source = %q, want %q", ip, "8.8.8.8") @@ -148,7 +142,7 @@ func TestClientIP_XForwardedFor_UsedWhenNoXRealIP(t *testing.T) { req.Header.Set("X-Forwarded-For", "203.0.113.10, 10.0.0.1") // No X-Real-IP; X-Forwarded-For first entry should be used. - ip := clientIPWithProxies(req, []string{"10.0.0.0/8"}) + ip := clientIPWithProxies(req, parseCIDRList([]string{"10.0.0.0/8"})) if ip != "203.0.113.10" { t.Errorf("clientIP X-Forwarded-For = %q, want %q", ip, "203.0.113.10") } @@ -160,7 +154,7 @@ func TestClientIP_XForwardedFor_UsedWhenNoXRealIP(t *testing.T) { // every client into the proxy's own bucket (one user's failed logins would // lock out everyone). The leftmost valid XFF entry keeps clients distinct. func TestClientIP_BroadTrustedCIDRKeepsClientsDistinct(t *testing.T) { - trusted := []string{"10.0.0.0/8"} // covers proxy AND LAN clients + trusted := parseCIDRList([]string{"10.0.0.0/8"}) // covers proxy AND LAN clients newReq := func(xff string) *http.Request { req := httptest.NewRequest("GET", "/", nil) @@ -190,7 +184,7 @@ func TestClientIP_SpoofedXFFFromUntrustedRemoteIgnored(t *testing.T) { req.RemoteAddr = "203.0.113.9:1234" req.Header.Set("X-Forwarded-For", "10.5.1.7") - ip := clientIPWithProxies(req, []string{"10.0.0.0/8"}) + ip := clientIPWithProxies(req, parseCIDRList([]string{"10.0.0.0/8"})) if ip != "203.0.113.9" { t.Fatalf("spoofed XFF from untrusted remote honoured: got %q", ip) } diff --git a/Server/api/dm_handler_test.go b/Server/api/dm_handler_test.go index a1a8c443..88ab61d7 100644 --- a/Server/api/dm_handler_test.go +++ b/Server/api/dm_handler_test.go @@ -48,7 +48,8 @@ CREATE TABLE IF NOT EXISTS users ( last_seen TEXT, banned INTEGER NOT NULL DEFAULT 0, ban_reason TEXT, - ban_expires TEXT + ban_expires TEXT, + identity_public_key TEXT ); CREATE TABLE IF NOT EXISTS sessions ( diff --git a/Server/api/middleware.go b/Server/api/middleware.go index 7b92f023..3ac03b73 100644 --- a/Server/api/middleware.go +++ b/Server/api/middleware.go @@ -164,9 +164,10 @@ func rateLimitMiddlewareWithPrefix(limiter *auth.RateLimiter, prefix string, lim if len(trustedProxies) > 0 { proxies = trustedProxies[0] } + proxyNets := parseCIDRList(proxies) // W3-3a: parse once at construction return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ip := clientIPWithProxies(r, proxies) + ip := clientIPWithProxies(r, proxyNets) key := prefix + ip if !limiter.Allow(key, limit, window) { @@ -198,30 +199,25 @@ func clientIP(r *http.Request) string { // Security model: // - Always parse the actual connecting address from r.RemoteAddr. // - Only honour X-Real-IP or X-Forwarded-For if the connecting address matches -// one of the trustedCIDRs. This prevents clients from forging their IP to +// one of the trustedNets. This prevents clients from forging their IP to // bypass rate limits. -// - If trustedCIDRs is empty (the default), RemoteAddr is always used. +// - If trustedNets is empty (the default), RemoteAddr is always used. // -// Invalid CIDR entries in trustedCIDRs are silently skipped so that a -// misconfigured entry cannot crash the server; the connecting IP is used as the -// fallback. -func clientIPWithProxies(r *http.Request, trustedCIDRs []string) string { +// trustedNets is the pre-parsed trusted-proxy list — parse the configured CIDR +// strings ONCE at middleware/handler construction with parseCIDRList (W3-3a); +// never parse on the request path. +func clientIPWithProxies(r *http.Request, trustedNets []*net.IPNet) string { remoteHost, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { // RemoteAddr without port (e.g. Unix socket or test stub) — use as-is. remoteHost = r.RemoteAddr } - if len(trustedCIDRs) == 0 { + if len(trustedNets) == 0 { return remoteHost } - // Parse the CIDR list once per request instead of once per XFF candidate. - // ponytail: parse at middleware construction if this ever shows in a - // profile — it would mean threading a parsed type through every caller. - nets := parseCIDRList(trustedCIDRs) - - if !ipInNets(remoteHost, nets) { + if !ipInNets(remoteHost, trustedNets) { return remoteHost } @@ -248,7 +244,7 @@ func clientIPWithProxies(r *http.Request, trustedCIDRs []string) string { continue } leftmostValid = candidate - if ipInNets(candidate, nets) { + if ipInNets(candidate, trustedNets) { continue // our own proxy hop, keep walking left } return candidate @@ -269,15 +265,20 @@ func clientIPWithProxies(r *http.Request, trustedCIDRs []string) string { return remoteHost } -// parseCIDRList parses CIDR strings, silently skipping invalid entries — a -// misconfigured entry must not crash request handling (config load warns -// about them at startup). +// parseCIDRList parses CIDR strings into networks, skipping invalid entries +// with a warning — a misconfigured entry must not take the server down. It is +// called once per middleware/handler at construction (startup), never on the +// request path (W3-3a). func parseCIDRList(cidrs []string) []*net.IPNet { nets := make([]*net.IPNet, 0, len(cidrs)) for _, c := range cidrs { - if _, n, err := net.ParseCIDR(c); err == nil { - nets = append(nets, n) + _, n, err := net.ParseCIDR(c) + if err != nil { + slog.Warn("ignoring invalid CIDR entry (use address/prefix notation, e.g. 10.0.0.1/32)", + "cidr", c, "error", err) + continue } + nets = append(nets, n) } return nets } @@ -297,26 +298,6 @@ func ipInNets(ipStr string, nets []*net.IPNet) bool { return false } -// isTrustedProxy reports whether remoteIP (a plain IP string, no port) falls -// within any of the provided CIDR ranges. It returns an error if any CIDR is -// malformed. -func isTrustedProxy(remoteIP string, cidrList []string) (bool, error) { - ip := net.ParseIP(remoteIP) - if ip == nil { - return false, nil - } - for _, cidr := range cidrList { - _, network, err := net.ParseCIDR(cidr) - if err != nil { - return false, fmt.Errorf("isTrustedProxy: invalid CIDR %q: %w", cidr, err) - } - if network.Contains(ip) { - return true, nil - } - } - return false, nil -} - // AdminIPRestrict returns middleware that blocks requests from IPs not in the // allowed CIDR list. Returns 403 Forbidden for disallowed IPs. If the CIDR // list is empty, all requests are allowed (no restriction). @@ -324,17 +305,24 @@ func isTrustedProxy(remoteIP string, cidrList []string) (bool, error) { // trustedProxyCIDRs specifies which connecting IPs are trusted reverse proxies. // When the connecting IP matches a trusted proxy, the real client IP is read // from X-Real-IP or X-Forwarded-For headers (BUG-116). +// +// Both lists are parsed once at construction (W3-3a); invalid entries are +// skipped with a warning. A non-empty allowedCIDRs list whose entries are all +// invalid yields zero networks — nothing matches, so access is denied (fail +// closed), same as before the hoist. func AdminIPRestrict(allowedCIDRs, trustedProxyCIDRs []string) func(http.Handler) http.Handler { + allowedNets := parseCIDRList(allowedCIDRs) + proxyNets := parseCIDRList(trustedProxyCIDRs) + restrict := len(allowedCIDRs) > 0 return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if len(allowedCIDRs) == 0 { + if !restrict { next.ServeHTTP(w, r) return } - ip := clientIPWithProxies(r, trustedProxyCIDRs) - allowed, _ := isTrustedProxy(ip, allowedCIDRs) - if !allowed { + ip := clientIPWithProxies(r, proxyNets) + if !ipInNets(ip, allowedNets) { writeJSON(w, http.StatusForbidden, errorResponse{ Error: "FORBIDDEN", Message: "access denied", diff --git a/Server/api/middleware_test.go b/Server/api/middleware_test.go index 7f2dfc3c..8287c6a2 100644 --- a/Server/api/middleware_test.go +++ b/Server/api/middleware_test.go @@ -1,12 +1,15 @@ package api_test import ( + "bytes" "context" "encoding/json" "fmt" + "log/slog" "net/http" "net/http/httptest" "strings" + "sync" "testing" "testing/fstest" "time" @@ -376,6 +379,62 @@ func TestRateLimitMiddleware_XRealIPIgnoredWithoutTrustedProxy(t *testing.T) { } } +// lockedBuffer is a goroutine-safe writer for capturing log output. +type lockedBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *lockedBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *lockedBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +// TestRateLimitMiddleware_InvalidCIDRWarnsAtConstructionNotPerRequest locks +// the W3-3a hoist: the trusted-proxy CIDR list is parsed once when the +// middleware is constructed — warning about invalid entries there — never on +// the per-request path. +func TestRateLimitMiddleware_InvalidCIDRWarnsAtConstructionNotPerRequest(t *testing.T) { + logBuf := &lockedBuffer{} + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(logBuf, nil))) + defer slog.SetDefault(prev) + + limiter := auth.NewRateLimiter() + h := api.RateLimitMiddleware(limiter, 100, time.Minute, + []string{"not-a-cidr", "10.0.0.0/8"})(http.HandlerFunc(ok)) + + const warnMsg = "ignoring invalid CIDR entry" + if got := strings.Count(logBuf.String(), warnMsg); got != 1 { + t.Fatalf("invalid-CIDR warnings at construction = %d, want 1 (log: %q)", + got, logBuf.String()) + } + + // The valid entry still works: X-Real-IP honoured from the trusted proxy. + for range 3 { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "10.0.0.1:9999" + req.Header.Set("X-Real-IP", "203.0.113.77") + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("request status = %d, want 200", rr.Code) + } + } + + if got := strings.Count(logBuf.String(), warnMsg); got != 1 { + t.Fatalf("invalid-CIDR warnings after 3 requests = %d, want 1 — CIDRs re-parsed on the request path (log: %q)", + got, logBuf.String()) + } +} + func TestRateLimitMiddleware_XRealIPHonouredFromTrustedProxy(t *testing.T) { // With a trusted proxy configured, X-Real-IP from that proxy is used. limiter := auth.NewRateLimiter() @@ -690,8 +749,9 @@ func TestAdminIPRestrict_EmptyAllowsAll(t *testing.T) { } func TestAdminIPRestrict_InvalidCIDR(t *testing.T) { - // Invalid CIDR should fail closed (deny access since isTrustedProxy - // returns false on parse error). + // Invalid CIDR should fail closed: the entry is skipped at construction, + // leaving a non-empty allowed list with zero parsed networks — nothing + // matches, so access is denied. h := api.AdminIPRestrict([]string{"not-a-cidr"}, nil)(http.HandlerFunc(ok)) req := httptest.NewRequest(http.MethodGet, "/", nil) @@ -954,7 +1014,8 @@ CREATE TABLE IF NOT EXISTS users ( last_seen TEXT, banned INTEGER NOT NULL DEFAULT 0, ban_reason TEXT, - ban_expires TEXT + ban_expires TEXT, + identity_public_key TEXT ); CREATE TABLE IF NOT EXISTS sessions ( diff --git a/Server/api/profile_handler.go b/Server/api/profile_handler.go index 1d4ef805..04d42f1e 100644 --- a/Server/api/profile_handler.go +++ b/Server/api/profile_handler.go @@ -1,6 +1,7 @@ package api import ( + "encoding/base64" "encoding/json" "fmt" "net/http" @@ -17,9 +18,12 @@ import ( // ─── Request / Response types ──────────────────────────────────────────────── // updateProfileRequest is the JSON body for PATCH /api/v1/users/me. +// identity_public_key, when present, publishes the client's long-term E2EE +// identity public key (F3 voice E2EE TOFU); omitted = leave unchanged. type updateProfileRequest struct { - Username string `json:"username"` - Avatar *string `json:"avatar"` + Username string `json:"username"` + Avatar *string `json:"avatar"` + IdentityPublicKey *string `json:"identity_public_key"` } // changePasswordRequest is the JSON body for PUT /api/v1/users/me/password. @@ -48,7 +52,7 @@ type sessionsListResponse struct { // ProfileBroadcaster is the interface the profile handler uses to notify // connected WebSocket clients about profile changes. type ProfileBroadcaster interface { - BroadcastUserUpdate(userID int64, username string, avatar *string) + BroadcastUserUpdate(userID int64, username string, avatar *string, identityPublicKey *string) } // MountProfileRoutes registers user profile management endpoints. @@ -70,6 +74,25 @@ func MountProfileRoutes(r chi.Router, database *db.DB, svc *service.Services, li // ─── Helpers ───────────────────────────────────────────────────────────────── +// validateIdentityKey checks that key is non-empty, at most 128 characters and +// valid standard-alphabet base64 (padded or unpadded) — the same posture as +// the WS voice_e2ee_announce public_key validation. +func validateIdentityKey(key string) error { + if key == "" { + return fmt.Errorf("identity_public_key must not be empty") + } + if len(key) > 128 { + return fmt.Errorf("identity_public_key too large (max 128 characters)") + } + if _, err := base64.StdEncoding.DecodeString(key); err == nil { + return nil + } + if _, err := base64.RawStdEncoding.DecodeString(key); err != nil { + return fmt.Errorf("identity_public_key is not valid base64") + } + return nil +} + // validateAvatarURL checks that avatar is either empty or a valid https:// URL // no longer than maxAvatarURLLen characters. func validateAvatarURL(avatar string) error { @@ -133,15 +156,36 @@ func handleUpdateProfile(svc *service.Services, broadcaster ProfileBroadcaster) req.Avatar = &trimmed } + // Validate the identity key before any write so the request is + // all-or-nothing. + if req.IdentityPublicKey != nil { + trimmed := strings.TrimSpace(*req.IdentityPublicKey) + if err := validateIdentityKey(trimmed); err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "INVALID_INPUT", Message: err.Error(), + }) + return + } + req.IdentityPublicKey = &trimmed + } + updated, err := svc.Users.UpdateProfile(r.Context(), user.ID, req.Username, req.Avatar) if err != nil { writeServiceError(w, err) return } + if req.IdentityPublicKey != nil { + updated, err = svc.Users.UpdateIdentityKey(r.Context(), user.ID, *req.IdentityPublicKey) + if err != nil { + writeServiceError(w, err) + return + } + } + // Broadcast profile change to all connected WebSocket clients. if broadcaster != nil { - broadcaster.BroadcastUserUpdate(updated.ID, updated.Username, updated.Avatar) + broadcaster.BroadcastUserUpdate(updated.ID, updated.Username, updated.Avatar, updated.IdentityPublicKey) } writeJSON(w, http.StatusOK, toUserResponse(updated)) diff --git a/Server/api/profile_handler_test.go b/Server/api/profile_handler_test.go index f41642c4..af6ec30f 100644 --- a/Server/api/profile_handler_test.go +++ b/Server/api/profile_handler_test.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -372,3 +373,85 @@ func TestRevokeSession_CurrentSession(t *testing.T) { t.Errorf("status = %d, want 204; body = %s", rr.Code, rr.Body.String()) } } + +// ─── PATCH /api/v1/users/me — identity_public_key (F3 voice E2EE TOFU) ─────── + +func TestUpdateProfile_PublishIdentityKey(t *testing.T) { + database := newAuthTestDB(t) + router := buildProfileRouter(database) + token := profileCreateToken(t, database, "idkeyuser", 4) + + key := "BPZ8bfkPz8B64iDeNtItYkEy0123456789abcdef+/==" + rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]string{ + "username": "idkeyuser", + "identity_public_key": key, + }) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", rr.Code, rr.Body.String()) + } + + u, err := database.GetUserByUsername(context.Background(), "idkeyuser") + if err != nil || u == nil { + t.Fatalf("GetUserByUsername: %v", err) + } + if u.IdentityPublicKey == nil || *u.IdentityPublicKey != key { + t.Errorf("IdentityPublicKey = %v, want %q", u.IdentityPublicKey, key) + } +} + +func TestUpdateProfile_IdentityKeyOmitted_Unchanged(t *testing.T) { + database := newAuthTestDB(t) + router := buildProfileRouter(database) + token := profileCreateToken(t, database, "idkeykeep", 4) + + key := "a2VlcHRoaXNrZXk=" + u, err := database.GetUserByUsername(context.Background(), "idkeykeep") + if err != nil || u == nil { + t.Fatalf("GetUserByUsername: %v", err) + } + if err := database.UpdateUserIdentityKey(context.Background(), u.ID, &key); err != nil { + t.Fatalf("UpdateUserIdentityKey: %v", err) + } + + // PATCH without identity_public_key must not clear the stored key. + rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]string{ + "username": "idkeykeep", + }) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", rr.Code, rr.Body.String()) + } + after, err := database.GetUserByID(context.Background(), u.ID) + if err != nil || after == nil { + t.Fatalf("GetUserByID: %v", err) + } + if after.IdentityPublicKey == nil || *after.IdentityPublicKey != key { + t.Errorf("IdentityPublicKey = %v, want %q (unchanged)", after.IdentityPublicKey, key) + } +} + +func TestUpdateProfile_IdentityKeyInvalid(t *testing.T) { + database := newAuthTestDB(t) + router := buildProfileRouter(database) + + cases := []struct { + name string + key string + }{ + {"not base64", "!!!not-base64!!!"}, + {"url-safe alphabet", "abc-_def"}, + {"too large", strings.Repeat("A", 132)}, + {"empty", ""}, + } + for i, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + token := profileCreateToken(t, database, fmt.Sprintf("idkeybad%d", i), 4) + rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]string{ + "username": fmt.Sprintf("idkeybad%d", i), + "identity_public_key": tc.key, + }) + if rr.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400; body = %s", rr.Code, rr.Body.String()) + } + }) + } +} diff --git a/Server/api/upload_handler_test.go b/Server/api/upload_handler_test.go index 39bc6131..74587cd7 100644 --- a/Server/api/upload_handler_test.go +++ b/Server/api/upload_handler_test.go @@ -63,7 +63,8 @@ CREATE TABLE IF NOT EXISTS users ( last_seen TEXT, banned INTEGER NOT NULL DEFAULT 0, ban_reason TEXT, - ban_expires TEXT + ban_expires TEXT, + identity_public_key TEXT ); CREATE TABLE IF NOT EXISTS sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/Server/db/auth_queries.go b/Server/db/auth_queries.go index 9062faf4..a44c0117 100644 --- a/Server/db/auth_queries.go +++ b/Server/db/auth_queries.go @@ -166,6 +166,19 @@ func (d *DB) UpdateUserTOTPSecret(ctx context.Context, id int64, secret *string) return nil } +// UpdateUserIdentityKey sets or clears the E2EE identity public key for a user +// (F3 voice E2EE TOFU). Last write wins; key changes are audited at the +// service layer so peers can detect a rotation. +func (d *DB) UpdateUserIdentityKey(ctx context.Context, id int64, key *string) error { + if err := d.q.UpdateUserIdentityKey(ctx, dbgen.UpdateUserIdentityKeyParams{ + IdentityPublicKey: key, + ID: id, + }); err != nil { + return fmt.Errorf("UpdateUserIdentityKey: %w", err) + } + return nil +} + // ResetAllUserStatuses sets all users to "offline". Called on server startup // to clear stale statuses from a previous run or crash. func (d *DB) ResetAllUserStatuses(ctx context.Context) error { @@ -421,6 +434,10 @@ type MemberSummary struct { Avatar *string `json:"avatar"` Status string `json:"status"` Role string `json:"role"` + // IdentityPublicKey is the user's long-term E2EE identity public key + // (base64), pinned by peers on first sight (F3 TOFU). Omitted when the + // user has not published one. + IdentityPublicKey *string `json:"identity_public_key,omitempty"` } // ListMembers returns non-banned users as lightweight summaries. @@ -433,11 +450,12 @@ func (d *DB) ListMembers(ctx context.Context) ([]MemberSummary, error) { members := make([]MemberSummary, 0, len(rows)) for _, r := range rows { members = append(members, MemberSummary{ - ID: r.ID, - Username: r.Username, - Avatar: r.Avatar, - Status: r.Status, - Role: r.Lower, + ID: r.ID, + Username: r.Username, + Avatar: r.Avatar, + Status: r.Status, + Role: r.Lower, + IdentityPublicKey: r.IdentityPublicKey, }) } return members, nil diff --git a/Server/db/auth_queries_test.go b/Server/db/auth_queries_test.go index 44f20ce7..423f555c 100644 --- a/Server/db/auth_queries_test.go +++ b/Server/db/auth_queries_test.go @@ -59,7 +59,8 @@ CREATE TABLE IF NOT EXISTS users ( last_seen TEXT, banned INTEGER NOT NULL DEFAULT 0, ban_reason TEXT, - ban_expires TEXT + ban_expires TEXT, + identity_public_key TEXT ); CREATE TABLE IF NOT EXISTS sessions ( @@ -729,3 +730,86 @@ func TestListMembers_SortedByUsername(t *testing.T) { t.Errorf("last member = %q, want 'zeta_user' (sorted)", members[2].Username) } } + +// ─── Identity key (F3 voice E2EE TOFU) ─────────────────────────────────────── + +func TestUpdateUserIdentityKey_RoundTrip(t *testing.T) { + database := newTestDB(t) + id, err := database.CreateUser(context.Background(), "idkey_user", "hash", 4) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + + key := "BAsE64iDeNtItYkEy+/==" + if err := database.UpdateUserIdentityKey(context.Background(), id, &key); err != nil { + t.Fatalf("UpdateUserIdentityKey: %v", err) + } + + u, err := database.GetUserByID(context.Background(), id) + if err != nil { + t.Fatalf("GetUserByID: %v", err) + } + if u.IdentityPublicKey == nil || *u.IdentityPublicKey != key { + t.Errorf("IdentityPublicKey = %v, want %q", u.IdentityPublicKey, key) + } +} + +func TestUpdateUserIdentityKey_LastWriteWins(t *testing.T) { + database := newTestDB(t) + id, err := database.CreateUser(context.Background(), "idkey_rotate", "hash", 4) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + + first := "Zmlyc3RrZXk=" + second := "c2Vjb25ka2V5" + if err := database.UpdateUserIdentityKey(context.Background(), id, &first); err != nil { + t.Fatalf("UpdateUserIdentityKey(first): %v", err) + } + if err := database.UpdateUserIdentityKey(context.Background(), id, &second); err != nil { + t.Fatalf("UpdateUserIdentityKey(second): %v", err) + } + + u, err := database.GetUserByID(context.Background(), id) + if err != nil { + t.Fatalf("GetUserByID: %v", err) + } + if u.IdentityPublicKey == nil || *u.IdentityPublicKey != second { + t.Errorf("IdentityPublicKey = %v, want %q (last write wins)", u.IdentityPublicKey, second) + } +} + +func TestListMembers_IncludesIdentityKey(t *testing.T) { + database := newTestDB(t) + id, err := database.CreateUser(context.Background(), "idkey_member", "hash", 4) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + key := "bWVtYmVya2V5" + if err := database.UpdateUserIdentityKey(context.Background(), id, &key); err != nil { + t.Fatalf("UpdateUserIdentityKey: %v", err) + } + // A user who never published a key must come back with a nil key. + if _, err := database.CreateUser(context.Background(), "idkey_none", "hash", 4); err != nil { + t.Fatalf("CreateUser(none): %v", err) + } + + members, err := database.ListMembers(context.Background()) + if err != nil { + t.Fatalf("ListMembers: %v", err) + } + if len(members) != 2 { + t.Fatalf("ListMembers() = %d, want 2", len(members)) + } + byName := map[string]db.MemberSummary{} + for _, m := range members { + byName[m.Username] = m + } + got := byName["idkey_member"].IdentityPublicKey + if got == nil || *got != key { + t.Errorf("idkey_member IdentityPublicKey = %v, want %q", got, key) + } + if byName["idkey_none"].IdentityPublicKey != nil { + t.Errorf("idkey_none IdentityPublicKey = %v, want nil", *byName["idkey_none"].IdentityPublicKey) + } +} diff --git a/Server/db/dbgen/models.go b/Server/db/dbgen/models.go index 0d3135ab..667ff0f2 100644 --- a/Server/db/dbgen/models.go +++ b/Server/db/dbgen/models.go @@ -187,18 +187,19 @@ type Sound struct { } type User struct { - ID int64 `json:"id"` - Username string `json:"username"` - Password string `json:"password"` - Avatar *string `json:"avatar"` - RoleID int64 `json:"roleId"` - TotpSecret *string `json:"totpSecret"` - Status string `json:"status"` - CreatedAt string `json:"createdAt"` - LastSeen *string `json:"lastSeen"` - Banned int64 `json:"banned"` - BanReason *string `json:"banReason"` - BanExpires *string `json:"banExpires"` + ID int64 `json:"id"` + Username string `json:"username"` + Password string `json:"password"` + Avatar *string `json:"avatar"` + RoleID int64 `json:"roleId"` + TotpSecret *string `json:"totpSecret"` + Status string `json:"status"` + CreatedAt string `json:"createdAt"` + LastSeen *string `json:"lastSeen"` + Banned int64 `json:"banned"` + BanReason *string `json:"banReason"` + BanExpires *string `json:"banExpires"` + IdentityPublicKey *string `json:"identityPublicKey"` } type UserBlock struct { diff --git a/Server/db/dbgen/querier.go b/Server/db/dbgen/querier.go index 07be9890..b5440b72 100644 --- a/Server/db/dbgen/querier.go +++ b/Server/db/dbgen/querier.go @@ -113,6 +113,7 @@ type Querier interface { UninstallPlugin(ctx context.Context, id int64) error UpdateChannel(ctx context.Context, arg UpdateChannelParams) error UpdateReadState(ctx context.Context, arg UpdateReadStateParams) error + UpdateUserIdentityKey(ctx context.Context, arg UpdateUserIdentityKeyParams) error UpdateUserPassword(ctx context.Context, arg UpdateUserPasswordParams) error UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (sql.Result, error) UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) error diff --git a/Server/db/dbgen/users.sql.go b/Server/db/dbgen/users.sql.go index e3eea621..a54c95ce 100644 --- a/Server/db/dbgen/users.sql.go +++ b/Server/db/dbgen/users.sql.go @@ -63,7 +63,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (sql.Res const getUserByID = `-- name: GetUserByID :one SELECT id, username, password, avatar, role_id, totp_secret, status, - created_at, last_seen, banned, ban_reason, ban_expires + created_at, last_seen, banned, ban_reason, ban_expires, identity_public_key FROM users WHERE id = ? ` @@ -83,13 +83,14 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { &i.Banned, &i.BanReason, &i.BanExpires, + &i.IdentityPublicKey, ) return i, err } const getUserByUsername = `-- name: GetUserByUsername :one SELECT id, username, password, avatar, role_id, totp_secret, status, - created_at, last_seen, banned, ban_reason, ban_expires + created_at, last_seen, banned, ban_reason, ban_expires, identity_public_key FROM users WHERE username = ? COLLATE NOCASE ` @@ -109,12 +110,13 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, &i.Banned, &i.BanReason, &i.BanExpires, + &i.IdentityPublicKey, ) return i, err } const listMembers = `-- name: ListMembers :many -SELECT u.id, u.username, u.avatar, u.status, LOWER(r.name) +SELECT u.id, u.username, u.avatar, u.status, LOWER(r.name), u.identity_public_key FROM users u JOIN roles r ON u.role_id = r.id WHERE u.banned = 0 @@ -123,11 +125,12 @@ LIMIT 1000 ` type ListMembersRow struct { - ID int64 `json:"id"` - Username string `json:"username"` - Avatar *string `json:"avatar"` - Status string `json:"status"` - Lower string `json:"lower"` + ID int64 `json:"id"` + Username string `json:"username"` + Avatar *string `json:"avatar"` + Status string `json:"status"` + Lower string `json:"lower"` + IdentityPublicKey *string `json:"identityPublicKey"` } func (q *Queries) ListMembers(ctx context.Context) ([]ListMembersRow, error) { @@ -145,6 +148,7 @@ func (q *Queries) ListMembers(ctx context.Context) ([]ListMembersRow, error) { &i.Avatar, &i.Status, &i.Lower, + &i.IdentityPublicKey, ); err != nil { return nil, err } @@ -177,6 +181,20 @@ func (q *Queries) UnbanUser(ctx context.Context, id int64) error { return err } +const updateUserIdentityKey = `-- name: UpdateUserIdentityKey :exec +UPDATE users SET identity_public_key = ? WHERE id = ? +` + +type UpdateUserIdentityKeyParams struct { + IdentityPublicKey *string `json:"identityPublicKey"` + ID int64 `json:"id"` +} + +func (q *Queries) UpdateUserIdentityKey(ctx context.Context, arg UpdateUserIdentityKeyParams) error { + _, err := q.db.ExecContext(ctx, updateUserIdentityKey, arg.IdentityPublicKey, arg.ID) + return err +} + const updateUserStatus = `-- name: UpdateUserStatus :exec UPDATE users SET status = ?, last_seen = datetime('now') WHERE id = ? ` diff --git a/Server/db/mappers.go b/Server/db/mappers.go index cff9329a..84e7d002 100644 --- a/Server/db/mappers.go +++ b/Server/db/mappers.go @@ -59,18 +59,19 @@ func strToNullPtr(s string) *string { // userFromGen maps a generated user row to the domain User model. func userFromGen(u dbgen.User) *User { return &User{ - ID: u.ID, - Username: u.Username, - PasswordHash: u.Password, - Avatar: u.Avatar, - RoleID: u.RoleID, - TOTPSecret: u.TotpSecret, - Status: u.Status, - CreatedAt: u.CreatedAt, - LastSeen: u.LastSeen, - Banned: u.Banned != 0, - BanReason: u.BanReason, - BanExpires: u.BanExpires, + ID: u.ID, + Username: u.Username, + PasswordHash: u.Password, + Avatar: u.Avatar, + RoleID: u.RoleID, + TOTPSecret: u.TotpSecret, + Status: u.Status, + CreatedAt: u.CreatedAt, + LastSeen: u.LastSeen, + Banned: u.Banned != 0, + BanReason: u.BanReason, + BanExpires: u.BanExpires, + IdentityPublicKey: u.IdentityPublicKey, } } diff --git a/Server/db/models.go b/Server/db/models.go index 6e7c9807..ebf767bf 100644 --- a/Server/db/models.go +++ b/Server/db/models.go @@ -16,6 +16,10 @@ type User struct { Banned bool BanReason *string BanExpires *string + // IdentityPublicKey is the long-term E2EE identity public key (base64, + // ECDSA P-256) used for TOFU pinning of voice E2EE announces. Nil = not + // published (legacy client). + IdentityPublicKey *string } // Session represents a row in the sessions table. diff --git a/Server/db/queries/sqlite/users.sql b/Server/db/queries/sqlite/users.sql index d70c19e0..4dd800fe 100644 --- a/Server/db/queries/sqlite/users.sql +++ b/Server/db/queries/sqlite/users.sql @@ -1,11 +1,11 @@ -- name: GetUserByUsername :one SELECT id, username, password, avatar, role_id, totp_secret, status, - created_at, last_seen, banned, ban_reason, ban_expires + created_at, last_seen, banned, ban_reason, ban_expires, identity_public_key FROM users WHERE username = ? COLLATE NOCASE; -- name: GetUserByID :one SELECT id, username, password, avatar, role_id, totp_secret, status, - created_at, last_seen, banned, ban_reason, ban_expires + created_at, last_seen, banned, ban_reason, ban_expires, identity_public_key FROM users WHERE id = ?; -- name: CreateUser :execresult @@ -17,6 +17,9 @@ UPDATE users SET status = ?, last_seen = datetime('now') WHERE id = ?; -- name: UpdateUserTOTPSecret :exec UPDATE users SET totp_secret = ? WHERE id = ?; +-- name: UpdateUserIdentityKey :exec +UPDATE users SET identity_public_key = ? WHERE id = ?; + -- name: ResetAllUserStatuses :exec UPDATE users SET status = 'offline' WHERE status != 'offline'; @@ -27,7 +30,7 @@ UPDATE users SET banned = 1, ban_reason = ?, ban_expires = ? WHERE id = ?; UPDATE users SET banned = 0, ban_reason = NULL, ban_expires = NULL WHERE id = ?; -- name: ListMembers :many -SELECT u.id, u.username, u.avatar, u.status, LOWER(r.name) +SELECT u.id, u.username, u.avatar, u.status, LOWER(r.name), u.identity_public_key FROM users u JOIN roles r ON u.role_id = r.id WHERE u.banned = 0 diff --git a/Server/migrations/017_user_identity_key.sql b/Server/migrations/017_user_identity_key.sql new file mode 100644 index 00000000..1db32c58 --- /dev/null +++ b/Server/migrations/017_user_identity_key.sql @@ -0,0 +1,9 @@ +-- Add the long-term E2EE identity public key to users (F3 voice E2EE TOFU). +-- +-- Clients generate an ECDSA P-256 identity keypair on first login, publish the +-- public key here via the profile endpoint, and peers pin it on first sight +-- (trust-on-first-use). The key signs ephemeral voice_e2ee_announce keys so a +-- malicious server cannot swap user_id <-> ephemeral pubkey. Nullable TEXT +-- (base64), mirroring totp_secret: NULL = no key published (legacy client). + +ALTER TABLE users ADD COLUMN identity_public_key TEXT; diff --git a/Server/service/datastore.go b/Server/service/datastore.go index f9458663..f6eb5ca5 100644 --- a/Server/service/datastore.go +++ b/Server/service/datastore.go @@ -59,6 +59,7 @@ type Store interface { UpdateUserPassword(ctx context.Context, userID int64, newPasswordHash string) error UpdateUserStatus(ctx context.Context, id int64, status string) error UpdateUserTOTPSecret(ctx context.Context, id int64, secret *string) error + UpdateUserIdentityKey(ctx context.Context, id int64, key *string) error UpdateUserRole(ctx context.Context, userID, roleID int64) error ResetAllUserStatuses(ctx context.Context) error DeleteAccount(ctx context.Context, userID int64) error diff --git a/Server/service/user.go b/Server/service/user.go index 0c51c646..1f41e616 100644 --- a/Server/service/user.go +++ b/Server/service/user.go @@ -51,6 +51,23 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, username return user, nil } +// UpdateIdentityKey publishes the user's long-term E2EE identity public key +// (F3 voice E2EE TOFU). Last write wins; every write is audited so a key +// rotation — which peers surface as a TOFU mismatch — leaves a trail. +// Returns the updated user for response building. +func (s *UserService) UpdateIdentityKey(ctx context.Context, userID int64, key string) (*db.User, error) { + if err := s.st.UpdateUserIdentityKey(ctx, userID, &key); err != nil { + return nil, fmt.Errorf("%w: failed to update identity key", ErrInternal) + } + user, err := s.st.GetUserByID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("%w: failed to fetch updated user", ErrInternal) + } + db.WriteAudit(context.WithoutCancel(ctx), s.st, userID, "identity_key_update", "user", userID, "") + slog.Info("identity key published", "user_id", userID) + return user, nil +} + // ChangePasswordResult reports a completed password change. RevokeFailed is // set when the password committed but other sessions could not be revoked — // a partial success the caller must surface as a warning, never as a 5xx: diff --git a/Server/updater/updater.go b/Server/updater/updater.go index b958940d..afeb2b58 100644 --- a/Server/updater/updater.go +++ b/Server/updater/updater.go @@ -314,47 +314,53 @@ func (u *Updater) ValidateDownloadURL(url string) error { // executable; on Linux it is a tar.gz archive containing a "chatserver" // binary, which is extracted to destPath. On verification failure the // downloaded file is removed. -func (u *Updater) DownloadAndVerify(ctx context.Context, latestVersion, downloadURL, checksumURL, signatureURL, manifestURL, manifestSignatureURL, destPath string) error { +// +// It returns the hex SHA256 of the staged binary at destPath, derived from +// the signed release manifest (on Linux, computed over the extracted bytes of +// the manifest-verified archive). Callers that later execute the staged file +// must re-verify it against this hash through an open handle +// (OpenVerifiedBinary), never by path. +func (u *Updater) DownloadAndVerify(ctx context.Context, latestVersion, downloadURL, checksumURL, signatureURL, manifestURL, manifestSignatureURL, destPath string) (string, error) { if err := u.ValidateDownloadURL(downloadURL); err != nil { - return err + return "", err } if err := u.ValidateDownloadURL(checksumURL); err != nil { - return fmt.Errorf("validating checksum URL: %w", err) + return "", fmt.Errorf("validating checksum URL: %w", err) } if err := u.ValidateDownloadURL(signatureURL); err != nil { - return fmt.Errorf("validating signature URL: %w", err) + return "", fmt.Errorf("validating signature URL: %w", err) } if err := u.ValidateDownloadURL(manifestURL); err != nil { - return fmt.Errorf("validating manifest URL: %w", err) + return "", fmt.Errorf("validating manifest URL: %w", err) } if err := u.ValidateDownloadURL(manifestSignatureURL); err != nil { - return fmt.Errorf("validating manifest signature URL: %w", err) + return "", fmt.Errorf("validating manifest signature URL: %w", err) } checksumData, err := u.fetchBody(ctx, checksumURL) if err != nil { - return fmt.Errorf("fetching checksums: %w", err) + return "", fmt.Errorf("fetching checksums: %w", err) } signatureData, err := u.fetchBody(ctx, signatureURL) if err != nil { - return fmt.Errorf("fetching signature: %w", err) + return "", fmt.Errorf("fetching signature: %w", err) } manifestData, err := u.fetchBody(ctx, manifestURL) if err != nil { - return fmt.Errorf("fetching release manifest: %w", err) + return "", fmt.Errorf("fetching release manifest: %w", err) } manifestSignatureData, err := u.fetchBody(ctx, manifestSignatureURL) if err != nil { - return fmt.Errorf("fetching release manifest signature: %w", err) + return "", fmt.Errorf("fetching release manifest signature: %w", err) } assetFilename, err := assetFilenameFromURL(downloadURL) if err != nil { - return fmt.Errorf("determining asset filename: %w", err) + return "", fmt.Errorf("determining asset filename: %w", err) } manifest, err := u.VerifyReleaseManifest(manifestData, manifestSignatureData, latestVersion, assetFilename) if err != nil { - return err + return "", err } names := checksumEntryNamesForGOOS(runtime.GOOS) if len(names) == 0 { @@ -362,12 +368,17 @@ func (u *Updater) DownloadAndVerify(ctx context.Context, latestVersion, download } expectedHash, err := u.parseChecksumFileAny(checksumData, names...) if err != nil { - return fmt.Errorf("parsing checksum file: %w", err) + return "", fmt.Errorf("parsing checksum file: %w", err) } if !strings.EqualFold(expectedHash, manifest.SHA256) { - return fmt.Errorf("release manifest checksum mismatch for %s", assetFilename) + return "", fmt.Errorf("release manifest checksum mismatch for %s", assetFilename) } + // Clear a stale staged binary from a previous aborted attempt. Staging is + // O_EXCL, so anything recreated at this path afterwards fails the download + // instead of being written through (TOCTOU). + _ = os.Remove(destPath) + goos := runtime.GOOS switch goos { case "windows": @@ -375,60 +386,80 @@ func (u *Updater) DownloadAndVerify(ctx context.Context, latestVersion, download case "linux": return u.downloadLinuxTarballAndVerify(ctx, downloadURL, destPath, expectedHash) default: - return fmt.Errorf("server auto-update is not supported on %s", goos) + return "", fmt.Errorf("server auto-update is not supported on %s", goos) } } -func (u *Updater) downloadWindowsBinaryAndVerify(ctx context.Context, downloadURL, destPath, expectedHash string, signatureData []byte) error { +func (u *Updater) downloadWindowsBinaryAndVerify(ctx context.Context, downloadURL, destPath, expectedHash string, signatureData []byte) (string, error) { if err := u.downloadFile(ctx, downloadURL, destPath); err != nil { - return fmt.Errorf("downloading binary: %w", err) + return "", fmt.Errorf("downloading binary: %w", err) } if err := u.VerifySignature(destPath, signatureData); err != nil { _ = os.Remove(destPath) - return err + return "", err } // Verify hash. if err := u.VerifyChecksum(destPath, expectedHash); err != nil { // Remove the invalid file. _ = os.Remove(destPath) - return err + return "", err } - return nil + // The asset is the binary itself, so the manifest-bound hash is the + // staged binary's trusted hash. + return expectedHash, nil } -func (u *Updater) downloadLinuxTarballAndVerify(ctx context.Context, downloadURL, destPath, expectedHash string) error { +func (u *Updater) downloadLinuxTarballAndVerify(ctx context.Context, downloadURL, destPath, expectedHash string) (string, error) { tarPath := destPath + ".tar.gz.partial" + _ = os.Remove(tarPath) // clear a stale partial; download stages O_EXCL defer func() { _ = os.Remove(tarPath) }() if err := u.downloadFile(ctx, downloadURL, tarPath); err != nil { - return fmt.Errorf("downloading archive: %w", err) - } - if err := u.VerifyChecksum(tarPath, expectedHash); err != nil { - return err + return "", fmt.Errorf("downloading archive: %w", err) } + // Open the archive once and do both the checksum and the extraction + // through this one handle, so the bytes verified are the bytes extracted + // even if the path is swapped in between (TOCTOU). f, err := os.Open(tarPath) if err != nil { - return fmt.Errorf("opening archive: %w", err) + return "", fmt.Errorf("opening archive: %w", err) } defer f.Close() //nolint:errcheck - if err := extractChatserverFromTarGz(f, destPath); err != nil { + actual, err := readerSHA256(f) + if err != nil { + return "", fmt.Errorf("hashing archive: %w", err) + } + if !strings.EqualFold(actual, expectedHash) { + return "", fmt.Errorf("checksum mismatch: expected %s, got %s", expectedHash, actual) + } + if _, err := f.Seek(0, io.SeekStart); err != nil { + return "", fmt.Errorf("rewinding archive: %w", err) + } + + binaryHash, err := extractChatserverFromTarGz(f, destPath) + if err != nil { _ = os.Remove(destPath) - return fmt.Errorf("extracting archive: %w", err) + return "", fmt.Errorf("extracting archive: %w", err) } if err := os.Chmod(destPath, 0o755); err != nil { //nolint:gosec // G302: binary must be world-executable to run - return fmt.Errorf("chmod binary: %w", err) + return "", fmt.Errorf("chmod binary: %w", err) } - return nil + return binaryHash, nil } -func extractChatserverFromTarGz(r io.Reader, destPath string) error { +// extractChatserverFromTarGz extracts the "chatserver" entry from a tar.gz +// stream to destPath and returns the hex SHA256 of the bytes it wrote, so the +// caller gets a trusted hash of the staged binary without a path re-read. +// destPath is created O_EXCL: a pre-existing file (attacker-planted staging +// path) fails the extraction instead of being written through. +func extractChatserverFromTarGz(r io.Reader, destPath string) (string, error) { gr, err := gzip.NewReader(r) if err != nil { - return fmt.Errorf("gzip: %w", err) + return "", fmt.Errorf("gzip: %w", err) } defer gr.Close() //nolint:errcheck @@ -436,10 +467,10 @@ func extractChatserverFromTarGz(r io.Reader, destPath string) error { for { hdr, err := tr.Next() if err == io.EOF { - return fmt.Errorf("archive contains no file named chatserver") + return "", fmt.Errorf("archive contains no file named chatserver") } if err != nil { - return fmt.Errorf("tar: %w", err) + return "", fmt.Errorf("tar: %w", err) } skipBody := func() error { if _, err := io.Copy(io.Discard, io.LimitReader(tr, hdr.Size)); err != nil { @@ -449,42 +480,43 @@ func extractChatserverFromTarGz(r io.Reader, destPath string) error { } if hdr.Typeflag != tar.TypeReg && hdr.Typeflag != tar.TypeRegA { if err := skipBody(); err != nil { - return err + return "", err } continue } if strings.Contains(hdr.Name, "..") { if err := skipBody(); err != nil { - return err + return "", err } continue } if filepath.Base(hdr.Name) != "chatserver" { if err := skipBody(); err != nil { - return err + return "", err } continue } - out, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + out, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o600) if err != nil { - return err + return "", err } - n, copyErr := io.Copy(out, io.LimitReader(tr, hdr.Size)) + h := sha256.New() + n, copyErr := io.Copy(io.MultiWriter(out, h), io.LimitReader(tr, hdr.Size)) closeErr := out.Close() if copyErr != nil { _ = os.Remove(destPath) - return fmt.Errorf("writing binary: %w", copyErr) + return "", fmt.Errorf("writing binary: %w", copyErr) } if closeErr != nil { _ = os.Remove(destPath) - return closeErr + return "", closeErr } if n != hdr.Size { _ = os.Remove(destPath) - return fmt.Errorf("incomplete tar entry (%d of %d bytes)", n, hdr.Size) + return "", fmt.Errorf("incomplete tar entry (%d of %d bytes)", n, hdr.Size) } - return nil + return hex.EncodeToString(h.Sum(nil)), nil } } @@ -643,27 +675,30 @@ func assetFilenameFromURL(rawURL string) (string, error) { return filename, nil } -// FileSHA256 returns the hex-encoded SHA256 of the file at path. Exported so -// callers that snapshot a verified binary (the admin update TOCTOU re-check) -// share this exact hashing instead of duplicating it. -func FileSHA256(path string) (string, error) { +// readerSHA256 returns the hex-encoded SHA256 of everything read from r. +func readerSHA256(r io.Reader) (string, error) { + h := sha256.New() + if _, err := io.Copy(h, r); err != nil { + return "", fmt.Errorf("computing checksum: %w", err) + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +// fileSHA256 returns the hex-encoded SHA256 of the file at path. +func fileSHA256(path string) (string, error) { f, err := os.Open(path) if err != nil { return "", fmt.Errorf("opening file for checksum: %w", err) } defer f.Close() //nolint:errcheck - h := sha256.New() - if _, err := io.Copy(h, f); err != nil { - return "", fmt.Errorf("computing checksum: %w", err) - } - return hex.EncodeToString(h.Sum(nil)), nil + return readerSHA256(f) } // VerifyChecksum computes the SHA256 hash of the file at filePath and // compares it (case-insensitive) against expectedHash. func (u *Updater) VerifyChecksum(filePath, expectedHash string) error { - actual, err := FileSHA256(filePath) + actual, err := fileSHA256(filePath) if err != nil { return err } @@ -673,6 +708,82 @@ func (u *Updater) VerifyChecksum(filePath, expectedHash string) error { return nil } +// StagedBinary is an open handle to a staged update binary whose contents +// were verified through that same handle. Because the hash check and Commit's +// same-file check use one open file, a swap of the on-disk path between +// verification and rename is detected instead of silently executed (the +// update TOCTOU window, W3-3). +type StagedBinary struct { + f *os.File + closed bool +} + +// OpenVerifiedBinary opens stagedPath exactly once and verifies the SHA256 of +// its contents through that handle against expectedHash (hex, +// case-insensitive). On success the returned StagedBinary keeps the handle +// open for Commit; the caller must Close it. +func OpenVerifiedBinary(stagedPath, expectedHash string) (*StagedBinary, error) { + f, err := os.Open(stagedPath) + if err != nil { + return nil, fmt.Errorf("opening staged binary: %w", err) + } + actual, err := readerSHA256(f) + if err != nil { + _ = f.Close() + return nil, fmt.Errorf("hashing staged binary: %w", err) + } + if !strings.EqualFold(actual, expectedHash) { + _ = f.Close() + return nil, fmt.Errorf("staged binary checksum mismatch: expected %s, got %s", expectedHash, actual) + } + return &StagedBinary{f: f}, nil +} + +// Commit renames the staged file to destPath and confirms the file now at +// destPath is the very file the hash was verified through (os.SameFile +// against the verification handle's identity). If the staged path was swapped +// after verification, the rename moves the impostor, the same-file check +// fails, and Commit returns an error; the caller must then treat destPath as +// unverified and restore or remove it. +func (s *StagedBinary) Commit(destPath string) error { + verified, err := s.f.Stat() + if err != nil { + return fmt.Errorf("stat of verified handle: %w", err) + } + if runtime.GOOS == "windows" { + // Windows cannot rename a file Go holds open (os.Open does not share + // delete) — until here that lock itself blocks swaps of the staged + // path. The stat captured above carries the NTFS file ID, which + // travels with the file across the rename, so the same-file check + // below still detects a swap in the close→rename window. + if err := s.Close(); err != nil { + return fmt.Errorf("closing verified handle: %w", err) + } + } + // On Unix the handle stays open through the rename: a held fd also pins + // the verified inode, so its number cannot be reused by another file. + if err := os.Rename(s.f.Name(), destPath); err != nil { + return fmt.Errorf("renaming staged binary: %w", err) + } + committed, err := os.Lstat(destPath) + if err != nil { + return fmt.Errorf("stat of committed binary: %w", err) + } + if !os.SameFile(verified, committed) { + return fmt.Errorf("staged binary was replaced after verification (refusing to run it)") + } + return nil +} + +// Close releases the verification handle. Safe to call more than once. +func (s *StagedBinary) Close() error { + if s.closed { + return nil + } + s.closed = true + return s.f.Close() +} + // ParseChecksumFile parses a sha256sum-format checksum file (lines of // " ") and returns the hash for the given filename. func (u *Updater) ParseChecksumFile(data []byte, filename string) (string, error) { @@ -879,7 +990,10 @@ func (u *Updater) downloadFile(ctx context.Context, url, destPath string) error return fmt.Errorf("HTTP %d downloading %s", resp.StatusCode, url) } - f, err := os.Create(destPath) + // O_EXCL: staging paths are predictable (exe + ".new"), so refuse to + // write through a pre-created file or symlink (TOCTOU). Callers remove + // stale staged files before downloading. + f, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if err != nil { return fmt.Errorf("creating destination file: %w", err) } diff --git a/Server/updater/updater_test.go b/Server/updater/updater_test.go index b6d3ef9f..f1c5b5b3 100644 --- a/Server/updater/updater_test.go +++ b/Server/updater/updater_test.go @@ -484,9 +484,14 @@ func TestExtractChatserverFromTarGz(t *testing.T) { tmpDir := t.TempDir() dest := filepath.Join(tmpDir, "chatserver") - if err := extractChatserverFromTarGz(bytes.NewReader(gzbuf.Bytes()), dest); err != nil { + gotHash, err := extractChatserverFromTarGz(bytes.NewReader(gzbuf.Bytes()), dest) + if err != nil { t.Fatalf("extractChatserverFromTarGz: %v", err) } + innerSum := sha256.Sum256(inner) + if gotHash != hex.EncodeToString(innerSum[:]) { + t.Errorf("extracted hash = %q, want hash of written bytes", gotHash) + } got, err := os.ReadFile(dest) if err != nil { t.Fatal(err) @@ -703,10 +708,13 @@ func testDownloadAndVerifySuccessWindows(t *testing.T) { Transport: &rewriteTransport{srv.URL}, } - err := u.DownloadAndVerify(context.Background(), "v1.0.0", downloadURL, checksumURL, signatureURL, manifestURL, manifestSignatureURL, dest) + stagedHash, err := u.DownloadAndVerify(context.Background(), "v1.0.0", downloadURL, checksumURL, signatureURL, manifestURL, manifestSignatureURL, dest) if err != nil { t.Fatalf("DownloadAndVerify: %v", err) } + if stagedHash != checksumHex { + t.Errorf("staged hash = %q, want manifest-bound hash %q", stagedHash, checksumHex) + } got, _ := os.ReadFile(dest) if !bytes.Equal(got, content) { @@ -759,10 +767,14 @@ func testDownloadAndVerifySuccessLinux(t *testing.T) { Transport: &rewriteTransport{srv.URL}, } - err := u.DownloadAndVerify(context.Background(), "v1.0.0", downloadURL, checksumURL, signatureURL, manifestURL, manifestSignatureURL, dest) + stagedHash, err := u.DownloadAndVerify(context.Background(), "v1.0.0", downloadURL, checksumURL, signatureURL, manifestURL, manifestSignatureURL, dest) if err != nil { t.Fatalf("DownloadAndVerify: %v", err) } + innerSum := sha256.Sum256(inner) + if stagedHash != hex.EncodeToString(innerSum[:]) { + t.Errorf("staged hash = %q, want hash of extracted binary", stagedHash) + } got, err := os.ReadFile(dest) if err != nil { @@ -801,7 +813,7 @@ func mustBuildChatserverTarGz(t *testing.T, inner []byte) []byte { func TestDownloadAndVerify_InvalidDownloadURL(t *testing.T) { u := NewUpdater("1.0.0", "", "J3vb", "OwnCord") - err := u.DownloadAndVerify(context.Background(), "v1.0.0", "https://evil.com/file", "https://evil.com/sum", "https://evil.com/file.sig", "https://evil.com/manifest.json", "https://evil.com/manifest.json.sig", "/tmp/out") + _, err := u.DownloadAndVerify(context.Background(), "v1.0.0", "https://evil.com/file", "https://evil.com/sum", "https://evil.com/file.sig", "https://evil.com/manifest.json", "https://evil.com/manifest.json.sig", "/tmp/out") if err == nil { t.Error("DownloadAndVerify should reject invalid download URL") } @@ -810,7 +822,7 @@ func TestDownloadAndVerify_InvalidDownloadURL(t *testing.T) { func TestDownloadAndVerify_InvalidChecksumURL(t *testing.T) { u := NewUpdater("1.0.0", "", "J3vb", "OwnCord") downloadURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe" - err := u.DownloadAndVerify(context.Background(), "v1.0.0", downloadURL, "https://evil.com/sum", "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe.sig", "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json", "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json.sig", "/tmp/out") + _, err := u.DownloadAndVerify(context.Background(), "v1.0.0", downloadURL, "https://evil.com/sum", "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe.sig", "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json", "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json.sig", "/tmp/out") if err == nil { t.Error("DownloadAndVerify should reject invalid checksum URL") } @@ -867,7 +879,7 @@ func testDownloadAndVerifyChecksumMismatchWindows(t *testing.T) { manifestURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json" manifestSignatureURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json.sig" - err := u.DownloadAndVerify(context.Background(), "v1.0.0", downloadURL, checksumURL, signatureURL, manifestURL, manifestSignatureURL, dest) + _, err := u.DownloadAndVerify(context.Background(), "v1.0.0", downloadURL, checksumURL, signatureURL, manifestURL, manifestSignatureURL, dest) if err == nil { t.Error("DownloadAndVerify should fail on checksum mismatch") } @@ -917,7 +929,7 @@ func testDownloadAndVerifyChecksumMismatchLinux(t *testing.T) { manifestURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json" manifestSignatureURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json.sig" - err := u.DownloadAndVerify(context.Background(), "v1.0.0", downloadURL, checksumURL, signatureURL, manifestURL, manifestSignatureURL, dest) + _, err := u.DownloadAndVerify(context.Background(), "v1.0.0", downloadURL, checksumURL, signatureURL, manifestURL, manifestSignatureURL, dest) if err == nil { t.Error("DownloadAndVerify should fail on checksum mismatch") } @@ -956,7 +968,7 @@ func TestDownloadAndVerify_MissingSignature(t *testing.T) { u.httpClient = &http.Client{Transport: &rewriteTransport{srv.URL}} - err := u.DownloadAndVerify( + _, err := u.DownloadAndVerify( context.Background(), "v1.0.0", "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe", @@ -1004,7 +1016,7 @@ func TestDownloadAndVerify_InvalidSignature(t *testing.T) { dest := filepath.Join(tmpDir, "chatserver.exe") u.httpClient = &http.Client{Transport: &rewriteTransport{srv.URL}} - err := u.DownloadAndVerify( + _, err := u.DownloadAndVerify( context.Background(), "v1.0.0", "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe", @@ -1053,7 +1065,7 @@ func TestDownloadAndVerify_MalformedSignature(t *testing.T) { dest := filepath.Join(tmpDir, "chatserver.exe") u.httpClient = &http.Client{Transport: &rewriteTransport{srv.URL}} - err := u.DownloadAndVerify( + _, err := u.DownloadAndVerify( context.Background(), "v1.0.0", "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe", @@ -1101,7 +1113,7 @@ func TestDownloadAndVerify_ManifestVersionMismatch(t *testing.T) { dest := filepath.Join(t.TempDir(), "chatserver.exe") u.httpClient = &http.Client{Transport: &rewriteTransport{srv.URL}} - err := u.DownloadAndVerify( + _, err := u.DownloadAndVerify( context.Background(), "v1.0.0", "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe", @@ -1165,3 +1177,126 @@ func TestVerifySignature_TauriBase64WrappedFormat(t *testing.T) { t.Error("wrapped signature must not verify tampered content") } } + +// ─── Staged-binary TOCTOU guard (W3-3) ─────────────────────────────────────── + +// TestOpenVerifiedBinary_CommitHappyPath: verify-through-handle then commit +// moves the exact verified file to the destination. +func TestOpenVerifiedBinary_CommitHappyPath(t *testing.T) { + dir := t.TempDir() + stagedPath := filepath.Join(dir, "app.new") + content := []byte("verified update bytes") + if err := os.WriteFile(stagedPath, content, 0o600); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(content) + + staged, err := OpenVerifiedBinary(stagedPath, hex.EncodeToString(sum[:])) + if err != nil { + t.Fatalf("OpenVerifiedBinary: %v", err) + } + defer staged.Close() //nolint:errcheck + + destPath := filepath.Join(dir, "app") + if err := staged.Commit(destPath); err != nil { + t.Fatalf("Commit: %v", err) + } + got, err := os.ReadFile(destPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, content) { + t.Errorf("committed content mismatch") + } + if _, err := os.Lstat(stagedPath); !os.IsNotExist(err) { + t.Errorf("staged path should be gone after commit") + } +} + +func TestOpenVerifiedBinary_WrongHash(t *testing.T) { + stagedPath := filepath.Join(t.TempDir(), "app.new") + if err := os.WriteFile(stagedPath, []byte("some bytes"), 0o600); err != nil { + t.Fatal(err) + } + wrong := "0000000000000000000000000000000000000000000000000000000000000000" + if _, err := OpenVerifiedBinary(stagedPath, wrong); err == nil { + t.Fatal("OpenVerifiedBinary must reject a hash mismatch") + } +} + +// TestOpenVerifiedBinary_SwapAfterVerifyDetectedAtCommit locks the W3-3 +// invariant: hash verification and the rename commit operate on the same +// file. The old path-based flow (VerifyChecksum(path) then os.Rename(path)) +// silently renamed — and would have spawned — whatever was swapped in at the +// staged path after verification. Now either the swap itself is blocked by +// the held verification handle (Windows) or Commit detects the swapped file +// and refuses (Unix); unverified bytes must never land at the destination. +func TestOpenVerifiedBinary_SwapAfterVerifyDetectedAtCommit(t *testing.T) { + dir := t.TempDir() + stagedPath := filepath.Join(dir, "app.new") + good := []byte("good verified bytes") + if err := os.WriteFile(stagedPath, good, 0o600); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(good) + + staged, err := OpenVerifiedBinary(stagedPath, hex.EncodeToString(sum[:])) + if err != nil { + t.Fatalf("OpenVerifiedBinary: %v", err) + } + defer staged.Close() //nolint:errcheck + + // Attacker tries to win the race: replace the staged path after + // verification but before the rename. + evilPath := filepath.Join(dir, "evil") + if err := os.WriteFile(evilPath, []byte("malicious payload"), 0o600); err != nil { + t.Fatal(err) + } + swapErr := os.Rename(evilPath, stagedPath) + + destPath := filepath.Join(dir, "app") + commitErr := staged.Commit(destPath) + switch { + case swapErr == nil && commitErr == nil: + t.Fatal("staged binary was swapped after verification and Commit did not detect it") + case swapErr != nil && commitErr != nil: + t.Fatalf("swap was blocked (%v) but Commit still failed: %v", swapErr, commitErr) + case commitErr == nil: + // Swap blocked by the held verification handle (Windows): the + // committed file must be the verified bytes. + got, err := os.ReadFile(destPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, good) { + t.Errorf("committed content is not the verified bytes") + } + } +} + +// TestDownloadFile_RefusesPreExistingDest locks the O_EXCL staging invariant: +// the download must refuse to write through a path an attacker pre-created. +func TestDownloadFile_RefusesPreExistingDest(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("downloaded content")) + })) + defer srv.Close() + + dest := filepath.Join(t.TempDir(), "staged.bin") + planted := []byte("attacker planted file") + if err := os.WriteFile(dest, planted, 0o600); err != nil { + t.Fatal(err) + } + + u := newTestUpdater(srv.URL, "1.0.0") + if err := u.downloadFile(context.Background(), srv.URL+"/binary", dest); err == nil { + t.Fatal("downloadFile must refuse a pre-existing staging path") + } + got, err := os.ReadFile(dest) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, planted) { + t.Errorf("pre-existing file must be left untouched") + } +} diff --git a/Server/ws/client.go b/Server/ws/client.go index 9b8a2bae..3aeb5d8d 100644 --- a/Server/ws/client.go +++ b/Server/ws/client.go @@ -32,6 +32,7 @@ type Client struct { voiceChID int64 // voice channel the user is in (0 = not in voice); guarded by voiceMu voiceJoinToken string // opaque join-instance token for the current voice session; guarded by voiceMu e2eePubKey string // ECDH P-256 public key (base64) for voice E2EE; guarded by voiceMu + e2eeSignature string // identity-key signature over e2eePubKey (F3 TOFU); "" for legacy announces; guarded by voiceMu roleName string // cached role name for chat_message broadcasts tokenHash string // SHA-256 hex of the session token; used for periodic revalidation lastSeq uint64 // last_seq sent by the client during auth; 0 = fresh connection (e.g. F5 reload) @@ -139,21 +140,24 @@ func (c *Client) clearVoiceState() (int64, string) { c.voiceChID = 0 c.voiceJoinToken = "" c.e2eePubKey = "" + c.e2eeSignature = "" return oldChID, oldJoinToken } -// setE2EEPubKey stores the ECDH public key for voice E2EE key exchange. -func (c *Client) setE2EEPubKey(key string) { +// setE2EEPubKey stores the ECDH public key for voice E2EE key exchange, +// together with its identity-key signature ("" for legacy announces). +func (c *Client) setE2EEPubKey(key, signature string) { c.voiceMu.Lock() defer c.voiceMu.Unlock() c.e2eePubKey = key + c.e2eeSignature = signature } -// getE2EEPubKey returns the stored ECDH public key. -func (c *Client) getE2EEPubKey() string { +// getE2EEPubKey returns the stored ECDH public key and its signature. +func (c *Client) getE2EEPubKey() (string, string) { c.voiceMu.Lock() defer c.voiceMu.Unlock() - return c.e2eePubKey + return c.e2eePubKey, c.e2eeSignature } // sendMsg queues a normal-priority message (chat messages, reactions, channel events). diff --git a/Server/ws/command.go b/Server/ws/command.go index 24010835..a54e0b77 100644 --- a/Server/ws/command.go +++ b/Server/ws/command.go @@ -198,14 +198,19 @@ func (c VoiceScreenshareCmd) UserID() int64 { return c.userID } func (c VoiceScreenshareCmd) Enabled() bool { return c.enabled } // VoiceE2EEAnnounceCmd represents a voice_e2ee_announce message. +// signature is the ECDSA identity-key signature over the ephemeral public key +// (F3 TOFU); optional at the protocol level — legacy clients omit it and the +// receiving client enforces the fail-closed posture. type VoiceE2EEAnnounceCmd struct { userID int64 publicKey string + signature string } func (c VoiceE2EEAnnounceCmd) Type() string { return MsgTypeVoiceE2EEAnnounce } func (c VoiceE2EEAnnounceCmd) UserID() int64 { return c.userID } func (c VoiceE2EEAnnounceCmd) PublicKey() string { return c.publicKey } +func (c VoiceE2EEAnnounceCmd) Signature() string { return c.signature } // ChatCommandCmd represents a chat_command (plugin slash command) message. type ChatCommandCmd struct { @@ -468,11 +473,12 @@ var commandConstructors = map[string]func(userID int64, reqID string, raw json.R MsgTypeVoiceE2EEAnnounce: func(userID int64, _ string, raw json.RawMessage) (Command, error) { var p struct { PublicKey string `json:"public_key"` + Signature string `json:"signature"` } if err := json.Unmarshal(raw, &p); err != nil { return nil, fmt.Errorf("invalid voice_e2ee_announce payload: %w", err) } - return VoiceE2EEAnnounceCmd{userID: userID, publicKey: p.PublicKey}, nil + return VoiceE2EEAnnounceCmd{userID: userID, publicKey: p.PublicKey, signature: p.Signature}, nil }, MsgTypeChatCommand: func(userID int64, reqID string, raw json.RawMessage) (Command, error) { diff --git a/Server/ws/event.go b/Server/ws/event.go index bbcbc57d..c62bd1a9 100644 --- a/Server/ws/event.go +++ b/Server/ws/event.go @@ -27,6 +27,11 @@ type Result struct { // SetE2EEPubKey, if non-nil, stores the ECDH public key on the client. // Used by voice_e2ee_announce to persist the key for later retrieval. SetE2EEPubKey *string + // SetE2EESignature, if non-nil, stores the identity-key signature over + // the announced ephemeral key (F3 TOFU) alongside SetE2EEPubKey, so the + // late-joiner replay path relays it. Only meaningful when SetE2EEPubKey + // is also set; nil for legacy announces without a signature. + SetE2EESignature *string // SetVoiceJoinToken, if non-nil, caches the voice join token on the client. // Used by voice_token_refresh when falling back to the DB for the token. SetVoiceJoinToken *string diff --git a/Server/ws/export_test.go b/Server/ws/export_test.go index 16ba1aaf..6a36a5ac 100644 --- a/Server/ws/export_test.go +++ b/Server/ws/export_test.go @@ -74,14 +74,15 @@ func SetClientVoiceStateForTest(c *Client, channelID int64, joinToken string) { c.voiceJoinToken = joinToken } -// SetClientE2EEPubKeyForTest sets the E2EE public key on a client. +// SetClientE2EEPubKeyForTest sets the E2EE public key on a client (no signature). func SetClientE2EEPubKeyForTest(c *Client, key string) { - c.setE2EEPubKey(key) + c.setE2EEPubKey(key, "") } // GetClientE2EEPubKeyForTest returns the E2EE public key from a client. func GetClientE2EEPubKeyForTest(c *Client) string { - return c.getE2EEPubKey() + key, _ := c.getE2EEPubKey() + return key } // NewTestClient creates a client with a caller-supplied send channel; conn is nil. diff --git a/Server/ws/handler_v2_voice_e2ee_test.go b/Server/ws/handler_v2_voice_e2ee_test.go index 010e4f9e..73bbc59d 100644 --- a/Server/ws/handler_v2_voice_e2ee_test.go +++ b/Server/ws/handler_v2_voice_e2ee_test.go @@ -133,3 +133,95 @@ func TestVoiceE2EEAnnounceV2_NoReply(t *testing.T) { t.Errorf("expected no reply, got %s", result.Reply) } } + +// ─── voice_e2ee_announce signature (F3 identity keys + TOFU) ──────────────── + +// validB64Sig is a valid base64-encoded 64-byte ECDSA P-256 signature (r||s). +var validB64Sig = base64.StdEncoding.EncodeToString(make([]byte, 64)) + +func TestVoiceE2EEAnnounceV2_SignatureStored(t *testing.T) { + deps := VoiceDeps{} + cmd := VoiceE2EEAnnounceCmd{userID: 1, publicKey: validB64Key, signature: validB64Sig} + info := ClientInfo{UserID: 1, Username: "alice", VoiceChannelID: 100} + + result := handleVoiceE2EEAnnounceV2(context.Background(), cmd, info, deps) + + if result.Error != nil { + t.Fatalf("unexpected error: %v", result.Error) + } + if result.SetE2EEPubKey == nil || *result.SetE2EEPubKey != validB64Key { + t.Fatalf("SetE2EEPubKey = %v, want %q", result.SetE2EEPubKey, validB64Key) + } + if result.SetE2EESignature == nil || *result.SetE2EESignature != validB64Sig { + t.Fatalf("SetE2EESignature = %v, want %q", result.SetE2EESignature, validB64Sig) + } + // The relayed payload must carry the signature. + if len(result.Events) != 1 { + t.Fatalf("expected 1 event, got %d", len(result.Events)) + } + evt := result.Events[0].(VoiceChannelEvent) + if !strings.Contains(string(evt.Payload()), validB64Sig) { + t.Errorf("relay payload missing signature: %s", evt.Payload()) + } +} + +func TestVoiceE2EEAnnounceV2_NoSignature_LegacyAccepted(t *testing.T) { + deps := VoiceDeps{} + cmd := VoiceE2EEAnnounceCmd{userID: 1, publicKey: validB64Key} + info := ClientInfo{UserID: 1, Username: "alice", VoiceChannelID: 100} + + result := handleVoiceE2EEAnnounceV2(context.Background(), cmd, info, deps) + + if result.Error != nil { + t.Fatalf("legacy announce without signature must be accepted, got: %v", result.Error) + } + if result.SetE2EESignature != nil { + t.Errorf("SetE2EESignature = %q, want nil for legacy announce", *result.SetE2EESignature) + } + evt := result.Events[0].(VoiceChannelEvent) + if strings.Contains(string(evt.Payload()), "signature") { + t.Errorf("legacy relay payload must omit signature field: %s", evt.Payload()) + } +} + +func TestVoiceE2EEAnnounceV2_SignatureInvalidBase64(t *testing.T) { + deps := VoiceDeps{} + cmd := VoiceE2EEAnnounceCmd{userID: 1, publicKey: validB64Key, signature: "!!!not-base64!!!"} + info := ClientInfo{UserID: 1, VoiceChannelID: 100} + + result := handleVoiceE2EEAnnounceV2(context.Background(), cmd, info, deps) + + if result.Error == nil { + t.Fatal("expected error for invalid signature base64") + } + ce, ok := result.Error.(ClientError) + if !ok { + t.Fatalf("expected ClientError, got %T", result.Error) + } + if ce.Code != ErrCodeBadPayload { + t.Errorf("expected code %q, got %q", ErrCodeBadPayload, ce.Code) + } + if result.SetE2EEPubKey != nil { + t.Error("invalid signature must not store the public key") + } +} + +func TestVoiceE2EEAnnounceV2_SignatureTooLarge(t *testing.T) { + deps := VoiceDeps{} + big := base64.StdEncoding.EncodeToString(make([]byte, 200)) + cmd := VoiceE2EEAnnounceCmd{userID: 1, publicKey: validB64Key, signature: big} + info := ClientInfo{UserID: 1, VoiceChannelID: 100} + + result := handleVoiceE2EEAnnounceV2(context.Background(), cmd, info, deps) + + if result.Error == nil { + t.Fatal("expected error for oversized signature") + } + ce, ok := result.Error.(ClientError) + if !ok { + t.Fatalf("expected ClientError, got %T", result.Error) + } + if ce.Code != ErrCodeBadPayload { + t.Errorf("expected code %q, got %q", ErrCodeBadPayload, ce.Code) + } +} diff --git a/Server/ws/handlers.go b/Server/ws/handlers.go index 6aad9065..47dfd206 100644 --- a/Server/ws/handlers.go +++ b/Server/ws/handlers.go @@ -166,7 +166,11 @@ func (h *Hub) handleMessage(c *Client, raw []byte) { } } if result.SetE2EEPubKey != nil { - c.setE2EEPubKey(*result.SetE2EEPubKey) + sig := "" + if result.SetE2EESignature != nil { + sig = *result.SetE2EESignature + } + c.setE2EEPubKey(*result.SetE2EEPubKey, sig) } if result.SetVoiceJoinToken != nil { chID := c.getVoiceChID() diff --git a/Server/ws/hub.go b/Server/ws/hub.go index c9eb684b..5a712752 100644 --- a/Server/ws/hub.go +++ b/Server/ws/hub.go @@ -647,9 +647,9 @@ func (h *Hub) DisconnectUser(userID int64) { } // BroadcastUserUpdate sends a user_update message to all connected clients -// when a user changes their profile (username, avatar). -func (h *Hub) BroadcastUserUpdate(userID int64, username string, avatar *string) { - h.BroadcastToAll(buildUserUpdate(userID, username, avatar)) +// when a user changes their profile (username, avatar, identity key). +func (h *Hub) BroadcastUserUpdate(userID int64, username string, avatar *string, identityPublicKey *string) { + h.BroadcastToAll(buildUserUpdate(userID, username, avatar, identityPublicKey)) } // BroadcastMemberUpdate sends a member_update message to all connected clients. diff --git a/Server/ws/hub_test.go b/Server/ws/hub_test.go index 075c5c59..d728c101 100644 --- a/Server/ws/hub_test.go +++ b/Server/ws/hub_test.go @@ -1013,7 +1013,8 @@ CREATE TABLE IF NOT EXISTS users ( last_seen TEXT, banned INTEGER NOT NULL DEFAULT 0, ban_reason TEXT, - ban_expires TEXT + ban_expires TEXT, + identity_public_key TEXT ); CREATE TABLE IF NOT EXISTS sessions ( diff --git a/Server/ws/messages.go b/Server/ws/messages.go index 8b72a01c..1851bc86 100644 --- a/Server/ws/messages.go +++ b/Server/ws/messages.go @@ -36,6 +36,11 @@ type memberUserPayload struct { Username string `json:"username"` Avatar *string `json:"avatar"` Role string `json:"role"` + // IdentityPublicKey is the user's long-term E2EE identity public key + // (base64), pinned by peers on first sight (F3 TOFU). Omitted when the + // user has not published one (legacy client) and in payloads that do not + // carry it (e.g. chat_message). + IdentityPublicKey *string `json:"identity_public_key,omitempty"` } type memberJoinPayload struct { @@ -63,6 +68,9 @@ type userUpdatePayload struct { UserID int64 `json:"user_id"` Username string `json:"username"` Avatar *string `json:"avatar"` + // IdentityPublicKey mirrors memberUserPayload — carried so peers can + // detect an identity-key change (TOFU mismatch) as it happens. + IdentityPublicKey *string `json:"identity_public_key,omitempty"` } type memberBanPayload struct { @@ -132,9 +140,12 @@ type voiceTokenPayload struct { // ── Voice E2EE (client-side ECDH key exchange) ───────────────────────────── // voiceE2EEAnnounceBroadcast is the server→client relay with user_id added. +// Signature is the sender's identity-key signature over the ephemeral key +// (F3 TOFU) — relayed verbatim, omitted for legacy announces without one. type voiceE2EEAnnounceBroadcast struct { UserID int64 `json:"user_id"` PublicKey string `json:"public_key"` + Signature string `json:"signature,omitempty"` } // voiceE2EEOfferRelay is the server→client relay with from_user_id. @@ -254,10 +265,11 @@ func buildMemberJoin(user *db.User, roleName string) []byte { Type: MsgTypeMemberJoin, Payload: memberJoinPayload{ User: memberUserPayload{ - ID: user.ID, - Username: user.Username, - Avatar: user.Avatar, - Role: roleName, + ID: user.ID, + Username: user.Username, + Avatar: user.Avatar, + Role: roleName, + IdentityPublicKey: user.IdentityPublicKey, }, }, }) @@ -299,10 +311,15 @@ func buildMemberUpdate(userID int64, roleName string) []byte { } // buildUserUpdate constructs a user_update broadcast for profile changes. -func buildUserUpdate(userID int64, username string, avatar *string) []byte { +func buildUserUpdate(userID int64, username string, avatar *string, identityPublicKey *string) []byte { return buildJSON(wsMsg{ - Type: MsgTypeUserUpdate, - Payload: userUpdatePayload{UserID: userID, Username: username, Avatar: avatar}, + Type: MsgTypeUserUpdate, + Payload: userUpdatePayload{ + UserID: userID, + Username: username, + Avatar: avatar, + IdentityPublicKey: identityPublicKey, + }, }) } @@ -420,12 +437,14 @@ func buildVoiceToken(channelID int64, token string, proxyPath string, directURL } // buildVoiceE2EEAnnounce constructs a voice_e2ee_announce server→client relay. -func buildVoiceE2EEAnnounce(userID int64, publicKey string) []byte { +// signature may be "" (legacy announce) — the field is then omitted. +func buildVoiceE2EEAnnounce(userID int64, publicKey, signature string) []byte { return buildJSON(wsMsg{ Type: MsgTypeVoiceE2EEAnnounceBC, Payload: voiceE2EEAnnounceBroadcast{ UserID: userID, PublicKey: publicKey, + Signature: signature, }, }) } diff --git a/Server/ws/messages_test.go b/Server/ws/messages_test.go index b3e11950..5b23e996 100644 --- a/Server/ws/messages_test.go +++ b/Server/ws/messages_test.go @@ -603,7 +603,7 @@ func TestBuildVoiceToken_ValidJSON(t *testing.T) { // ─── buildVoiceE2EEAnnounce ───────────────────────────────────────────────── func TestBuildVoiceE2EEAnnounce_ValidJSON(t *testing.T) { - msg := buildVoiceE2EEAnnounce(42, "dGVzdC1wdWJrZXk=") + msg := buildVoiceE2EEAnnounce(42, "dGVzdC1wdWJrZXk=", "") if !json.Valid(msg) { t.Error("buildVoiceE2EEAnnounce output is not valid JSON") } @@ -659,3 +659,65 @@ func TestBuildVoiceE2EEOffer_ValidJSON(t *testing.T) { t.Errorf("iv = %q, want random-iv", env.Payload.IV) } } + +// ─── identity_public_key in member payloads (F3 voice E2EE TOFU) ───────────── + +func TestBuildMemberJoin_IncludesIdentityKey(t *testing.T) { + key := "aWRlbnRpdHlrZXk=" + user := &db.User{ID: 7, Username: "pinned", IdentityPublicKey: &key} + msg := buildMemberJoin(user, "member") + var env struct { + Payload struct { + User struct { + IdentityPublicKey string `json:"identity_public_key"` + } `json:"user"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Payload.User.IdentityPublicKey != key { + t.Errorf("identity_public_key = %q, want %q", env.Payload.User.IdentityPublicKey, key) + } +} + +func TestBuildMemberJoin_NoIdentityKey_Omitted(t *testing.T) { + user := &db.User{ID: 8, Username: "legacy"} + msg := buildMemberJoin(user, "member") + var env struct { + Payload struct { + User map[string]any `json:"user"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if _, present := env.Payload.User["identity_public_key"]; present { + t.Error("identity_public_key should be omitted when the user has no key") + } +} + +func TestBuildUserUpdate_IncludesIdentityKey(t *testing.T) { + key := "dXBkYXRlZGtleQ==" + msg := buildUserUpdate(9, "rotator", nil, &key) + var env struct { + Type string `json:"type"` + Payload struct { + UserID int64 `json:"user_id"` + Username string `json:"username"` + IdentityPublicKey string `json:"identity_public_key"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Type != "user_update" { + t.Errorf("type = %q, want user_update", env.Type) + } + if env.Payload.UserID != 9 || env.Payload.Username != "rotator" { + t.Errorf("payload = %+v, want user_id 9 username rotator", env.Payload) + } + if env.Payload.IdentityPublicKey != key { + t.Errorf("identity_public_key = %q, want %q", env.Payload.IdentityPublicKey, key) + } +} diff --git a/Server/ws/voice_e2ee.go b/Server/ws/voice_e2ee.go index 3263eb9b..66737a5a 100644 --- a/Server/ws/voice_e2ee.go +++ b/Server/ws/voice_e2ee.go @@ -118,8 +118,21 @@ func handleVoiceE2EEAnnounceV2(_ context.Context, cmd Command, info ClientInfo, return Result{Error: ClientError{Code: ErrCodeBadPayload, Message: "public_key is not valid base64"}} } - msg := buildVoiceE2EEAnnounce(userID, pubKey) - return Result{ + // signature (F3 TOFU) is optional — legacy clients omit it and the + // receiving client enforces the fail-closed posture. When present it is + // validated and carried verbatim: the server relays, never verifies. + sig := announceCmd.Signature() + if sig != "" { + if len(sig) > 128 { + return Result{Error: ClientError{Code: ErrCodeBadPayload, Message: "signature too large"}} + } + if err := validateBase64Loose(sig); err != nil { + return Result{Error: ClientError{Code: ErrCodeBadPayload, Message: "signature is not valid base64"}} + } + } + + msg := buildVoiceE2EEAnnounce(userID, pubKey, sig) + result := Result{ SetE2EEPubKey: &pubKey, Events: []Event{VoiceE2EEAnnounceEvent{ voiceChannelID: voiceChID, @@ -127,6 +140,10 @@ func handleVoiceE2EEAnnounceV2(_ context.Context, cmd Command, info ClientInfo, payload: msg, }}, } + if sig != "" { + result.SetE2EESignature = &sig + } + return result } // handleVoiceE2EEOfferV2 is the V2 (pure) handler for voice_e2ee_offer. @@ -233,22 +250,24 @@ func (h *Hub) sendToVoiceChannelExcept(channelID int64, excludeUserID int64, msg } } -// getClientE2EEPubKey returns the stored ECDH public key for a connected user. -// I-6 fix: Copy the public key value while h.mu.RLock is still held so the -// client cannot be garbage collected between the lookup and the key read. -func (h *Hub) getClientE2EEPubKey(userID int64) string { +// getClientE2EEPubKey returns the stored ECDH public key and its identity +// signature ("" for legacy announces) for a connected user. +// I-6 fix: Copy the values while h.mu.RLock is still held so the client +// cannot be garbage collected between the lookup and the key read. +func (h *Hub) getClientE2EEPubKey(userID int64) (string, string) { h.mu.RLock() c, ok := h.clients[userID] if !ok { h.mu.RUnlock() - return "" + return "", "" } - key := c.getE2EEPubKey() + key, sig := c.getE2EEPubKey() h.mu.RUnlock() - return key + return key, sig } // GetClientE2EEPubKeyForTest is an exported wrapper for tests. func (h *Hub) GetClientE2EEPubKeyForTest(userID int64) string { - return h.getClientE2EEPubKey(userID) + key, _ := h.getClientE2EEPubKey(userID) + return key } diff --git a/Server/ws/voice_e2ee_test.go b/Server/ws/voice_e2ee_test.go index aa9276aa..6411b1c6 100644 --- a/Server/ws/voice_e2ee_test.go +++ b/Server/ws/voice_e2ee_test.go @@ -465,3 +465,145 @@ func TestE2EE_ConcurrentPubKeyAccess(t *testing.T) { } <-done } + +// ─── F3: announce signature — relay + late-joiner replay ───────────────────── + +// e2eeAnnounceMsgSigned builds a voice_e2ee_announce message with a signature. +func e2eeAnnounceMsgSigned(publicKey, signature string) []byte { + raw, _ := json.Marshal(map[string]any{ + "type": "voice_e2ee_announce", + "payload": map[string]any{ + "public_key": publicKey, + "signature": signature, + }, + }) + return raw +} + +// validB64Sig returns a valid base64-encoded 64-byte ECDSA signature. +func validB64SigStr() string { + sig := make([]byte, 64) + sig[0] = 0x01 + return base64.StdEncoding.EncodeToString(sig) +} + +func TestE2EE_AnnounceSignature_RelayedToPeers(t *testing.T) { + hub, database := newVoiceHub(t) + chanID := seedVoiceChan(t, database, "vc-sig-relay") + + user1 := seedVoiceOwner(t, database, "sig-user1") + send1 := make(chan []byte, 32) + c1 := ws.NewTestClientWithUser(hub, user1, 0, send1) + hub.Register(c1) + user2 := seedVoiceOwner(t, database, "sig-user2") + send2 := make(chan []byte, 32) + c2 := ws.NewTestClientWithUser(hub, user2, 0, send2) + hub.Register(c2) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c1, voiceJoinMsg(chanID)) + hub.HandleMessageForTest(c2, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + drainChan(send1) + drainChan(send2) + + key := validB64Key() + sig := validB64SigStr() + hub.HandleMessageForTest(c1, e2eeAnnounceMsgSigned(key, sig)) + time.Sleep(30 * time.Millisecond) + + found := false + for _, m := range drainChan(send2) { + if extractType(t, m) != "voice_e2ee_announce" { + continue + } + found = true + gotSig, _ := extractPayloadField(t, m, "signature").(string) + if gotSig != sig { + t.Errorf("relayed signature = %q, want %q", gotSig, sig) + } + gotKey, _ := extractPayloadField(t, m, "public_key").(string) + if gotKey != key { + t.Errorf("relayed public_key = %q, want %q", gotKey, key) + } + } + if !found { + t.Error("peer should receive the signed announce") + } +} + +func TestE2EE_AnnounceSignature_ReplayedToLateJoiner(t *testing.T) { + hub, database := newVoiceHub(t) + chanID := seedVoiceChan(t, database, "vc-sig-replay") + + user1 := seedVoiceOwner(t, database, "sigrp-user1") + send1 := make(chan []byte, 32) + c1 := ws.NewTestClientWithUser(hub, user1, 0, send1) + hub.Register(c1) + time.Sleep(20 * time.Millisecond) + hub.HandleMessageForTest(c1, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + + key := validB64Key() + sig := validB64SigStr() + hub.HandleMessageForTest(c1, e2eeAnnounceMsgSigned(key, sig)) + time.Sleep(30 * time.Millisecond) + drainChan(send1) + + // A late joiner must receive the stored announce WITH its signature. + user2 := seedVoiceOwner(t, database, "sigrp-user2") + send2 := make(chan []byte, 32) + c2 := ws.NewTestClientWithUser(hub, user2, 0, send2) + hub.Register(c2) + time.Sleep(20 * time.Millisecond) + hub.HandleMessageForTest(c2, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + + found := false + for _, m := range drainChan(send2) { + if extractType(t, m) != "voice_e2ee_announce" { + continue + } + found = true + gotSig, _ := extractPayloadField(t, m, "signature").(string) + if gotSig != sig { + t.Errorf("replayed signature = %q, want %q", gotSig, sig) + } + } + if !found { + t.Error("late joiner should receive the replayed announce") + } +} + +func TestE2EE_AnnounceNoSignature_ReplayOmitsField(t *testing.T) { + hub, database := newVoiceHub(t) + chanID := seedVoiceChan(t, database, "vc-nosig") + + user1 := seedVoiceOwner(t, database, "nosig-user1") + send1 := make(chan []byte, 32) + c1 := ws.NewTestClientWithUser(hub, user1, 0, send1) + hub.Register(c1) + time.Sleep(20 * time.Millisecond) + hub.HandleMessageForTest(c1, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + hub.HandleMessageForTest(c1, e2eeAnnounceMsg(validB64Key())) + time.Sleep(30 * time.Millisecond) + drainChan(send1) + + user2 := seedVoiceOwner(t, database, "nosig-user2") + send2 := make(chan []byte, 32) + c2 := ws.NewTestClientWithUser(hub, user2, 0, send2) + hub.Register(c2) + time.Sleep(20 * time.Millisecond) + hub.HandleMessageForTest(c2, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + + for _, m := range drainChan(send2) { + if extractType(t, m) != "voice_e2ee_announce" { + continue + } + if v := extractPayloadField(t, m, "signature"); v != nil { + t.Errorf("legacy replay must omit signature, got %v", v) + } + } +} diff --git a/Server/ws/voice_join.go b/Server/ws/voice_join.go index edbac54d..f5a147b4 100644 --- a/Server/ws/voice_join.go +++ b/Server/ws/voice_join.go @@ -212,10 +212,11 @@ func (h *Hub) handleVoiceJoin(ctx context.Context, c *Client, payload json.RawMe continue } c.sendMsg(buildVoiceState(vs)) - // Send existing participant's ECDH public key so the joiner can - // participate in the client-side E2EE key exchange. - if pubKey := h.getClientE2EEPubKey(vs.UserID); pubKey != "" { - c.sendMsg(buildVoiceE2EEAnnounce(vs.UserID, pubKey)) + // Send existing participant's ECDH public key (and its identity + // signature, F3 TOFU) so the joiner can participate in the + // client-side E2EE key exchange. + if pubKey, sig := h.getClientE2EEPubKey(vs.UserID); pubKey != "" { + c.sendMsg(buildVoiceE2EEAnnounce(vs.UserID, pubKey, sig)) } } diff --git a/docs/plans/security-hardening-remediation.md b/docs/plans/security-hardening-remediation.md index 323c56e6..136f852b 100644 --- a/docs/plans/security-hardening-remediation.md +++ b/docs/plans/security-hardening-remediation.md @@ -1,8 +1,22 @@ # Plan: Remediate security-hardening review regressions -**Status:** mostly landed — verified 2026-07-23 (deletion audit): every item -except **W2-4** and **W3-3** has been implemented or superseded. This doc is -the tracker of record for those two; close it when they land. +**Status:** COMPLETE — verified 2026-07-23 (branch `feat/e2ee-identity-tofu`): every item +has been implemented or superseded. W2-4 and both halves of W3-3 are the last to land. +**W2-4:** DONE 2026-07-23 — `Server/db/attachment_queries.go:111` +`LinkAttachmentsToMessage` links atomically and skips (not fails) already-linked, +non-owned, and missing ids; legacy `uploader_id IS NULL` rows claimable. Locked by +`TestLinkAttachmentsToMessage_SkipsAlreadyLinked` and +`TestLinkAttachmentsToMessage_OwnershipGuard` (`Server/db/attachment_queries_test.go`). +**W3-3:** DONE 2026-07-23 — two halves. (a) **XFF CIDR pre-parse:** `trustedCIDRs` parsed +once at middleware construction into `[]*net.IPNet`; `clientIPWithProxies` takes the parsed +form, `isTrustedProxy` deleted (callers use `ipInNets`), invalid entries warn at startup not +per request. Locked by `TestRateLimitMiddleware_InvalidCIDRWarnsAtConstructionNotPerRequest`; +leftmost-valid XFF fallback + `AdminIPRestrict` fail-closed unchanged. (b) **Update TOCTOU:** +`DownloadAndVerify` returns the trusted hash; new `updater.OpenVerifiedBinary`/`Commit` +verify through one open handle and confirm via `os.SameFile` that the renamed file is the one +verified; O_EXCL 0600 staging refuses pre-planted paths. Locked by +`TestOpenVerifiedBinary_SwapAfterVerifyDetectedAtCommit` + `TestDownloadFile_RefusesPreExistingDest` +(Linux fd/tarball path is CI-verified). Server `-race` + `-tags deadlock` green. **Owner:** TBD **Tracks:** code review of branch `fix/security-hardening-review` (2026-07-17) **Estimated effort:** 2–4 focused days diff --git a/docs/plans/security-scan-2026-07-22-remediation.md b/docs/plans/security-scan-2026-07-22-remediation.md index e771c683..9a41ffa8 100644 --- a/docs/plans/security-scan-2026-07-22-remediation.md +++ b/docs/plans/security-scan-2026-07-22-remediation.md @@ -12,7 +12,7 @@ This is a continuation/handoff doc: what is done, what remains, and how to resum |---|-----|---------|--------| | F1 | MED | Login lockout keyed on un-canonicalized username (vs `COLLATE NOCASE`) | ✅ done, committed `7145f76` | | F2 | MED | Unsynchronized concurrent wazero module invocation (data race) | ✅ done, committed `71b5f13` | -| F3 | MED | Voice E2EE trusts server-relayed ECDH keys (server MITM) | ⏳ **TODO — designed, not started** | +| F3 | MED | Voice E2EE trusts server-relayed ECDH keys (server MITM) | ✅ **implemented (branch `feat/e2ee-identity-tofu`)** — MITM closed for published+pinned peers; UI surfacing is follow-up (see below) | | F4 | MED | HTTP TOFU proxy accepts any cert on first use (credential exposure) | ✅ done, committed `f22985a` | | F5 | LOW | Voice perms use stale connect-time role snapshot | ✅ done, committed `260d038` | | F6 | LOW | Lost cache invalidation in `PermissionService.getOrPopulate` | ✅ done, committed `e6a0d87` | @@ -26,7 +26,8 @@ This is a continuation/handoff doc: what is done, what remains, and how to resum `cd Client/tauri-client/src-tauri && cargo clippy -- -D warnings` (or push and let CI do it). Pure `tofu` logic has `#[cfg(test)]` unit tests; the frontend is covered by the 3311-green unit suite. -2. **Then F3** — the only remaining finding (below). (F6 landed 2026-07-23 as +2. ~~**Then F3**~~ — **DONE 2026-07-23** on branch `feat/e2ee-identity-tofu` (see the + "F3 status 2026-07-23" block directly below). (F6 landed 2026-07-23 as `e6a0d87`, split out from the D13 permission-consolidation commits that followed it on this branch.) @@ -39,6 +40,46 @@ perms served up to `permCacheTTL`). Fix: a `gen uint64` counter bumped by every cache if it changed. Test `TestGetOrPopulate_InvalidationDuringPopulateNotLost` locks it. Verified `-race` + `-tags deadlock` green. +## F3 status 2026-07-23 (branch `feat/e2ee-identity-tofu`) + +**Implemented, test-first, MITM path verified closed by a 3-lens adversarial panel + a +dedicated re-verification pass.** The original implementation shipped the crypto but had a +dead publish path (the feature was inert); that and three related defects were caught by +review and fixed. What is done: + +- **Server:** migration `017_user_identity_key.sql` (`users.identity_public_key`); + `UpdateUserIdentityKey` + column in user/`ListMembers` SELECTs; `PATCH /users/me` + accepts+persists the key (via `UserService.UpdateIdentityKey`, audited); key carried in + `ready`/`member_join`/`user_update`; `voice_e2ee_announce` gains an optional `signature` + validated + stored + relayed (incl. the late-joiner replay). Legacy unsigned announces + still accepted (client enforces fail-closed). `make sqlc-verify`/`protocol-verify` green; + server `-race` + `-tags deadlock` green. +- **Client:** ECDSA P-256 identity keypair (OS keyring via new Rust + `save/load/delete_identity_key` + pin store `identity_pins.json`); ephemeral announces + signed at all sites; **publish wired into the `ready` flow** (dispatcher publishes the key + once, with username, when the server copy is absent/stale); `verifyPeerAnnounce` resolves + the **pin before** the legacy shortcut (a stripping server can't downgrade a pinned peer); + `rePinPeerIdentity` for key-rotation recovery. Full client suite 3337 green; + typecheck/lint/format clean. (Rust halves compile-checked only — no local MSVC; **CI + must verify `cargo`**.) + +**Verified closed:** a malicious/stripping server can no longer silently MITM a peer whose +identity key is published and locally pinned — an ephemeral-key swap fails ECDSA +verification and the room key is never wrapped for the attacker. + +**Follow-up (not MITM holes — deferred, none block the crypto):** +1. **Surface the safety number in the voice panel.** `safetyNumber`/`peerVerifications` are + computed and stored but **no component renders them**, so the out-of-band check that + detects the inherent TOFU *first-contact* window is not user-reachable yet. +2. **Wire the verified/unverified/mismatch badge + a re-pin affordance.** `rePinPeerIdentity` + exists but no UI calls it — a legitimately rotated peer key currently blocks voice with no + in-app recovery (mirror `main.ts`'s `createCertMismatchModal onAccept` flow). +3. `getIdentityPin` **fail-opens** on a transient local keyring/store read error (one announce + falls through to legacy). Not server-controllable; consider fail-closed when a pin *may* + exist. +4. Fast-join timing: a peer joining voice before peers process its `user_update` is seen as + legacy for that announce — degrades to *unverified*, never wrongly-*verified*. + ## F3 — Voice E2EE identity keys + TOFU (the remaining work) **Problem.** `voice_e2ee_announce` carries only `{public_key}`; the server diff --git a/docs/protocol.md b/docs/protocol.md index 6e51bacd..75629ce2 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -246,7 +246,7 @@ Sent once after `auth_ok` (fresh connection or replay fallback). **dm_channels[]:** `channel_id`, `recipient` (user object with `id`, `username`, `avatar`, `status`), `last_message_id`, `last_message`, `last_message_at`, `unread_count` -**members[]:** All registered users with `id`, `username`, `avatar`, `role` (lowercase name), `status` +**members[]:** All registered users with `id`, `username`, `avatar`, `role` (lowercase name), `status`, `identity_public_key` (base64 long-term E2EE identity key, omitted when the user has not published one — see voice E2EE TOFU) **voice_states[]:** All users currently in any voice channel: `channel_id`, `user_id`, `muted`, `deafened` @@ -531,12 +531,16 @@ Sent when a user first connects (fresh connection, not reconnect replay). "id": 5, "username": "newuser", "avatar": null, - "role": "member" + "role": "member", + "identity_public_key": "base64-identity-pubkey" } } } ``` +`identity_public_key` is the user's long-term E2EE identity public key (see +voice E2EE TOFU); omitted when the user has not published one. + ### member_update (Server -> Client, broadcast) Triggered when an admin changes a user's role. @@ -565,7 +569,7 @@ Triggered when an admin changes a user's role. ### user_update (Server -> Client, broadcast) Broadcast when a user changes their own profile via `PATCH /api/v1/users/me` -(username and/or avatar). +(username, avatar and/or identity key). ```json { @@ -574,12 +578,15 @@ Broadcast when a user changes their own profile via `PATCH /api/v1/users/me` "payload": { "user_id": 5, "username": "newname", - "avatar": "uuid.png" + "avatar": "uuid.png", + "identity_public_key": "base64-identity-pubkey" } } ``` -`avatar` may be `null` when unset. +`avatar` may be `null` when unset. `identity_public_key` carries the user's +current long-term E2EE identity key and is omitted when none is published; +peers that pinned a different key must surface a TOFU mismatch. ### member_leave (reserved) @@ -739,24 +746,50 @@ the room key so departed members cannot decrypt future media. Both E2EE message types are rate limited at 5 per second per user. Key material must be standard-alphabet base64 (padded or unpadded). +**Identity keys + TOFU:** each client holds a long-term ECDSA P-256 identity +keypair, published via `PATCH /api/v1/users/me` (`identity_public_key`) and +distributed in the `ready` / `member_join` / `user_update` member payloads. +Peers pin the key on first sight (trust-on-first-use) and verify each +announce's `signature` against the pin, so a malicious server cannot swap +`user_id ↔ ephemeral pubkey` after first contact. A later key change is +surfaced to the user as a TOFU mismatch. + ### voice_e2ee_announce (Client -> Server) -Announce this participant's ECDH public key to the channel. +Announce this participant's ephemeral ECDH public key to the channel. +`signature` is the ECDSA P-256 signature by the sender's long-term identity +key over `"owncord-voice-e2ee-announce-v1" ‖ userId ‖ ephemeral-pubkey-raw` +(TOFU — see above). It is optional at the protocol level: legacy clients omit +it, and receiving clients enforce the fail-closed posture (peer has a +published identity key but the signature is missing/invalid → reject). ```json -{ "type": "voice_e2ee_announce", "payload": { "public_key": "base64-ecdh-pubkey" } } +{ + "type": "voice_e2ee_announce", + "payload": { + "public_key": "base64-ecdh-pubkey", + "signature": "base64-ecdsa-signature" + } +} ``` +The server validates `signature` like `public_key` (standard-alphabet base64, +max 128 chars) and stores it alongside the key, but never verifies it — only +clients hold the pinned identity keys. + ### voice_e2ee_announce (Server -> Client, broadcast to voice channel) -Relayed to the other participants with the sender's user ID attached: +Relayed to the other participants with the sender's user ID attached. Also +replayed to late joiners from the stored key+signature. `signature` is +omitted when the announcing client did not send one: ```json { "type": "voice_e2ee_announce", "payload": { "user_id": 1, - "public_key": "base64-ecdh-pubkey" + "public_key": "base64-ecdh-pubkey", + "signature": "base64-ecdsa-signature" } } ``` diff --git a/docs/schema.md b/docs/schema.md index b44320ae..de49c0ae 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -59,6 +59,7 @@ CREATE TABLE IF NOT EXISTS schema_versions ( | `014_events_table.sql` | Adds `events` — persistent broadcast log for reconnect cold-tier replay | | `015_plugins.sql` | Adds `plugins` and `plugin_kv` for the WASM plugin runtime | | `016_announcement_channel_type.sql` | Recreates the channel-type triggers to allow `announcement` | +| `017_user_identity_key.sql` | Adds `users.identity_public_key` (long-term E2EE identity key for voice TOFU) | --- @@ -105,12 +106,17 @@ CREATE TABLE users ( last_seen TEXT, banned INTEGER NOT NULL DEFAULT 0, ban_reason TEXT, - ban_expires TEXT + ban_expires TEXT, + identity_public_key TEXT ); ``` Valid status values: `online`, `idle`, `dnd`, `offline`. All statuses are reset to `offline` on server startup. +`identity_public_key` (added in migration 017) is the user's long-term E2EE +identity public key (base64 ECDSA P-256) used for TOFU pinning of voice E2EE +announces; `NULL` = not published (legacy client). + --- ### sessions