Merge pull request #1232 from J3vb/feat/e2ee-identity-tofu

feat(e2ee): F3 voice identity keys + TOFU verification (server, client, UI) + W2-4/W3-3 hardening
This commit is contained in:
J3vb
2026-07-23 22:08:33 +02:00
committed by GitHub
68 changed files with 3374 additions and 347 deletions
+103 -1
View File
@@ -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<Option<String>, 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");
}
}
@@ -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";
@@ -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<Option<String>, 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 {
+5
View File
@@ -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,
@@ -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" });
@@ -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<void> {
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) {
+1 -1
View File
@@ -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<MemberResponse> {
return request<MemberResponse>("PATCH", "/users/me", data, signal);
+25 -3
View File
@@ -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<WsClient, "onStateChange">): () =>
*/
export function wireDispatcher(
ws: WsClient,
api?: Pick<ApiClient, "listBlocks">,
api?: Pick<ApiClient, "listBlocks"> & Partial<Pick<ApiClient, "updateProfile" | "getConfig">>,
): 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);
}),
);
+119
View File
@@ -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<CryptoKey> {
* 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<string> {
const raw = await crypto.subtle.exportKey("raw", publicKey);
@@ -71,6 +86,110 @@ export async function computeKeyFingerprint(publicKey: CryptoKey): Promise<strin
return groups.slice(0, 8).join(" ");
}
// ── Identity keypair (sign/verify ephemeral announces) ─────────────────────
/** Generate a long-term ECDSA P-256 identity keypair. */
export async function generateIdentityKeyPair(): Promise<CryptoKeyPair> {
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<string> {
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<boolean> {
let signature: Uint8Array<ArrayBuffer>;
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<CryptoKey> {
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<string> {
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<CryptoKeyPair> {
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<ArrayBuffer> {
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. */
+8
View File
@@ -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<IconName, string> = {
// Lightning bolt (auto-login indicator)
zap: `<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>`,
// Security shields — E2EE identity verification badges (F3 TOFU voice panel).
shield: `<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>`,
"shield-check": `<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/>`,
"shield-alert": `<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="M12 8v4"/><path d="M12 16h.01"/>`,
};
// ---------------------------------------------------------------------------
+187
View File
@@ -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<string, unknown>) => Promise<unknown>) | 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<boolean> {
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<string | null> {
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<boolean> {
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<boolean> {
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<string | null> {
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<CryptoKeyPair> {
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<unknown>,
serverCopy: string | null | undefined,
publicKey: CryptoKey,
): Promise<boolean> {
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<unknown>,
): Promise<boolean> {
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;
}
}
+211 -32
View File
@@ -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<number, CryptoKey> = 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<typeof setTimeout> | 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<CryptoKeyPair | null> {
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<boolean> {
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<boolean> {
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<void> {
async handleE2EEAnnounce(
userId: number,
publicKeyBase64: string,
signatureBase64?: string,
): Promise<void> {
// 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<void> {
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);
+14 -1
View File
@@ -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<VoiceCameraPayload> & { readonly type: "voice_camera" })
| (WsEnvelope<VoiceScreensharePayload> & { readonly type: "voice_screenshare" })
| (WsEnvelope<Record<string, never>> & { 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";
});
@@ -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 };
});
}
@@ -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<number, ReadonlyMap<number, VoiceUser>>; // 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<number, PeerVerification>;
}
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<VoiceState>(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) => {
+3
View File
@@ -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 {
@@ -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<typeof import("../../src/lib/e2eeCrypto")>();
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<typeof createChannelSidebar>;
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();
});
});
@@ -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<unknown>;
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] });
@@ -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),
);
});
});
});
@@ -40,6 +40,9 @@ const ALL_ICON_NAMES: IconName[] = [
"arrow-right",
"hash",
"triangle-alert",
"shield",
"shield-check",
"shield-alert",
];
describe("createIcon", () => {
@@ -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<Parameters<typeof createIdentityMismatchModal>[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");
});
});
@@ -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<string, unknown>) => {
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<string, unknown>) => {
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);
});
});
@@ -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<typeof vi.fn> }): Promise<void> {
session.setServerHost(HOST);
session.setWsClient(ws as any);
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880", true);
}
function offerSends(ws: { send: ReturnType<typeof vi.fn> }): 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);
});
});
});
@@ -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,
});
});
@@ -33,6 +33,7 @@ function resetStore(): void {
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
peerVerifications: new Map(),
}));
}
+2 -1
View File
@@ -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 (
+2 -1
View File
@@ -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,
+18 -20
View File
@@ -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,
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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{
+2 -1
View File
@@ -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,
+58 -64
View File
@@ -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)
}
+2 -1
View File
@@ -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 (
+32 -44
View File
@@ -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",
+64 -3
View File
@@ -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 (
+48 -4
View File
@@ -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))
+83
View File
@@ -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())
}
})
}
}
+2 -1
View File
@@ -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,
+23 -5
View File
@@ -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
+85 -1
View File
@@ -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)
}
}
+13 -12
View File
@@ -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 {
+1
View File
@@ -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
+26 -8
View File
@@ -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 = ?
`
+13 -12
View File
@@ -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,
}
}
+4
View File
@@ -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.
+6 -3
View File
@@ -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
@@ -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;
+1
View File
@@ -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
+17
View File
@@ -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:
+169 -55
View File
@@ -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
// "<hash> <filename>") 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)
}
+146 -11
View File
@@ -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")
}
}
+9 -5
View File
@@ -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).
+7 -1
View File
@@ -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) {
+5
View File
@@ -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
+4 -3
View File
@@ -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.
+92
View File
@@ -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)
}
}
+5 -1
View File
@@ -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()
+3 -3
View File
@@ -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.
+2 -1
View File
@@ -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 (
+27 -8
View File
@@ -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,
},
})
}
+63 -1
View File
@@ -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)
}
}
+29 -10
View File
@@ -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
}
+142
View File
@@ -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)
}
}
}
+5 -4
View File
@@ -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))
}
}
+17 -3
View File
@@ -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:** 24 focused days
@@ -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
+42 -9
View File
@@ -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"
}
}
```
+7 -1
View File
@@ -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