mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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"/>`,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
# Plan: Remediate security-hardening review regressions
|
||||
|
||||
**Status:** mostly landed — verified 2026-07-23 (deletion audit): every item
|
||||
except **W2-4** and **W3-3** has been implemented or superseded. This doc is
|
||||
the tracker of record for those two; close it when they land.
|
||||
**Status:** COMPLETE — verified 2026-07-23 (branch `feat/e2ee-identity-tofu`): every item
|
||||
has been implemented or superseded. W2-4 and both halves of W3-3 are the last to land.
|
||||
**W2-4:** DONE 2026-07-23 — `Server/db/attachment_queries.go:111`
|
||||
`LinkAttachmentsToMessage` links atomically and skips (not fails) already-linked,
|
||||
non-owned, and missing ids; legacy `uploader_id IS NULL` rows claimable. Locked by
|
||||
`TestLinkAttachmentsToMessage_SkipsAlreadyLinked` and
|
||||
`TestLinkAttachmentsToMessage_OwnershipGuard` (`Server/db/attachment_queries_test.go`).
|
||||
**W3-3:** DONE 2026-07-23 — two halves. (a) **XFF CIDR pre-parse:** `trustedCIDRs` parsed
|
||||
once at middleware construction into `[]*net.IPNet`; `clientIPWithProxies` takes the parsed
|
||||
form, `isTrustedProxy` deleted (callers use `ipInNets`), invalid entries warn at startup not
|
||||
per request. Locked by `TestRateLimitMiddleware_InvalidCIDRWarnsAtConstructionNotPerRequest`;
|
||||
leftmost-valid XFF fallback + `AdminIPRestrict` fail-closed unchanged. (b) **Update TOCTOU:**
|
||||
`DownloadAndVerify` returns the trusted hash; new `updater.OpenVerifiedBinary`/`Commit`
|
||||
verify through one open handle and confirm via `os.SameFile` that the renamed file is the one
|
||||
verified; O_EXCL 0600 staging refuses pre-planted paths. Locked by
|
||||
`TestOpenVerifiedBinary_SwapAfterVerifyDetectedAtCommit` + `TestDownloadFile_RefusesPreExistingDest`
|
||||
(Linux fd/tarball path is CI-verified). Server `-race` + `-tags deadlock` green.
|
||||
**Owner:** TBD
|
||||
**Tracks:** code review of branch `fix/security-hardening-review` (2026-07-17)
|
||||
**Estimated effort:** 2–4 focused days
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user