fix: camera button delay and video feed flickering + security hardening

Camera fixes:
- Optimistic setLocalCamera(true) before async setCameraEnabled for instant
  button highlight, with revert on failure
- Only call checkVideoMode when camera-relevant state changes, not on every
  speaking poll tick (100ms)
- VideoGrid.addStream updates existing cells in place instead of
  destroy+recreate to prevent black frame flashes
- VideoModeController tracks localTileAdded to avoid redundant addStream calls

Security & hardening (from prior session review):
- Settings store key allowlist prevents arbitrary key writes
- Certificate fingerprint validates SHA-256 colon-hex format
- CredentialData Debug impl redacts token and password
- LiveKit config file written with 0600 permissions
- Token TTL reduced from 24h to 4h
- Null-check on client.user before token generation
- Thread-safe getChannelID/trySendMsg helpers on Client
- Warn on default dev LiveKit credentials
- Devtools feature-gated behind cfg(feature = "devtools")
- Updater uses configure_client for self-signed cert acceptance
- Clamp voice sensitivity input to 0-100 range
- Clear lastConnectToken/Host on logout
- Fix animation frame leak in VoiceAudioTab mic meter
This commit is contained in:
jevb
2026-03-20 12:30:12 +01:00
parent bbfafb7930
commit 2ac8e886a9
19 changed files with 186 additions and 54 deletions
+5 -1
View File
@@ -11,8 +11,12 @@ crate-type = ["lib", "cdylib", "staticlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[features]
default = ["devtools"]
devtools = ["tauri/devtools"]
[dependencies]
tauri = { version = "2", features = ["tray-icon", "devtools"] }
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-store = "2"
tauri-plugin-global-shortcut = "2"
tauri-plugin-notification = "2"
+47 -3
View File
@@ -1,10 +1,33 @@
use serde_json::Value;
use tauri::Manager;
use tauri_plugin_store::StoreExt;
const SETTINGS_STORE: &str = "settings.json";
const CERTS_STORE: &str = "certs.json";
/// Maximum length for a settings key to prevent denial-of-service.
const MAX_SETTINGS_KEY_LEN: usize = 128;
/// Allowed key prefixes and exact keys for the settings store.
/// Keys must either match an exact entry or start with an allowed prefix.
const ALLOWED_SETTINGS_PREFIXES: &[&str] = &[
"owncord:", // owncord:profiles, owncord:settings:*, owncord:recent-emoji
"userVolume_", // per-user volume: userVolume_{userId}
];
const ALLOWED_SETTINGS_EXACT: &[&str] = &[
"windowState",
];
fn is_settings_key_allowed(key: &str) -> bool {
if key.len() > MAX_SETTINGS_KEY_LEN || key.is_empty() {
return false;
}
if ALLOWED_SETTINGS_EXACT.contains(&key) {
return true;
}
ALLOWED_SETTINGS_PREFIXES.iter().any(|prefix| key.starts_with(prefix))
}
// ---------------------------------------------------------------------------
// Settings commands
// ---------------------------------------------------------------------------
@@ -27,6 +50,10 @@ pub fn get_settings(app: tauri::AppHandle) -> Result<Value, String> {
#[tauri::command]
pub fn save_settings(app: tauri::AppHandle, key: String, value: Value) -> Result<(), String> {
if !is_settings_key_allowed(&key) {
return Err(format!("unknown settings key: {key}"));
}
let store = app
.store(SETTINGS_STORE)
.map_err(|e| format!("failed to open settings store: {e}"))?;
@@ -55,6 +82,20 @@ pub fn store_cert_fingerprint(
return Err("fingerprint must not be empty".into());
}
// Validate SHA-256 colon-hex format: "AA:BB:CC:..." (95 chars, 32 hex pairs)
if fingerprint.len() != 95 {
return Err("fingerprint must be a SHA-256 colon-hex string (95 chars)".into());
}
for (i, ch) in fingerprint.chars().enumerate() {
if i % 3 == 2 {
if ch != ':' {
return Err("fingerprint must use colon-separated hex pairs".into());
}
} else if !ch.is_ascii_hexdigit() {
return Err("fingerprint contains invalid hex character".into());
}
}
let store = app
.store(CERTS_STORE)
.map_err(|e| format!("failed to open certs store: {e}"))?;
@@ -95,7 +136,10 @@ pub fn get_cert_fingerprint(
// ---------------------------------------------------------------------------
#[tauri::command]
pub fn open_devtools(window: tauri::WebviewWindow) {
pub fn open_devtools(_window: tauri::WebviewWindow) {
#[cfg(feature = "devtools")]
window.open_devtools();
{
use tauri::Manager;
_window.open_devtools();
}
}
@@ -8,7 +8,7 @@ use windows::Win32::Security::Credentials::{
};
/// Data returned from `load_credential`.
#[derive(Serialize, Clone, Debug)]
#[derive(Serialize, Clone)]
pub struct CredentialData {
pub username: String,
pub token: String,
@@ -16,6 +16,16 @@ pub struct CredentialData {
pub password: Option<String>,
}
impl std::fmt::Debug for CredentialData {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CredentialData")
.field("username", &self.username)
.field("token", &"[REDACTED]")
.field("password", &self.password.as_ref().map(|_| "[REDACTED]"))
.finish()
}
}
/// Build the target name used in Windows Credential Manager.
fn target_name(host: &str) -> Vec<u16> {
let name = format!("OwnCord/{host}");
@@ -129,12 +139,12 @@ pub fn load_credential(host: String) -> Result<Option<CredentialData>, String> {
let username = parsed
.get("username")
.and_then(|v| v.as_str())
.unwrap_or_default()
.ok_or("credential blob missing 'username' field")?
.to_string();
let token = parsed
.get("token")
.and_then(|v| v.as_str())
.unwrap_or_default()
.ok_or("credential blob missing 'token' field")?
.to_string();
let password = parsed
.get("password")
@@ -33,10 +33,14 @@ pub async fn check_client_update(
.parse()
.map_err(|e: url::ParseError| format!("bad endpoint URL: {e}"))?;
// OwnCord is self-hosted and commonly uses self-signed TLS certs.
// The updater connects to the user's own server, so accept invalid certs
// (the update artifact itself is verified via Ed25519 signature).
let updater = app
.updater_builder()
.endpoints(vec![url])
.map_err(|e| format!("failed to set endpoints: {e}"))?
.configure_client(|client| client.danger_accept_invalid_certs(true))
.build()
.map_err(|e| format!("failed to build updater: {e}"))?;
@@ -87,6 +91,7 @@ pub async fn download_and_install_update(
.updater_builder()
.endpoints(vec![url])
.map_err(|e| format!("failed to set endpoints: {e}"))?
.configure_client(|client| client.danger_accept_invalid_certs(true))
.build()
.map_err(|e| format!("failed to build updater: {e}"))?;
@@ -48,8 +48,6 @@
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEJCQkQ0NzM1MjkxRTlGQTIKUldTaW54NHBOVWU5dTRqYW0yalI5VTJBd0NXOUZwM2UrcDM4YkhCSmlMZWJKWWVXaGJWdHBaSHgK",
"endpoints": [],
"dangerousAcceptInvalidCerts": true,
"dangerousAcceptInvalidHostnames": true,
"windows": {
"installMode": "passive"
}
@@ -101,7 +101,6 @@ function showUserVolumeMenu(
// Close on click outside
const dismissAc = new AbortController();
const combinedSignal = signal;
setTimeout(() => {
document.addEventListener("mousedown", (e: MouseEvent) => {
if (!menu.contains(e.target as Node)) {
@@ -112,7 +111,7 @@ function showUserVolumeMenu(
}, 0);
// Also clean up if the parent component is destroyed
combinedSignal.addEventListener("abort", () => {
signal.addEventListener("abort", () => {
menu.remove();
dismissAc.abort();
});
@@ -31,9 +31,22 @@ export function createVideoGrid(): VideoGridComponent {
function addStream(userId: number, username: string, stream: MediaStream): void {
if (root === null) return;
// Remove existing cell for this user first
if (cells.has(userId)) {
removeStream(userId);
// If a cell already exists for this user, update it in place
const existing = cells.get(userId);
if (existing) {
const video = existing.querySelector("video");
if (video !== null) {
// Only replace srcObject if the underlying tracks changed
const oldTracks = (video.srcObject as MediaStream | null)?.getTracks() ?? [];
const newTracks = stream.getTracks();
const tracksMatch =
oldTracks.length === newTracks.length &&
oldTracks.every((t, i) => t.id === newTracks[i]?.id);
if (!tracksMatch) {
video.srcObject = stream;
}
}
return;
}
const video = createElement("video", {
@@ -269,6 +269,7 @@ function buildVoiceAudioTabInner(signal: AbortSignal, registerMic: MicRegistrar,
const dataArray = new Uint8Array(analyser.frequencyBinCount);
let latestFrame = 0;
function updateMeter(): void {
if (signal.aborted) return;
analyser.getByteFrequencyData(dataArray);
@@ -291,11 +292,11 @@ function buildVoiceAudioTabInner(signal: AbortSignal, registerMic: MicRegistrar,
meterLevel.style.background = "#faa61a"; // yellow — below threshold
}
const frame = requestAnimationFrame(updateMeter);
registerMic(stream, audioCtx, frame);
latestFrame = requestAnimationFrame(updateMeter);
registerMic(stream, audioCtx, latestFrame);
}
const firstFrame = requestAnimationFrame(updateMeter);
registerMic(stream, audioCtx, firstFrame);
latestFrame = requestAnimationFrame(updateMeter);
registerMic(stream, audioCtx, latestFrame);
} catch {
// Mic access denied or unavailable — meter stays empty
}
@@ -625,6 +625,9 @@ export async function enableCamera(): Promise<void> {
return;
}
// Optimistic UI update — highlight button immediately
setLocalCamera(true);
try {
await room.localParticipant.setCameraEnabled(true);
@@ -634,10 +637,11 @@ export async function enableCamera(): Promise<void> {
await room.switchActiveDevice("videoinput", savedVideoDevice);
}
setLocalCamera(true);
ws.send({ type: "voice_camera", payload: { enabled: true } });
log.info("Camera enabled");
} catch (err) {
// Revert optimistic update on failure
setLocalCamera(false);
log.error("Failed to enable camera", err);
if (err instanceof DOMException && err.name === "NotAllowedError") {
onErrorCallback?.("Camera permission denied");
@@ -759,7 +763,8 @@ export function getUserVolume(userId: number): number {
* High sensitivity (100) = low threshold (picks up quiet sounds).
* Low sensitivity (0) = high threshold (only loud sounds). */
export function setVoiceSensitivity(sensitivity: number): void {
speakingThreshold = ((100 - sensitivity) / 100) * 0.15;
const clamped = Math.max(0, Math.min(100, sensitivity));
speakingThreshold = ((100 - clamped) / 100) * 0.15;
}
/** Get the local camera stream for self-view display. */
+2
View File
@@ -332,6 +332,8 @@ authStore.subscribe((state) => {
dispatcherCleanup?.();
dispatcherCleanup = null;
ws.disconnect();
lastConnectToken = "";
lastConnectHost = "";
// Clear stored credential on logout
const host = api.getConfig().host;
if (host) {
+21 -2
View File
@@ -496,8 +496,27 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
});
unsubscribers.push(() => clearOnRemoteVideo());
// Subscribe to voice store for camera state changes
unsubscribers.push(voiceStore.subscribe(() => videoModeCtrl?.checkVideoMode()));
// Subscribe to voice store for camera state changes only (not speaking ticks)
let prevLocalCamera = voiceStore.getState().localCamera;
let prevCameraSignature = "";
unsubscribers.push(voiceStore.subscribe((state) => {
// Build a lightweight signature of camera-relevant state
let sig = state.localCamera ? "1" : "0";
const channelId = state.currentChannelId;
if (channelId !== null) {
const users = state.voiceUsers.get(channelId);
if (users) {
for (const [uid, u] of users) {
if (u.camera) sig += `:${uid}`;
}
}
}
if (sig !== prevCameraSignature || state.localCamera !== prevLocalCamera) {
prevCameraSignature = sig;
prevLocalCamera = state.localCamera;
videoModeCtrl?.checkVideoMode();
}
}));
// Auto-update notifier — checks server for newer client version
if (apiConfig.host) {
@@ -46,6 +46,8 @@ export function createVideoModeController(
): VideoModeController {
const { slots, videoGrid, getCurrentUserId } = opts;
let videoMode = false;
/** Track whether we've already added the local self-view tile. */
let localTileAdded = false;
function showVideoGrid(): void {
if (videoMode) return;
@@ -94,20 +96,26 @@ export function createVideoModeController(
showChat();
}
// Manage local self-view tile
// Manage local self-view tile — only add once, skip if already showing
const currentUserId = getCurrentUserId();
if (voice.localCamera) {
const localStream = getLocalCameraStream();
if (localStream !== null) {
const me = channelUsers.get(currentUserId);
videoGrid.addStream(
currentUserId,
me?.username ? `${me.username} (You)` : "You",
localStream,
);
if (!localTileAdded) {
const localStream = getLocalCameraStream();
if (localStream !== null) {
const me = channelUsers.get(currentUserId);
videoGrid.addStream(
currentUserId,
me?.username ? `${me.username} (You)` : "You",
localStream,
);
localTileAdded = true;
}
}
} else {
videoGrid.removeStream(currentUserId);
if (localTileAdded) {
videoGrid.removeStream(currentUserId);
localTileAdded = false;
}
}
// Remove remote video tiles for users who turned off their camera
@@ -126,6 +134,7 @@ export function createVideoModeController(
function destroy(): void {
if (videoMode) showChat();
localTileAdded = false;
}
return {
+9
View File
@@ -3,6 +3,7 @@ package config
import (
"fmt"
"log/slog"
"os"
"strings"
@@ -207,6 +208,14 @@ func Load(cfgPath string) (*Config, error) {
// the YAML section is present but fields are commented out / omitted).
applyVoiceDefaults(&cfg.Voice)
// Warn if using default dev credentials — these are public and insecure.
if cfg.Voice.LiveKitAPISecret == "owncord-dev-secret-key-min-32chars" {
slog.Warn("using default LiveKit API secret — change voice.livekit_api_secret in config.yaml for production")
}
if cfg.Voice.LiveKitAPIKey == "devkey" {
slog.Warn("using default LiveKit API key — change voice.livekit_api_key in config.yaml for production")
}
return &cfg, nil
}
+23
View File
@@ -108,6 +108,13 @@ func NewTestClientWithTokenHash(hub *Hub, user *db.User, tokenHash string, chann
}
}
// getChannelID returns the currently focused channel ID under mu.
func (c *Client) getChannelID() int64 {
c.mu.Lock()
defer c.mu.Unlock()
return c.channelID
}
// getVoiceChID returns the voice channel ID under voiceMu.
func (c *Client) getVoiceChID() int64 {
c.voiceMu.Lock()
@@ -146,6 +153,22 @@ func (c *Client) sendMsg(msg []byte) {
}
}
// trySendMsg queues a message and returns true if it was accepted, false if
// the buffer is full or the channel is closed.
func (c *Client) trySendMsg(msg []byte) bool {
c.mu.Lock()
defer c.mu.Unlock()
if c.sendClosed {
return false
}
select {
case c.send <- msg:
return true
default:
return false
}
}
// closeSend marks the send channel closed and closes it exactly once.
// Safe to call from any goroutine.
func (c *Client) closeSend() {
+2 -5
View File
@@ -483,13 +483,10 @@ func (h *Hub) broadcastExclude(channelID, excludeUserID int64, msg []byte) {
if uid == excludeUserID {
continue
}
if channelID != 0 && c.channelID != channelID {
if channelID != 0 && c.getChannelID() != channelID {
continue
}
select {
case c.send <- msg:
default:
}
c.sendMsg(msg)
}
}
+4 -15
View File
@@ -258,13 +258,7 @@ func (h *Hub) SendToUser(userID int64, msg []byte) bool {
if !ok {
return false
}
select {
case c.send <- msg:
return true
default:
// send buffer full — drop rather than block.
return false
}
return c.trySendMsg(msg)
}
// ClientCount returns the number of currently registered clients (test helper).
@@ -295,17 +289,12 @@ func (h *Hub) deliverBroadcast(bm broadcastMsg) {
skipped := 0
for _, c := range h.clients {
// channelID == 0 → broadcast to everyone.
if bm.channelID != 0 && c.channelID != bm.channelID && c.getVoiceChID() != bm.channelID {
if bm.channelID != 0 && c.getChannelID() != bm.channelID && c.getVoiceChID() != bm.channelID {
skipped++
continue
}
select {
case c.send <- bm.msg:
delivered++
default:
slog.Warn("broadcast dropped: client send buffer full",
"user_id", c.userID, "channel_id", bm.channelID)
}
c.sendMsg(bm.msg)
delivered++
}
if bm.channelID != 0 {
slog.Debug("hub: channel broadcast",
+1 -1
View File
@@ -19,7 +19,7 @@ import (
)
// tokenTTL is the validity duration for generated LiveKit access tokens.
const tokenTTL = 24 * time.Hour
const tokenTTL = 4 * time.Hour
// LiveKitClient provides token generation and room management via
// the LiveKit server SDK.
+1 -1
View File
@@ -64,7 +64,7 @@ logging:
if err := os.MkdirAll(p.dataDir, 0o755); err != nil {
return "", fmt.Errorf("creating data dir: %w", err)
}
if err := os.WriteFile(cfgPath, []byte(content), 0o644); err != nil {
if err := os.WriteFile(cfgPath, []byte(content), 0o600); err != nil {
return "", fmt.Errorf("writing livekit config: %w", err)
}
+5
View File
@@ -96,6 +96,11 @@ func (h *Hub) handleVoiceJoin(c *Client, payload json.RawMessage) {
// Generate LiveKit token if LiveKit client is available.
if h.livekit != nil {
if c.user == nil {
slog.Error("handleVoiceJoin: nil user on client", "user_id", c.userID)
c.sendMsg(buildErrorMsg("INTERNAL", "not authenticated"))
return
}
canPublish := true
canSubscribe := true
token, tokenErr := h.livekit.GenerateToken(c.userID, c.user.Username, channelID, canPublish, canSubscribe)