mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user