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
+2 -1
View File
@@ -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 (
+2 -1
View File
@@ -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,
+18 -20
View File
@@ -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,
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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{
+2 -1
View File
@@ -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
View File
@@ -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)
}
+2 -1
View File
@@ -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
View File
@@ -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",
+64 -3
View File
@@ -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 (
+48 -4
View File
@@ -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))
+83
View File
@@ -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())
}
})
}
}
+2 -1
View File
@@ -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,
+23 -5
View File
@@ -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
+85 -1
View File
@@ -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
View File
@@ -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 {
+1
View File
@@ -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
+26 -8
View File
@@ -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
View File
@@ -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,
}
}
+4
View File
@@ -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.
+6 -3
View File
@@ -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;
+1
View File
@@ -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
+17
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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).
+7 -1
View File
@@ -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) {
+5
View File
@@ -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
+4 -3
View File
@@ -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.
+92
View File
@@ -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)
}
}
+5 -1
View File
@@ -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
View File
@@ -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.
+2 -1
View File
@@ -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
View File
@@ -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,
},
})
}
+63 -1
View File
@@ -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
View File
@@ -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
}
+142
View File
@@ -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)
}
}
}
+5 -4
View File
@@ -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))
}
}