* docs(plans): phased remediation plan for the 2026-08-19 audit Executes the audit's §8 MUST-fix verdict and §9.1 fix order: one phase per finding group, statuses updated in place as phases land. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ * test(client): give the renderWindow-breaker test its own timeout (audit F-5) 30 synchronous 100-row jsdom rebuilds can exceed vitest's default 5s on a loaded runner; the test timed out once under CI-like load and passes in isolation, so it now carries an explicit 20s budget. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ * docs: fix the ten wrong reference-doc statements from audit 2026-08-19 (B-01..B-10) schema.md: migrations 030/031 documented, attachments ON DELETE SET NULL (matching 030's rebuild), index inventory rewritten from cumulative migration state, writer/reader pool split described, default-roles table made a consistent post-migration snapshot, dbgen preamble updated. protocol.md: DM chat events documented as sequenced/ring-buffered/replayable (they are), plugin_broadcast seq flipped to Yes, retry_after claim removed (no WS error carries it), the five enforced-but-documented-as-None rate limits added (channel_focus, mark_read, call_decline, chat_command, ping), E2EE announce/offer budgets corrected incl. the per-target inner cap, BAD_PAYLOAD and NOT_KEY_HOLDER added to the error table, ready voice_states/ roles field lists completed, member_join top-level status documented. api.md: diagnostics endpoint is ADMINISTRATOR-only (H-8) with a per-IP limiter and host:port livekit_url, error-code table now matches emitted codes (INTERNAL_ERROR, STORAGE_ERROR 507; oversize upload is 400), body-cap exemptions listed, identity_public_key documented on PATCH /users/me, plugin endpoints' plain-text errors + X-Plugin-Runtime header documented, /health 503 degraded state documented, metrics/LiveKit CIDR keys named, updates/apply restart-conflict 409s added. Also folds in the audit's D-04/D-05 comment and plan-header staleness fixes (buildReady comment, e2e spec-count comments, logctx stray word, three plan status headers). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ * fix(server): log the five silently-discarded persistence errors (audit F-3/F-4/D-16) Lockout Upsert/Delete/Cleanup failures (auth/ratelimit.go), the H-6 session-cap eviction failure in CreateSession (db/auth_queries.go), and the channel_focus read-state write failure (service/channel.go) all discarded their errors with no trace — a brute-force lockout could silently fail to survive a restart. In-memory behavior is unchanged (warn-and-continue); the lockout write paths are pinned by tests mirroring OC-0061's load-path test. The session-cap and read-state sites are log-only additions on seams the existing suites already exercise on the success path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ * fix(dm): blocking a user evicts them from the pair's live 1:1 DM voice call (audit F-1) The block gate ran only at voice_join and voluntary voice_token_refresh, so a blocked user already in the shared 1:1 DM call kept their session indefinitely — the same guard-asymmetry family as A-2026-08-03. handleBlockUser now severs the call through the dmVoiceEvictor capability handleCloseDM already exercises, using a new find-only FindDMChannelIDBetween lookup (sqlc-generated; mirrors GetOrCreateDMChannel's is_group=0 clause so group DM calls stay exempt, matching requireDMNotBlocked). Pinned by three handler tests: shared-DM eviction, no-DM no-op, group-only no-op. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ * fix(ws): close the role-reassignment/handshake race (audit F-2) A role reassignment landing mid-handshake was invisible for the socket's whole life: both handshake paths resolved permissions from the auth-time c.user snapshot, revokeUnreadableChannels early-returns for a user not yet in h.clients, and its Unsubscribe no-ops on the pubsub identity guard once a reconnect replaced the client. Three coordinated fixes: (1) refreshUserSnapshot re-reads the user row (and role name) in reconnectPrecheck and handleFreshConnect, fail-closed; (2) the resume-fallback path re-reads the role once more after registerNow and runs the revocation pass when it moved, so the reassignment-vs-registration orderings meet in the middle; (3) revokeUnreadableChannels re-resolves the live client immediately before acting, mirroring RefreshChannelVisibility. Pinned by four tests driving real WS handshakes through the existing race hooks plus a new pre-register/pre-act hook pair; ws suite green under the default and deadlock builds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ * refactor(client): remove the inert replay-dedup machinery (audit F-6) The server writes auth_ok before the replay burst, so replayDedup — created on socket-open and cleared when auth_ok is processed — could never be active for a real replayed frame, and the dispatcher's isReplaying() unread gates never fired. Their no-op behavior is the correct behavior (a buffer/db resume has no ready payload, so replayed frames must count as unread), so the machinery, the gates, and the misleading comments are removed rather than repaired. The pinning tests injected replay frames in an order a spec-compliant server never produces; they are replaced by a test pinning the real contract (frames after auth_ok are dispatched verbatim; duplicate handling belongs to the stores). Client suite green: 5036/5036. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ * docs(plans): mark remediation phases 1-6 done Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ * fix(ws): nolint the context-less revoke call golangci-lint flags revokeUnreadableChannels takes no context by design (admin HubBroadcaster interface); annotate the one call site inside a ctx-taking function, matching the RefreshChannelVisibility precedent. golangci-lint v2.11.3: 0 issues. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ --------- Co-authored-by: Claude <noreply@anthropic.com>
12 KiB
Security-Scan Remediation (Claude Security run 2026-07-22)
Status (verified 2026-08-04): Shipped — all 8 findings (F1–F8) closed. Of the four F3 follow-ups listed below, two have since shipped: the safety number is rendered (voice-roster shield badge title,
ChannelSidebar.ts:45-60) and the re-pin affordance exists (mismatch badge click → identity-mismatch modal →rePinPeerIdentity,ChannelSidebar.ts:84-135). Follow-up 3 (getIdentityPinfail-open on a transient keyring read error) closed 2026-08-05 (DC-08): the lookup is now tri-state (pinned/unpinned/unavailable,identity.tsgetIdentityPin) andverifyPeerAnnouncefails closed on "unavailable"; follow-up 4 is accepted behavior (degrades to unverified, never wrongly-verified). The scan artifact directoryCLAUDE-SECURITY-20260722-184557/referenced below is not part of this repository.
Scan: CLAUDE-SECURITY-20260722-184557/ at revision e983459 (branch main).
Findings: 8 — 4 MEDIUM (F1–F4), 4 LOW (F5–F8), all confidence medium, no HIGH.
Branch: fix/security-scan-2026-07-22.
This is a continuation/handoff doc: what is done, what remains, and how to resume.
Status at a glance
| # | Sev | Finding | Status |
|---|---|---|---|
| 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) | ✅ 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 |
| F7 | LOW | ReDoS regex on link-preview HTML | ✅ done, committed 6952202 |
| F8 | LOW | WS TOFU verifier accepts any cert on first use | ✅ done, committed f22985a (with F4) |
Resume checklist (do these first)
- Confirm the F4/F8 Rust compiles. It could not be built in the dev sandbox
(no local Tauri builds per
Client/tauri-client/CLAUDE.md). Runcd Client/tauri-client/src-tauri && cargo clippy -- -D warnings(or push and let CI do it). Puretofulogic has#[cfg(test)]unit tests; the frontend is covered by the 3311-green unit suite. Then F3— DONE 2026-07-23 on branchfeat/e2ee-identity-tofu(see the "F3 status 2026-07-23" block directly below). (F6 landed 2026-07-23 ase6a0d87, split out from the D13 permission-consolidation commits that followed it on this branch.)
F6 detail (done, committed e6a0d87)
getOrPopulate read the DB then cached the snapshot with no version guard, so a
concurrent InvalidateUser racing the populate was silently overwritten (stale
perms served up to permCacheTTL). Fix: a gen uint64 counter bumped by every
Invalidate*; getOrPopulate snapshots gen before its DB read and refuses to
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/ListMembersSELECTs;PATCH /users/meaccepts+persists the key (viaUserService.UpdateIdentityKey, audited); key carried inready/member_join/user_update;voice_e2ee_announcegains an optionalsignaturevalidated + stored + relayed (incl. the late-joiner replay). Legacy unsigned announces still accepted (client enforces fail-closed).make sqlc-verify/protocol-verifygreen; server-race+-tags deadlockgreen. - Client: ECDSA P-256 identity keypair (OS keyring via new Rust
save/load/delete_identity_key+ pin storeidentity_pins.json); ephemeral announces signed at all sites; publish wired into thereadyflow (dispatcher publishes the key once, with username, when the server copy is absent/stale);verifyPeerAnnounceresolves the pin before the legacy shortcut (a stripping server can't downgrade a pinned peer);rePinPeerIdentityfor key-rotation recovery. Full client suite 3337 green; typecheck/lint/format clean. (Rust halves compile-checked only — no local MSVC; CI must verifycargo.)
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):
- Surface the safety number in the voice panel.
safetyNumber/peerVerificationsare 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. - Wire the verified/unverified/mismatch badge + a re-pin affordance.
rePinPeerIdentityexists but no UI calls it — a legitimately rotated peer key currently blocks voice with no in-app recovery (mirrormain.ts'screateCertMismatchModal onAcceptflow). getIdentityPinfail-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.- Fast-join timing: a peer joining voice before peers process its
user_updateis 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
attaches user_id on broadcast (Server/ws/messages.go:136) and relays/caches
keys — so a malicious server swaps user_id ↔ ephemeral pubkey and MITMs the
SFrame room key. Nothing authenticates peer keys; computeKeyFingerprint
(e2eeCrypto.ts:63) exists but is never used.
Approach (approved: Signal-style TOFU). Additive — the existing ephemeral ECDH + HKDF + AES-GCM wrap is sound; add the missing authentication layer, do not rewrite the key exchange.
Trust anchor: TOFU. Each client holds a long-term identity keypair; peers pin each other's identity key on first sight and flag any later change. A malicious server can only MITM at first-ever contact (the accepted TOFU window), and the optional safety-number makes even that detectable.
What gets signed
WebCrypto ECDSA P-256 (same curve family as the existing ECDH; works in all
three webviews — Ed25519 is unreliable on WKWebView/WebKitGTK; zero new deps).
When announcing its ephemeral key E_pub, the client signs
"owncord-voice-e2ee-announce-v1" ‖ myUserId ‖ E_pub_raw with the identity
private key. Binding myUserId stops the server re-attributing a valid announce
to a different user. Receivers verify against the peer's pinned identity key.
Verify + TOFU-pin (receive path)
In handleE2EEAnnounce (livekitSession.ts ~1195, before the _peerPublicKeys
store at ~1198 and the holder's wrap at ~1207), and the queued-drain at ~852-857:
- Resolve the peer's identity key — first sight → take it from the member
payload and pin it (
identity_pins.json, key{host}:{userId}); subsequent → use the pin; delivered key differs → emitidentity-tofu, block/ warn until the user re-pins (copy of the TLS cert-mismatch flow). - Verify the announce signature against the pinned identity key. Invalid → reject (MITM), do not store/wrap.
Infrastructure (mirror existing patterns)
- Identity private key → OS keyring:
save/load/delete_identity_keyTauri commands mirroringsrc-tauri/src/credentials.rssave_credential, accountidentity:{host}; TS wrapper copiessrc/lib/credentials.ts. Never localStorage. - Peer pins → new
identity_pins.jsontauri-plugin-storefile +store/get_identity_pincommands, near-verbatim copy of thecerts.jsoncert-pin commands insrc-tauri/src/commands.rs. - Safety number: repoint
computeKeyFingerprintat the stable identity key; surface a per-peer/combined safety number in the voice panel (optional OOB verify).
Server (db-change + protocol-change workflows)
- Migration
Server/migrations/017_user_identity_key.sql:ALTER TABLE users ADD COLUMN identity_public_key TEXT;(mirrorstotp_secret). AddUpdateUserIdentityKeyquery; include the column in the user +ListMembersSELECTs;make sqlc-generate. One column, not a multi-device table (YAGNI). - Publish: extend the REST profile update (
Server/api/profile_handler.goupdateProfileRequest) to acceptidentity_public_key; client publishes once after first-login keygen. - Fetch: add
identity_public_keyto the member payload inready,member_join,user_update(Server/ws/messages.gomemberUserPayload,buildMemberJoin,userUpdatePayload). Peers pin on first sight — no new WS msg. - The one protocol change: add
signatureto thevoice_e2ee_announcepayload —docs/protocol-schema.json→make protocol-generate. Server validates size/base64 likepublic_key, and stores the signature alongside the key inSetE2EEPubKey(Server/ws/client.go:34,voice_e2ee.go) so the replay-to-late-joiners path (voice_join.go:217-218) doesn't drop it.
Client session (livekitSession.ts)
Sign the ephemeral announce at all three sites (~916, ~467, ~891); verify+pin on receive as above. Move the primary announce earlier (~876) so the added identity round-trip doesn't stack on the existing 10s non-holder stall.
Compatibility posture (transition)
Peer has published an identity key but the announce signature is missing/invalid → fail closed (reject). Peer has no identity key at all (legacy client) → accept but mark unverified in the UI, pin-pending. Avoids a hard cutover for alpha while closing the hole for upgraded clients.
Suggested PR split
- PR-a (server): identity-key column + publish/fetch +
voice_e2ee_announcesignature field +SetE2EEPubKeycarries the signature. - PR-b (client): keygen + keyring commands, sign/verify, TOFU pin store, safety-number UI, receive-path verification.
Verification (planned)
- vitest for
signEphemeralKey/verifyEphemeralKeySignatureand the TOFU pin (first-sight pins, changed key flags, invalid signature rejects); a "server substitutes a peer's ephemeral key → verify fails" test; keyring round-trip (Rust); manual two-client voice call confirming audio decrypts and safety numbers match;make protocol-verify+make sqlc-verify; full server-race/-tags deadlock; clientnpm test+ typecheck/lint/format;ci-check.
Notes carried from the build
- F4/F8 approach was simplified vs the original design: instead of a new
check_server_certpeek command, first-use is handled by reject-and-retry — the proxy captures the fingerprint, rejects (wsErr/ http502), and emitscert-tofu{first_use}; the connect page's existinggetHealthis the natural pre-flight. A globalcert-tofulistener (ws.startCertListener, registered at bootstrap inmain.ts) surfaces the confirm modal before any WS connect. - The scan report + machine-readable companion are in
CLAUDE-SECURITY-20260722-184557/.