feat(e2ee): F3 identity/TOFU + W2-4/W3-3 hardening — checkpoint before F3 UI

WIP save point. Server + W2-4/W3-3 complete and gate-green; F3 voice E2EE
identity keys + TOFU implemented and MITM-verified-closed; the F3 voice-panel
UI (safety-number display, verified/mismatch badge, re-pin modal) is still TODO.

- W2-4 attachment link (coverage confirmed); W3-3a XFF CIDR pre-parse;
  W3-3b update-binary TOCTOU (single-handle verify + O_EXCL staging)
- F3 server: migration 017 identity_public_key, PATCH /users/me persist,
  ready/member_join/user_update carry key, signed voice_e2ee_announce
- F3 client: ECDSA identity keypair (keyring + pin store), publish wired into
  ready, verifyPeerAnnounce pin-before-legacy, rePinPeerIdentity recovery
- Gates: server full CI mirror green (-race/-deadlock/lint/4 build tags);
  client typecheck/lint/format + 3337 vitest green. Rust CI-verify only.

Next: build F3 voice-panel UI, then adversarial review, then finalize commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
J3vb
2026-07-23 21:07:09 +02:00
co-authored by Claude Opus 4.8
parent 865dffc771
commit 81a0b63e65
61 changed files with 2814 additions and 343 deletions
+17 -3
View File
@@ -1,8 +1,22 @@
# Plan: Remediate security-hardening review regressions
**Status:** mostly landed — verified 2026-07-23 (deletion audit): every item
except **W2-4** and **W3-3** has been implemented or superseded. This doc is
the tracker of record for those two; close it when they land.
**Status:** COMPLETE — verified 2026-07-23 (branch `feat/e2ee-identity-tofu`): every item
has been implemented or superseded. W2-4 and both halves of W3-3 are the last to land.
**W2-4:** DONE 2026-07-23 — `Server/db/attachment_queries.go:111`
`LinkAttachmentsToMessage` links atomically and skips (not fails) already-linked,
non-owned, and missing ids; legacy `uploader_id IS NULL` rows claimable. Locked by
`TestLinkAttachmentsToMessage_SkipsAlreadyLinked` and
`TestLinkAttachmentsToMessage_OwnershipGuard` (`Server/db/attachment_queries_test.go`).
**W3-3:** DONE 2026-07-23 — two halves. (a) **XFF CIDR pre-parse:** `trustedCIDRs` parsed
once at middleware construction into `[]*net.IPNet`; `clientIPWithProxies` takes the parsed
form, `isTrustedProxy` deleted (callers use `ipInNets`), invalid entries warn at startup not
per request. Locked by `TestRateLimitMiddleware_InvalidCIDRWarnsAtConstructionNotPerRequest`;
leftmost-valid XFF fallback + `AdminIPRestrict` fail-closed unchanged. (b) **Update TOCTOU:**
`DownloadAndVerify` returns the trusted hash; new `updater.OpenVerifiedBinary`/`Commit`
verify through one open handle and confirm via `os.SameFile` that the renamed file is the one
verified; O_EXCL 0600 staging refuses pre-planted paths. Locked by
`TestOpenVerifiedBinary_SwapAfterVerifyDetectedAtCommit` + `TestDownloadFile_RefusesPreExistingDest`
(Linux fd/tarball path is CI-verified). Server `-race` + `-tags deadlock` green.
**Owner:** TBD
**Tracks:** code review of branch `fix/security-hardening-review` (2026-07-17)
**Estimated effort:** 24 focused days
@@ -12,7 +12,7 @@ This is a continuation/handoff doc: what is done, what remains, and how to resum
|---|-----|---------|--------|
| F1 | MED | Login lockout keyed on un-canonicalized username (vs `COLLATE NOCASE`) | ✅ done, committed `7145f76` |
| F2 | MED | Unsynchronized concurrent wazero module invocation (data race) | ✅ done, committed `71b5f13` |
| F3 | MED | Voice E2EE trusts server-relayed ECDH keys (server MITM) | **TODO — designed, not started** |
| F3 | MED | Voice E2EE trusts server-relayed ECDH keys (server MITM) | **implemented (branch `feat/e2ee-identity-tofu`)** — MITM closed for published+pinned peers; UI surfacing is follow-up (see below) |
| F4 | MED | HTTP TOFU proxy accepts any cert on first use (credential exposure) | ✅ done, committed `f22985a` |
| F5 | LOW | Voice perms use stale connect-time role snapshot | ✅ done, committed `260d038` |
| F6 | LOW | Lost cache invalidation in `PermissionService.getOrPopulate` | ✅ done, committed `e6a0d87` |
@@ -26,7 +26,8 @@ This is a continuation/handoff doc: what is done, what remains, and how to resum
`cd Client/tauri-client/src-tauri && cargo clippy -- -D warnings` (or push and
let CI do it). Pure `tofu` logic has `#[cfg(test)]` unit tests; the frontend is
covered by the 3311-green unit suite.
2. **Then F3** — the only remaining finding (below). (F6 landed 2026-07-23 as
2. ~~**Then F3**~~**DONE 2026-07-23** on branch `feat/e2ee-identity-tofu` (see the
"F3 status 2026-07-23" block directly below). (F6 landed 2026-07-23 as
`e6a0d87`, split out from the D13 permission-consolidation commits that
followed it on this branch.)
@@ -39,6 +40,46 @@ perms served up to `permCacheTTL`). Fix: a `gen uint64` counter bumped by every
cache if it changed. Test `TestGetOrPopulate_InvalidationDuringPopulateNotLost`
locks it. Verified `-race` + `-tags deadlock` green.
## F3 status 2026-07-23 (branch `feat/e2ee-identity-tofu`)
**Implemented, test-first, MITM path verified closed by a 3-lens adversarial panel + a
dedicated re-verification pass.** The original implementation shipped the crypto but had a
dead publish path (the feature was inert); that and three related defects were caught by
review and fixed. What is done:
- **Server:** migration `017_user_identity_key.sql` (`users.identity_public_key`);
`UpdateUserIdentityKey` + column in user/`ListMembers` SELECTs; `PATCH /users/me`
accepts+persists the key (via `UserService.UpdateIdentityKey`, audited); key carried in
`ready`/`member_join`/`user_update`; `voice_e2ee_announce` gains an optional `signature`
validated + stored + relayed (incl. the late-joiner replay). Legacy unsigned announces
still accepted (client enforces fail-closed). `make sqlc-verify`/`protocol-verify` green;
server `-race` + `-tags deadlock` green.
- **Client:** ECDSA P-256 identity keypair (OS keyring via new Rust
`save/load/delete_identity_key` + pin store `identity_pins.json`); ephemeral announces
signed at all sites; **publish wired into the `ready` flow** (dispatcher publishes the key
once, with username, when the server copy is absent/stale); `verifyPeerAnnounce` resolves
the **pin before** the legacy shortcut (a stripping server can't downgrade a pinned peer);
`rePinPeerIdentity` for key-rotation recovery. Full client suite 3337 green;
typecheck/lint/format clean. (Rust halves compile-checked only — no local MSVC; **CI
must verify `cargo`**.)
**Verified closed:** a malicious/stripping server can no longer silently MITM a peer whose
identity key is published and locally pinned — an ephemeral-key swap fails ECDSA
verification and the room key is never wrapped for the attacker.
**Follow-up (not MITM holes — deferred, none block the crypto):**
1. **Surface the safety number in the voice panel.** `safetyNumber`/`peerVerifications` are
computed and stored but **no component renders them**, so the out-of-band check that
detects the inherent TOFU *first-contact* window is not user-reachable yet.
2. **Wire the verified/unverified/mismatch badge + a re-pin affordance.** `rePinPeerIdentity`
exists but no UI calls it — a legitimately rotated peer key currently blocks voice with no
in-app recovery (mirror `main.ts`'s `createCertMismatchModal onAccept` flow).
3. `getIdentityPin` **fail-opens** on a transient local keyring/store read error (one announce
falls through to legacy). Not server-controllable; consider fail-closed when a pin *may*
exist.
4. Fast-join timing: a peer joining voice before peers process its `user_update` is seen as
legacy for that announce — degrades to *unverified*, never wrongly-*verified*.
## F3 — Voice E2EE identity keys + TOFU (the remaining work)
**Problem.** `voice_e2ee_announce` carries only `{public_key}`; the server
+42 -9
View File
@@ -246,7 +246,7 @@ Sent once after `auth_ok` (fresh connection or replay fallback).
**dm_channels[]:** `channel_id`, `recipient` (user object with `id`, `username`, `avatar`, `status`), `last_message_id`, `last_message`, `last_message_at`, `unread_count`
**members[]:** All registered users with `id`, `username`, `avatar`, `role` (lowercase name), `status`
**members[]:** All registered users with `id`, `username`, `avatar`, `role` (lowercase name), `status`, `identity_public_key` (base64 long-term E2EE identity key, omitted when the user has not published one — see voice E2EE TOFU)
**voice_states[]:** All users currently in any voice channel: `channel_id`, `user_id`, `muted`, `deafened`
@@ -531,12 +531,16 @@ Sent when a user first connects (fresh connection, not reconnect replay).
"id": 5,
"username": "newuser",
"avatar": null,
"role": "member"
"role": "member",
"identity_public_key": "base64-identity-pubkey"
}
}
}
```
`identity_public_key` is the user's long-term E2EE identity public key (see
voice E2EE TOFU); omitted when the user has not published one.
### member_update (Server -> Client, broadcast)
Triggered when an admin changes a user's role.
@@ -565,7 +569,7 @@ Triggered when an admin changes a user's role.
### user_update (Server -> Client, broadcast)
Broadcast when a user changes their own profile via `PATCH /api/v1/users/me`
(username and/or avatar).
(username, avatar and/or identity key).
```json
{
@@ -574,12 +578,15 @@ Broadcast when a user changes their own profile via `PATCH /api/v1/users/me`
"payload": {
"user_id": 5,
"username": "newname",
"avatar": "uuid.png"
"avatar": "uuid.png",
"identity_public_key": "base64-identity-pubkey"
}
}
```
`avatar` may be `null` when unset.
`avatar` may be `null` when unset. `identity_public_key` carries the user's
current long-term E2EE identity key and is omitted when none is published;
peers that pinned a different key must surface a TOFU mismatch.
### member_leave (reserved)
@@ -739,24 +746,50 @@ the room key so departed members cannot decrypt future media.
Both E2EE message types are rate limited at 5 per second per user. Key
material must be standard-alphabet base64 (padded or unpadded).
**Identity keys + TOFU:** each client holds a long-term ECDSA P-256 identity
keypair, published via `PATCH /api/v1/users/me` (`identity_public_key`) and
distributed in the `ready` / `member_join` / `user_update` member payloads.
Peers pin the key on first sight (trust-on-first-use) and verify each
announce's `signature` against the pin, so a malicious server cannot swap
`user_id ↔ ephemeral pubkey` after first contact. A later key change is
surfaced to the user as a TOFU mismatch.
### voice_e2ee_announce (Client -> Server)
Announce this participant's ECDH public key to the channel.
Announce this participant's ephemeral ECDH public key to the channel.
`signature` is the ECDSA P-256 signature by the sender's long-term identity
key over `"owncord-voice-e2ee-announce-v1" ‖ userId ‖ ephemeral-pubkey-raw`
(TOFU — see above). It is optional at the protocol level: legacy clients omit
it, and receiving clients enforce the fail-closed posture (peer has a
published identity key but the signature is missing/invalid → reject).
```json
{ "type": "voice_e2ee_announce", "payload": { "public_key": "base64-ecdh-pubkey" } }
{
"type": "voice_e2ee_announce",
"payload": {
"public_key": "base64-ecdh-pubkey",
"signature": "base64-ecdsa-signature"
}
}
```
The server validates `signature` like `public_key` (standard-alphabet base64,
max 128 chars) and stores it alongside the key, but never verifies it — only
clients hold the pinned identity keys.
### voice_e2ee_announce (Server -> Client, broadcast to voice channel)
Relayed to the other participants with the sender's user ID attached:
Relayed to the other participants with the sender's user ID attached. Also
replayed to late joiners from the stored key+signature. `signature` is
omitted when the announcing client did not send one:
```json
{
"type": "voice_e2ee_announce",
"payload": {
"user_id": 1,
"public_key": "base64-ecdh-pubkey"
"public_key": "base64-ecdh-pubkey",
"signature": "base64-ecdsa-signature"
}
}
```
+7 -1
View File
@@ -59,6 +59,7 @@ CREATE TABLE IF NOT EXISTS schema_versions (
| `014_events_table.sql` | Adds `events` — persistent broadcast log for reconnect cold-tier replay |
| `015_plugins.sql` | Adds `plugins` and `plugin_kv` for the WASM plugin runtime |
| `016_announcement_channel_type.sql` | Recreates the channel-type triggers to allow `announcement` |
| `017_user_identity_key.sql` | Adds `users.identity_public_key` (long-term E2EE identity key for voice TOFU) |
---
@@ -105,12 +106,17 @@ CREATE TABLE users (
last_seen TEXT,
banned INTEGER NOT NULL DEFAULT 0,
ban_reason TEXT,
ban_expires TEXT
ban_expires TEXT,
identity_public_key TEXT
);
```
Valid status values: `online`, `idle`, `dnd`, `offline`. All statuses are reset to `offline` on server startup.
`identity_public_key` (added in migration 017) is the user's long-term E2EE
identity public key (base64 ECDSA P-256) used for TOFU pinning of voice E2EE
announces; `NULL` = not published (legacy client).
---
### sessions