- I-1: Add key holder election in Hub (lowest userID per channel); reject
non-key-holder voice_e2ee_offer with NOT_KEY_HOLDER error
- I-2: Accept raw (unpadded) base64 in E2EE announce/offer handlers via
decodeBase64Loose fallback
- I-6: Copy E2EE public key value while h.mu.RLock is held in getClientE2EEPubKey
- I-7: Lower loginRateLimitPerMinute from 60 to 5
- C-1: TOCTOU fix — target channel check held under same lock as client lookup
- C-2: Include is_key_holder bool in voice_token payload so client knows
whether to initiate key distribution
- M-5/M-6: Add ErrCodeBadPayload/ErrCodeNotKeyHolder error constants
- Fix pre-existing api build errors: block_handler.go getUserFromContext,
router.go RequirePermission arg count
- Add user_blocks table to all test DB schemas (ws, api DM)
- Add voice_e2ee_test.go and constants_test.go covering all fixes
Periodic key rotation:
- Key holder rotates room key every 5 minutes for forward secrecy,
independent of participant changes. Timer managed by key holder only.
Offer retry mechanism:
- Non-key-holders re-announce their public key after 10s timeout and
wait 5s more before giving up. Covers lost offers from target
disconnect during async key wrapping.
- Key holder now re-sends room key offer on duplicate announces (peer
may be re-requesting after a missed offer), instead of ignoring them.
Key fingerprint verification:
- New computeKeyFingerprint() in e2eeCrypto.ts — SHA-256 hash of raw
public key formatted as "AB12 CD34 ..." for out-of-band verification.
Can be displayed in UI for MITM detection.
Server hardening:
- Public key size limit tightened from 256 to 128 bytes (P-256
uncompressed = 65 bytes = ~88 base64 chars).
Client hardening:
- WebCrypto availability check at module load — throws descriptive
error if crypto.subtle is unavailable (non-HTTPS context).
- base64ToUint8() now wraps atob() in try-catch with clear error message.
https://claude.ai/code/session_01KKo3RwjdmcNzkgXNfUkgNT
Critical fixes:
- C1: Key holder election now uses lowest-user-ID from voiceStore instead
of "am I first in peerPublicKeys" heuristic, preventing simultaneous
join race where both participants generate conflicting room keys
- C2: TOCTOU race in handleVoiceE2EEOffer — target channel check now
happens inside h.mu.RLock() section (atomically with client lookup)
- C3: Server now validates base64 encoding for public_key, encrypted_key,
and iv before relaying, preventing client-side DoS via malformed payloads
High fixes:
- H1: ECDH keypair regenerated on reconnect with fresh announce, so
stale keys don't persist and key rotation during disconnect is handled
- H2: E2EE epoch counter prevents stale offers from overwriting a
rotated room key (handleE2EEOffer discards if epoch changed during unwrap)
- H3: After key rotation, re-check for peers that arrived during the async
wrapping loop and send them the new key too
- H4: _ecdhKeyPair and _roomKey captured in local vars before async
operations to prevent null dereference if clearE2EEState runs concurrently
Medium fixes:
- M1: User notified via onErrorCallback when E2EE key exchange times out
- M2: Timeout timer properly cleared to prevent leak and unhandled rejection
- M3: Duplicate announces deduplicated — same key ignored, changed key logged
https://claude.ai/code/session_01KKo3RwjdmcNzkgXNfUkgNT
- Use deterministic key holder election (lowest user_id) instead of
Map insertion order which is not guaranteed to match join order
- Use parseUserId() instead of raw parseInt() for LiveKit identity parsing
- Add concurrent key rotation guard (_rotatingKey flag) to prevent
races when multiple participants leave in rapid succession
- Queue voice_e2ee_announce messages that arrive before ECDH keypair
is ready; drain after keypair generation in connectAndSetup
- Propagate decryption failures to roomKeyResolver so connectAndSetup
unblocks with an error instead of hanging
- Reject (not resolve) roomKeyResolver on voice leave for proper cleanup
- Convert dynamic await import("@lib/e2eeCrypto") to static imports
- Add VOICE_E2EE_ANNOUNCE/OFFER to protocolTypes.ts enum constants
- Use typed S.VOICE_E2EE_* constants in dispatcher instead of string casts
- Add payload size limits for encrypted_key (1024) and iv (128) on server
https://claude.ai/code/session_01KKo3RwjdmcNzkgXNfUkgNT
Replace server-generated symmetric keys with client-side ECDH P-256 key
exchange. The server now only relays opaque public keys and encrypted
room key blobs — it never sees the actual room encryption key.
Protocol:
- voice_e2ee_announce: clients broadcast ECDH public keys
- voice_e2ee_offer: key holder wraps room key for each peer via ECDH+HKDF+AES-GCM
- Key rotation on participant leave (forward secrecy)
Server changes:
- Remove VoiceE2EEKeys (server-side key generation)
- Add relay handlers for announce/offer messages
- Store per-client ECDH public keys on Client struct
- Send existing public keys to new joiners during voice state sync
Client changes:
- New e2eeCrypto.ts: ECDH P-256, HKDF-SHA256, AES-256-GCM key wrapping
- LiveKitSession generates keypair on join, manages key holder election
- Key holder generates room key and wraps for each peer
- Non-holders wait for offer before connecting to LiveKit
- Room key rotated when any participant leaves
https://claude.ai/code/session_01KKo3RwjdmcNzkgXNfUkgNT
Restores dangerous-settings and allowSelfSigned which are required for
self-hosted servers with self-signed certificates. Makes HealthResponse.version
optional to match server-side removal, and updates router tests to assert
version is correctly omitted from unauthenticated endpoints.
https://claude.ai/code/session_01KKo3RwjdmcNzkgXNfUkgNT
Addresses 14 findings from the security audit across all severity levels:
CRITICAL:
- C-1: Add user blocking system (migration, DB queries, REST API, WS DM
send check) to prevent harassment via unconsented DMs
- C-2: Remove server version from unauthenticated /health and /info endpoints
to prevent fingerprinting
HIGH:
- H-1: Remove dangerous-settings feature from tauri-plugin-http
- H-3: Default allowSelfSigned to false in API client (was hardcoded true)
- H-4: Cap invite expiration to 30 days (720 hours)
- H-5: Add 256KB message size limit to LiveKit WS proxy (prevents OOM)
- H-6: Cap concurrent sessions to 25 per user (evicts oldest on overflow)
- H-8: Restrict /diagnostics/connectivity to ADMINISTRATOR role
MEDIUM:
- M-2: Deny access to legacy NULL-uploader unlinked attachments
- M-4: Log warnings on TOTP plaintext decryption fallback paths
- M-8: Remove acceptInvalidCerts from OG preview fetches
- M-10: Expand file upload blocklist (Java .class, OLE2, WASM, .lnk)
- M-12: Add LIMIT to ListInvites (200) and ListMembers (1000)
- M-14: Add CHECK constraint trigger on channels.type (text/voice/dm)
https://claude.ai/code/session_01KKo3RwjdmcNzkgXNfUkgNT
Server generates a per-channel 256-bit symmetric key (crypto/rand) when
the first participant joins voice. The key is distributed to all
participants via the voice_token WS message (already TLS-encrypted) and
cleared when the channel empties for forward secrecy per session.
Client configures LiveKit Room with ExternalE2EEKeyProvider and an
SFrame e2ee-worker. All audio/video frames are encrypted client-side
before reaching the SFU — the server never sees plaintext media.
Changes:
- Server: new VoiceE2EEKeys store, e2ee_key in voice_token payload
- Client: E2EE Room options, key provider wiring for connect/reconnect
- CSP: added worker-src 'self' blob: for the E2EE Web Worker
https://claude.ai/code/session_01KKo3RwjdmcNzkgXNfUkgNT
- Restrict PTT key capture to non-text keys only (function, navigation, mouse buttons) via allowlist (BUG-136)
- Gate DevTools button and F12/Ctrl+Shift+I shortcut behind import.meta.env.DEV
- Disable devtools Tauri feature in production (Cargo.toml default feature removed)
- Remove overly broad http:default capability, replace with scoped http:allow-fetch
- Set withGlobalTauri to false to avoid global __TAURI__ surface exposure
- Fix reconnect race: add abort checks after room creation, URL resolve, and connect (BUG-070)
- Fix ws.ts reconnect guard: bail out safely when config is null after disconnect
- Fix DM broadcast double-send and add monotonic seq + replay buffer support via sendSequencedToUsers
- Add seqMu mutex to serialize seq assignment across broadcastDM and deliverBroadcast paths
- Fix handleFreshConnect to unregister client and close connection on buildReady failure
- Add tests for PTT allowlist, ws reconnect config-null guard, livekit abort-after-connect, and DM sequencing
- Fix typo in setup-buildx-action pin (d8db...→d36ec...) that caused CI to fail
- Replace stale TenorGif type with GifResult in gif-picker.test.ts lines 531-532
- deployment.md: new Docker section with quick-start, config.yaml notes,
data persistence, upgrade, and LiveKit reference
- livekit-setup.md: new Docker section with .env / livekit.yaml setup,
node_ip explanation, and firewall table; companion process section
retitled for clarity
- docker-compose.yml: owncord + livekit/livekit-server on shared network
- Secrets (API key/secret) injected via .env → OWNCORD_VOICE_* env vars
- livekit.yaml.example: template config with port ranges and node_ip guidance
- .env.example: secret template with min-length reminder for API secret
- .gitignore: add .env / Server/.env to prevent accidental secret commits
Users: cp .env.example .env && cp livekit.yaml.example livekit.yaml,
fill in values, then docker compose up -d
Client:
- Replace Win32 Credential Manager with cross-platform keyring crate
(Windows Credential Manager / Linux Secret Service / macOS Keychain)
- Add Linux PTT support via device_query crate with VK-code-compatible
mapping; thread-local DeviceState avoids repeated /dev/input/ opens
- Add AppImage + deb bundle targets to tauri.conf.json with Linux
metadata and deb runtime dependencies
Cargo.toml:
- Add keyring = "3" (all platforms)
- Add device_query = "2" (Linux only, cfg guard)
- Remove Win32_Security_Credentials feature (no longer needed)
CI/CD:
- Add ubuntu-22.04 and ubuntu-22.04-arm to tauri-build matrix
- Fix Linux deps step condition: startsWith(matrix.os, 'ubuntu')
- Add server Docker build verification job (build-only, no push)
- Add release-client-linux (x86_64) and release-client-linux-arm64
jobs producing AppImage + deb artifacts
- Add release-server-docker job pushing to ghcr.io on version tags
- Update publish job to include all Linux and ARM64 artifacts
Server:
- Add multi-stage Dockerfile (golang:1.25-bookworm → distroless/static)
- Non-root user (uid 65532), /app/data volume, port 8443 exposed
- Add .dockerignore excluding binaries, data, and local config
Tenor shuts down June 30, 2026. Klipy is a drop-in replacement built
by the ex-Tenor team, free for production use.
- Replace tenor.ts with gifProvider.ts (api.klipy.com/v2)
- CDN allowlist updated to *.klipy.com (static.klipy.com is the real CDN)
- Add Klipy watermark logo to sent GIFs in chat (bottom-left)
- Update attribution text to "Powered by Klipy"
- Wire VITE_KLIPY_API_KEY secret into release workflow
- Add .env to .gitignore to protect local API key
- updater.go: DownloadAndVerify now uses parseChecksumFileAny with
checksumEntryNamesForGOOS so the linux/ path prefix produced by the
release workflow's sha256sum is found correctly (fixes
TestDownloadAndVerify_Success on ubuntu-latest)
- video-grid.test.ts: add setScreenshareAudioVolume to the
@lib/livekitSession mock so vitest does not throw on the export
that VideoGrid.ts imports
- ci.yml: add top-level `permissions: contents: read` to restrict
GITHUB_TOKEN to minimum required (fixes 3 missing-workflow-permissions alerts)
- claude-code-review.yml: remove unsafe `ref: pull_request.head.sha`
checkout in pull_request_target workflow and pin checkout to SHA
(fixes untrusted-checkout/high alert)
- tenor.ts: add codeql suppression comment for hard-coded-credentials;
the fallback key is Google's public anonymous demo key, not a secret
- Consolidate ws-state and cert-tofu emit calls in ws_proxy.rs into
private helper functions (emit_ws_state, emit_cert_tofu). One call
site per event name prevents tauri-typegen 0.5.0 from generating
duplicate event listener functions.
- Add CI fixup step that injects 'export type Value = unknown' into
generated types.ts — tauri-typegen cannot map serde_json::Value to
a TypeScript type, so the generated file references an undefined type.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Update dtolnay/rust-toolchain from stale SHA to current stable HEAD
- Add -timeout 20m to go test -race to prevent false timeouts on Windows
CI runners where the race detector adds significant overhead across the
large api test suite (was hitting the default 10m limit)
- release.yml: integrate signing/manifest/changelog steps with new
multi-platform artifact layout (windows/ + linux/ dirs)
- updater.go: combine Linux tar.gz support with existing signature
verification; merge platform-aware asset matching into switch
- updater_test.go: keep PR Linux tests + dev signing/manifest tests
- G204 in proc_spawner_nix.go and proc_spawner_win.go: exePath is the
server's own validated binary path, not arbitrary user input
- G302 in updater.go: 0o755 is required for the extracted Linux binary
to be executable
- Add validateAvatarURL helper enforcing https:// scheme, non-empty host, and 512-char max length
- Add rate limiting (10/min) to PATCH /api/v1/users/me profile update endpoint
- Guard avatar rendering in DmSidebar, DmProfileSidebar, and UserProfilePopup with isSafeUrl check to prevent unsafe URL injection in the UI