diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index b5e8cfd4..f26cfba2 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -1,7 +1,7 @@ name: Claude Code Review on: - pull_request: + pull_request_target: types: [opened, synchronize, ready_for_review, reopened] # Optional: Only run on specific file changes # paths: @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - pull-requests: read + pull-requests: write issues: read id-token: write @@ -29,6 +29,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 with: + ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 1 - name: Run Claude Code Review diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 097ea06d..a721d7fb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -121,6 +121,14 @@ jobs: permissions: contents: write steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: Client/tauri-client/package-lock.json + - name: Download Windows client assets uses: actions/download-artifact@v4 with: @@ -139,10 +147,43 @@ jobs: name: server-linux path: linux + - name: Extract version from tag + shell: bash + run: | + VERSION="${GITHUB_REF_NAME#v}" + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + - name: Generate SHA256 checksums run: | find windows linux -type f -exec sha256sum {} \; > checksums.sha256 + - name: Generate server update manifest + shell: bash + run: | + SERVER_HASH=$(sha256sum windows/chatserver.exe | awk '{print $1}') + printf '{"version":"v%s","asset":"chatserver.exe","sha256":"%s"}' "$VERSION" "$SERVER_HASH" > windows/server-update-manifest.json + + - name: Sign server update assets + working-directory: Client/tauri-client + shell: bash + env: + SERVER_UPDATE_SIGNING_PRIVATE_KEY: ${{ secrets.SERVER_UPDATE_SIGNING_PRIVATE_KEY }} + SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + KEY_PATH=$(mktemp) + printf '%s' "$SERVER_UPDATE_SIGNING_PRIVATE_KEY" > "$KEY_PATH" + trap 'rm -f "$KEY_PATH"' EXIT + npm ci + npx tauri signer sign -k "$KEY_PATH" -p "$SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD" ../../windows/chatserver.exe + npx tauri signer sign -k "$KEY_PATH" -p "$SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD" ../../windows/server-update-manifest.json + + - name: Install root dependencies (changelogen) + run: npm ci + + - name: Generate changelog + shell: bash + run: npx changelogen --output CHANGELOG.md + - name: Create GitHub Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -150,5 +191,5 @@ jobs: mapfile -t assets < <(find windows linux -type f) assets+=(checksums.sha256) gh release create "${{ github.ref_name }}" \ - --generate-notes \ + --notes-file CHANGELOG.md \ "${assets[@]}" diff --git a/Client/tauri-client/src-tauri/tauri.conf.json b/Client/tauri-client/src-tauri/tauri.conf.json index 4b6dc257..7c1cfd51 100644 --- a/Client/tauri-client/src-tauri/tauri.conf.json +++ b/Client/tauri-client/src-tauri/tauri.conf.json @@ -19,7 +19,7 @@ "decorations": true, "resizable": true, "center": true, - "additionalBrowserArgs": "--autoplay-policy=no-user-gesture-required" + "additionalBrowserArgs": "--autoplay-policy=no-user-gesture-required --use-fake-ui-for-media-stream" } ], "withGlobalTauri": true, diff --git a/Client/tauri-client/src/components/DmProfileSidebar.ts b/Client/tauri-client/src/components/DmProfileSidebar.ts index a8f5e435..8db9fd18 100644 --- a/Client/tauri-client/src/components/DmProfileSidebar.ts +++ b/Client/tauri-client/src/components/DmProfileSidebar.ts @@ -13,6 +13,7 @@ import { createElement, appendChildren, setText } from "@lib/dom"; import type { MountableComponent } from "@lib/safe-render"; import type { UserStatus } from "@lib/types"; +import { isSafeUrl } from "./message-list/attachments"; // --------------------------------------------------------------------------- // Types @@ -114,7 +115,7 @@ export function createDmProfileSidebar( wrapper.style.position = "relative"; wrapper.style.flexShrink = "0"; - if (user.avatar !== null && user.avatar.length > 0) { + if (user.avatar !== null && user.avatar.length > 0 && isSafeUrl(user.avatar)) { wrapper.style.background = "transparent"; const img = createElement("img", { src: user.avatar, diff --git a/Client/tauri-client/src/components/DmSidebar.ts b/Client/tauri-client/src/components/DmSidebar.ts index 5beedaf5..2c25d267 100644 --- a/Client/tauri-client/src/components/DmSidebar.ts +++ b/Client/tauri-client/src/components/DmSidebar.ts @@ -11,6 +11,7 @@ import { createElement, setText, appendChildren } from "@lib/dom"; import { createIcon } from "@lib/icons"; import type { MountableComponent } from "@lib/safe-render"; +import { isSafeUrl } from "./message-list/attachments"; export interface DmConversation { readonly userId: number; @@ -59,7 +60,7 @@ function renderDmItem( const avatar = createElement("div", { class: "dm-avatar" }); avatar.style.background = avatarBg; - if (convo.avatar !== null) { + if (convo.avatar !== null && isSafeUrl(convo.avatar)) { const img = createElement("img", { src: convo.avatar, alt: convo.username, diff --git a/Client/tauri-client/src/components/UserProfilePopup.ts b/Client/tauri-client/src/components/UserProfilePopup.ts index 7c9944d0..6e635f2b 100644 --- a/Client/tauri-client/src/components/UserProfilePopup.ts +++ b/Client/tauri-client/src/components/UserProfilePopup.ts @@ -13,6 +13,7 @@ import { createElement, appendChildren } from "@lib/dom"; import { createIcon } from "@lib/icons"; import type { MountableComponent } from "@lib/safe-render"; import type { UserStatus } from "@lib/types"; +import { isSafeUrl } from "./message-list/attachments"; // --------------------------------------------------------------------------- // Types @@ -137,7 +138,7 @@ export function createUserProfilePopup( wrapper.style.background = "#4e5058"; const text = createElement("span", {}, "?"); wrapper.appendChild(text); - } else if (user.avatar !== null && user.avatar.length > 0) { + } else if (user.avatar !== null && user.avatar.length > 0 && isSafeUrl(user.avatar)) { const img = createElement("img", { src: user.avatar, alt: user.username, diff --git a/README.md b/README.md index 0e916b2b..eb3d1a09 100644 --- a/README.md +++ b/README.md @@ -306,13 +306,20 @@ Key settings: ## Auto-Updates The client checks for updates after connecting to the server. -Updates are Ed25519-signed and verified before install. +Client updates are Ed25519-signed and verified before install. +Server auto-updates use a separate minisign/Ed25519 signing key, verify `chatserver.exe.sig`, and require a signed `server-update-manifest.json` that binds the binary hash to the release version before apply. -To enable signed releases in CI, add these GitHub repository secrets: +For maintainers publishing signed releases from GitHub Actions, configure these repository secrets: -- `TAURI_SIGNING_PRIVATE_KEY` — Ed25519 private key - (via `npx tauri signer generate`) -- `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` — key password +- `TAURI_SIGNING_PRIVATE_KEY` — client updater private key + (via `npx tauri signer generate`) +- `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` — client updater key password +- `SERVER_UPDATE_SIGNING_PRIVATE_KEY` — server updater private key +- `SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD` — server updater key password + +These are secret names only. Do not commit private key material or passphrases to the repository. + +When rotating the server updater key, also update [Server/updater/server_update_public_key.txt](Server/updater/server_update_public_key.txt). For live deployments that rely on server auto-update continuity, treat key rotation as a staged rollover rather than a one-step secret swap. ## Documentation diff --git a/Server/admin/handlers_backup.go b/Server/admin/handlers_backup.go index 86f0d78d..6c4727a7 100644 --- a/Server/admin/handlers_backup.go +++ b/Server/admin/handlers_backup.go @@ -124,12 +124,12 @@ func handleDeleteBackup(database *db.DB) http.Handler { return } - if _, err := os.Stat(target); os.IsNotExist(err) { + if _, err := os.Stat(target); os.IsNotExist(err) { //nolint:gosec // G703: path sanitized by HasPrefix check above writeErr(w, http.StatusNotFound, "NOT_FOUND", "backup not found") return } - if err := os.Remove(target); err != nil { + if err := os.Remove(target); err != nil { //nolint:gosec // G703: path sanitized by HasPrefix check above writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete backup") return } @@ -156,7 +156,7 @@ func handleRestoreBackup(database *db.DB, hub HubBroadcaster) http.Handler { return } - if _, err := os.Stat(target); os.IsNotExist(err) { + if _, err := os.Stat(target); os.IsNotExist(err) { //nolint:gosec // G703: path sanitized by HasPrefix check above writeErr(w, http.StatusNotFound, "NOT_FOUND", "backup not found") return } diff --git a/Server/admin/update_handlers.go b/Server/admin/update_handlers.go index 2f666492..3aa1574c 100644 --- a/Server/admin/update_handlers.go +++ b/Server/admin/update_handlers.go @@ -10,6 +10,7 @@ import ( "time" "github.com/owncord/server/updater" + "golang.org/x/mod/semver" ) // handleCheckUpdate returns the current update status. @@ -31,7 +32,7 @@ func handleCheckUpdate(u *updater.Updater) http.HandlerFunc { // handleApplyUpdate downloads and applies a server update. func handleApplyUpdate(u *updater.Updater, hub HubBroadcaster, _ string) http.Handler { - //TODO: maybe disable this endpoint in future docker build type? + // TODO: maybe disable this endpoint in future docker build type? return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if u == nil { writeErr(w, http.StatusServiceUnavailable, "UPDATE_UNAVAILABLE", "update checking is not configured") @@ -46,10 +47,14 @@ func handleApplyUpdate(u *updater.Updater, hub HubBroadcaster, _ string) http.Ha return } if !info.UpdateAvailable { + if semver.Compare(info.Current, info.Latest) < 0 && !info.RequiredAssetsPresent { + writeErr(w, http.StatusBadGateway, "MISSING_ASSETS", "release is missing required assets") + return + } writeErr(w, http.StatusConflict, "NO_UPDATE", "server is already up to date") return } - if info.DownloadURL == "" || info.ChecksumURL == "" { + if !info.RequiredAssetsPresent { writeErr(w, http.StatusBadGateway, "MISSING_ASSETS", "release is missing required assets") return } @@ -73,7 +78,7 @@ 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.DownloadURL, info.ChecksumURL, newPath); err != nil { + if err := u.DownloadAndVerify(ctx, info.Latest, info.DownloadURL, info.ChecksumURL, info.SignatureURL, info.ManifestURL, info.ManifestSignatureURL, newPath); err != nil { slog.Error("update download/verify failed", "err", err) writeErr(w, http.StatusBadGateway, "DOWNLOAD_FAILED", "download or verification failed — see server logs") return diff --git a/Server/admin/update_handlers_test.go b/Server/admin/update_handlers_test.go index b5d6a2d9..7d634906 100644 --- a/Server/admin/update_handlers_test.go +++ b/Server/admin/update_handlers_test.go @@ -22,6 +22,9 @@ func TestAdminAPI_CheckUpdate_OK(t *testing.T) { {"name": "chatserver.exe", "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/chatserver.exe"}, {"name": "chatserver-linux-amd64.tar.gz", "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/chatserver-linux-amd64.tar.gz"}, {"name": "checksums.sha256", "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/checksums.sha256"}, + {"name": "chatserver.exe.sig", "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/chatserver.exe.sig"}, + {"name": "server-update-manifest.json", "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/server-update-manifest.json"}, + {"name": "server-update-manifest.json.sig", "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/server-update-manifest.json.sig"}, }, }) })) @@ -47,6 +50,45 @@ func TestAdminAPI_CheckUpdate_OK(t *testing.T) { if info.Latest != "v2.0.0" { t.Errorf("latest = %q, want v2.0.0", info.Latest) } + if !info.RequiredAssetsPresent { + t.Error("expected required_assets_present = true") + } +} + +func TestAdminAPI_CheckUpdate_IncompleteReleaseNotInstallable(t *testing.T) { + mockGH := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "tag_name": "v2.0.0", + "body": "Missing manifest", + "html_url": "https://github.com/J3vb/OwnCord/releases/tag/v2.0.0", + "assets": []map[string]any{ + {"name": "chatserver.exe", "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/chatserver.exe"}, + {"name": "checksums.sha256", "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/checksums.sha256"}, + }, + }) + })) + defer mockGH.Close() + + u := updater.NewUpdater("1.0.0", "", "J3vb", "OwnCord") + u.SetBaseURL(mockGH.URL) + + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil) + token := createAdminUser(t, database) + + w := doRequest(t, handler, http.MethodGet, "/updates", token, nil) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + + var info updater.UpdateInfo + _ = json.Unmarshal(w.Body.Bytes(), &info) + if info.UpdateAvailable { + t.Error("expected update_available = false for incomplete release") + } + if info.RequiredAssetsPresent { + t.Error("expected required_assets_present = false for incomplete release") + } } func TestAdminAPI_CheckUpdate_UpToDate(t *testing.T) { @@ -198,7 +240,7 @@ func TestAdminAPI_ApplyUpdate_CheckFails(t *testing.T) { } // TestAdminAPI_ApplyUpdate_MissingAssets verifies that 502 is returned when the -// release has no download URL or checksum URL. +// release has no download URL, checksum URL, or detached signature URL. func TestAdminAPI_ApplyUpdate_MissingAssets(t *testing.T) { // Return a newer version but with no assets (empty download/checksum URLs). mockGH := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -273,6 +315,18 @@ func TestAdminAPI_ApplyUpdate_DownloadFails(t *testing.T) { "name": "checksums.sha256", "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/checksums.sha256", }, + { + "name": "chatserver.exe.sig", + "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/chatserver.exe.sig", + }, + { + "name": "server-update-manifest.json", + "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/server-update-manifest.json", + }, + { + "name": "server-update-manifest.json.sig", + "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/server-update-manifest.json.sig", + }, }, }) _ = mockGHURL // suppress unused warning diff --git a/Server/api/auth_handler.go b/Server/api/auth_handler.go index 6efb7bd3..73acfa99 100644 --- a/Server/api/auth_handler.go +++ b/Server/api/auth_handler.go @@ -75,7 +75,7 @@ func MountAuthRoutes(r chi.Router, database *db.DB, limiter *auth.RateLimiter, t Post("/register", handleRegister(database)) r.With(RateLimitMiddleware(loginLimiter, loginRateLimitPerMinute, time.Minute, trustedProxies)). - Post("/login", handleLogin(database, limiter, partialStore, trustedProxies, totpKey)) + Post("/login", handleLogin(database, limiter, partialStore, trustedProxies)) r.With(RateLimitMiddleware(limiter, verifyTOTPRateLimitPerMinute, time.Minute, trustedProxies)). Post("/verify-totp", handleVerifyTOTP(database, partialStore, limiter, usedTOTPCodes, totpKey)) @@ -251,7 +251,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, totpKey []byte) http.HandlerFunc { +func handleLogin(database *db.DB, limiter *auth.RateLimiter, partialStore *auth.PartialAuthStore, trustedProxies []string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req loginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { diff --git a/Server/api/constants.go b/Server/api/constants.go index e3d9be43..830fc1c3 100644 --- a/Server/api/constants.go +++ b/Server/api/constants.go @@ -65,6 +65,10 @@ const ( // per IP per minute. profilePasswordRateLimitPerMinute = 5 + // profileUpdateRateLimitPerMinute is the maximum profile update attempts + // per user per minute. + profileUpdateRateLimitPerMinute = 10 + // loginUserFailureThreshold is the number of failed login attempts for a // specific username (regardless of source IP) before the account is locked. loginUserFailureThreshold = 9 @@ -131,4 +135,7 @@ const ( // maxUploadFilenameLength is the maximum length of an upload filename // (filesystem-safe limit). maxUploadFilenameLength = 255 + + // maxAvatarURLLen is the maximum length of a user avatar URL. + maxAvatarURLLen = 512 ) diff --git a/Server/api/profile_handler.go b/Server/api/profile_handler.go index c810dd6e..13d0f260 100644 --- a/Server/api/profile_handler.go +++ b/Server/api/profile_handler.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "net/http" + "net/url" "strings" "time" @@ -57,7 +58,8 @@ func MountProfileRoutes(r chi.Router, database *db.DB, limiter *auth.RateLimiter r.Route("/api/v1/users/me", func(r chi.Router) { r.Use(AuthMiddleware(database)) - r.Patch("/", handleUpdateProfile(database, broadcaster)) + r.With(RateLimitMiddleware(limiter, profileUpdateRateLimitPerMinute, time.Minute, trustedProxies)). + Patch("/", handleUpdateProfile(database, broadcaster)) r.With(RateLimitMiddleware(limiter, profilePasswordRateLimitPerMinute, time.Minute, trustedProxies)). Put("/password", handleChangePassword(database, limiter)) @@ -67,6 +69,24 @@ func MountProfileRoutes(r chi.Router, database *db.DB, limiter *auth.RateLimiter }) } +// ─── Helpers ───────────────────────────────────────────────────────────────── + +// validateAvatarURL checks that avatar is either empty or a valid https:// URL +// no longer than maxAvatarURLLen characters. +func validateAvatarURL(avatar string) error { + if avatar == "" { + return nil + } + if len(avatar) > maxAvatarURLLen { + return fmt.Errorf("avatar URL too long (max %d characters)", maxAvatarURLLen) + } + parsed, err := url.Parse(avatar) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" { + return fmt.Errorf("avatar URL must use https://") + } + return nil +} + // ─── Handlers ──────────────────────────────────────────────────────────────── // handleUpdateProfile processes PATCH /api/v1/users/me. @@ -108,9 +128,16 @@ func handleUpdateProfile(database *db.DB, broadcaster ProfileBroadcaster) http.H return } - // Sanitize avatar if provided. + // Sanitize and validate avatar if provided. if req.Avatar != nil { trimmed := strings.TrimSpace(sanitizer.Sanitize(*req.Avatar)) + if err := validateAvatarURL(trimmed); err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "INVALID_INPUT", + Message: err.Error(), + }) + return + } req.Avatar = &trimmed } diff --git a/Server/api/upload_handler.go b/Server/api/upload_handler.go index 2d54b163..a9b5f8e9 100644 --- a/Server/api/upload_handler.go +++ b/Server/api/upload_handler.go @@ -262,14 +262,12 @@ func handleServeFile(database *db.DB, store *storage.Storage, allowedOrigins []s }) return } - } else { - if !hasChannelPermREST(database, role, *aa.ChannelID, permissions.ReadMessages) { - writeJSON(w, http.StatusForbidden, errorResponse{ - Error: "FORBIDDEN", - Message: "you do not have access to this file", - }) - return - } + } else if !hasChannelPermREST(database, role, *aa.ChannelID, permissions.ReadMessages) { + writeJSON(w, http.StatusForbidden, errorResponse{ + Error: "FORBIDDEN", + Message: "you do not have access to this file", + }) + return } } } diff --git a/Server/api/waf.go b/Server/api/waf.go index 3ba2e569..49636588 100644 --- a/Server/api/waf.go +++ b/Server/api/waf.go @@ -128,5 +128,5 @@ func handleWAFInterruption(w http.ResponseWriter, it *types.Interruption) { ) w.Header().Set("Content-Type", "application/json") w.WriteHeader(it.Status) - fmt.Fprintf(w, `{"error":"request blocked by security rules"}`) + _, _ = fmt.Fprintf(w, `{"error":"request blocked by security rules"}`) } diff --git a/Server/auth/totp_encrypt.go b/Server/auth/totp_encrypt.go index 75e706df..60f147ad 100644 --- a/Server/auth/totp_encrypt.go +++ b/Server/auth/totp_encrypt.go @@ -110,8 +110,8 @@ func DecryptTOTPSecret(key []byte, ciphertext string) (string, error) { data, err := hex.DecodeString(ciphertext) if err != nil { - // Not valid hex -- treat as unencrypted plaintext. - return ciphertext, nil + // Not valid hex -- treat as unencrypted plaintext (backwards compat). + return ciphertext, nil //nolint:nilerr } block, err := aes.NewCipher(key) @@ -135,7 +135,7 @@ func DecryptTOTPSecret(key []byte, ciphertext string) (string, error) { if err != nil { // Decryption failed -- likely an unencrypted legacy secret. // Return as-is for backwards compatibility. - return ciphertext, nil + return ciphertext, nil //nolint:nilerr } return string(plaintext), nil diff --git a/Server/go.mod b/Server/go.mod index ef7df5e8..a8865883 100644 --- a/Server/go.mod +++ b/Server/go.mod @@ -3,6 +3,7 @@ module github.com/owncord/server go 1.25.0 require ( + aead.dev/minisign v0.3.0 github.com/go-chi/chi/v5 v5.2.5 github.com/google/uuid v1.6.0 github.com/knadh/koanf/parsers/yaml v1.1.0 diff --git a/Server/go.sum b/Server/go.sum index 446feef5..f1a4e8e9 100644 --- a/Server/go.sum +++ b/Server/go.sum @@ -1,3 +1,5 @@ +aead.dev/minisign v0.3.0 h1:8Xafzy5PEVZqYDNP60yJHARlW1eOQtsKNp/Ph2c0vRA= +aead.dev/minisign v0.3.0/go.mod h1:NLvG3Uoq3skkRMDuc3YHpWUTMTrSExqm+Ij73W13F6Y= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 h1:PMmTMyvHScV9Mn8wc6ASge9uRcHy0jtqPd+fM35LmsQ= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= buf.build/go/protovalidate v1.1.2 h1:83vYHoY8f34hB8MeitGaYE3CGVPFxwdEUuskh5qQpA0= diff --git a/Server/updater/server_update_public_key.txt b/Server/updater/server_update_public_key.txt new file mode 100644 index 00000000..65e9d690 --- /dev/null +++ b/Server/updater/server_update_public_key.txt @@ -0,0 +1 @@ +dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEFCQjA3OEZEOEVCRkY1RkEKUldUNjliK08vWGl3cStHamIrVHhNbWNLT3Bwb3ppeTIwdDBkQkFlaytHSWVqZkExSmFxRHZDVVoK \ No newline at end of file diff --git a/Server/updater/updater.go b/Server/updater/updater.go index b5f06a88..01f17cf9 100644 --- a/Server/updater/updater.go +++ b/Server/updater/updater.go @@ -4,9 +4,12 @@ package updater import ( "archive/tar" + "bytes" "compress/gzip" "context" "crypto/sha256" + _ "embed" + "encoding/base64" "encoding/hex" "encoding/json" "fmt" @@ -14,36 +17,61 @@ import ( "net/http" neturl "net/url" "os" + "path" "path/filepath" "runtime" "strings" "time" + "aead.dev/minisign" + "github.com/owncord/server/syncutil" "golang.org/x/mod/semver" ) const ( - defaultBaseURL = "https://api.github.com" - cacheTTL = 1 * time.Hour - errorCacheTTL = 5 * time.Minute - checksumAsset = "checksums.sha256" + defaultBaseURL = "https://api.github.com" + cacheTTL = 1 * time.Hour + errorCacheTTL = 5 * time.Minute + checksumAsset = "checksums.sha256" + signatureAsset = windowsServerBinary + ".sig" + manifestAsset = "server-update-manifest.json" + manifestSigAsset = manifestAsset + ".sig" windowsServerBinary = "chatserver.exe" linuxServerArchive = "chatserver-linux-amd64.tar.gz" ) +// serverUpdatePublicKeyText is the pinned public key for server update +// signatures. Keep this file in sync with the SERVER_UPDATE_SIGNING_* CI +// secrets when rotating the server updater keypair. +// +//go:embed server_update_public_key.txt +var serverUpdatePublicKeyText string + +var defaultServerSignaturePublicKey = strings.TrimSpace(serverUpdatePublicKeyText) + // UpdateInfo holds the result of a version check. type UpdateInfo struct { - Current string `json:"current"` - Latest string `json:"latest"` - UpdateAvailable bool `json:"update_available"` - ReleaseURL string `json:"release_url"` - DownloadURL string `json:"download_url"` - ChecksumURL string `json:"checksum_url"` - ReleaseNotes string `json:"release_notes"` - Assets []Asset `json:"assets,omitempty"` + Current string `json:"current"` + Latest string `json:"latest"` + UpdateAvailable bool `json:"update_available"` + RequiredAssetsPresent bool `json:"required_assets_present"` + ReleaseURL string `json:"release_url"` + DownloadURL string `json:"download_url"` + ChecksumURL string `json:"checksum_url"` + SignatureURL string `json:"signature_url"` + ManifestURL string `json:"manifest_url"` + ManifestSignatureURL string `json:"manifest_signature_url"` + ReleaseNotes string `json:"release_notes"` + Assets []Asset `json:"assets,omitempty"` +} + +type releaseManifest struct { + Version string `json:"version"` + Asset string `json:"asset"` + SHA256 string `json:"sha256"` } // Asset is a simplified release asset with name and download URL. @@ -86,6 +114,7 @@ type Updater struct { errCacheExpiry time.Time mu syncutil.Mutex httpClient *http.Client + signingKeyText string } // NewUpdater creates an Updater for the given repository. @@ -96,6 +125,7 @@ func NewUpdater(currentVersion, githubToken, repoOwner, repoName string) *Update repoOwner: repoOwner, repoName: repoName, httpClient: &http.Client{Timeout: 30 * time.Second}, + signingKeyText: defaultServerSignaturePublicKey, } } @@ -192,7 +222,7 @@ func (u *Updater) fetchLatestRelease(ctx context.Context) (UpdateInfo, error) { // semver.Compare returns -1, 0, or +1. Update available when current < latest. updateAvailable := semver.Compare(currentV, latestV) < 0 - var downloadURL, checksumURL string + var downloadURL, checksumURL, signatureURL, manifestURL, manifestSignatureURL string assets := make([]Asset, 0, len(release.Assets)) wantBinary := serverDownloadAssetName(runtime.GOOS) for _, asset := range release.Assets { @@ -200,25 +230,42 @@ func (u *Updater) fetchLatestRelease(ctx context.Context) (UpdateInfo, error) { Name: asset.Name, DownloadURL: asset.BrowserDownloadURL, }) - if wantBinary != "" && strings.EqualFold(asset.Name, wantBinary) { + switch { + case wantBinary != "" && strings.EqualFold(asset.Name, wantBinary): downloadURL = asset.BrowserDownloadURL - } else if strings.EqualFold(asset.Name, checksumAsset) { + case strings.EqualFold(asset.Name, checksumAsset): checksumURL = asset.BrowserDownloadURL + case strings.EqualFold(asset.Name, signatureAsset): + signatureURL = asset.BrowserDownloadURL + case strings.EqualFold(asset.Name, manifestAsset): + manifestURL = asset.BrowserDownloadURL + case strings.EqualFold(asset.Name, manifestSigAsset): + manifestSignatureURL = asset.BrowserDownloadURL } } + requiredAssetsPresent := hasRequiredServerAssets(downloadURL, checksumURL, signatureURL, manifestURL, manifestSignatureURL) + updateAvailable = updateAvailable && requiredAssetsPresent return UpdateInfo{ - Current: currentV, - Latest: latestV, - UpdateAvailable: updateAvailable, - ReleaseURL: release.HTMLURL, - DownloadURL: downloadURL, - ChecksumURL: checksumURL, - ReleaseNotes: release.Body, - Assets: assets, + Current: currentV, + Latest: latestV, + UpdateAvailable: updateAvailable, + RequiredAssetsPresent: requiredAssetsPresent, + ReleaseURL: release.HTMLURL, + DownloadURL: downloadURL, + ChecksumURL: checksumURL, + SignatureURL: signatureURL, + ManifestURL: manifestURL, + ManifestSignatureURL: manifestSignatureURL, + ReleaseNotes: release.Body, + Assets: assets, }, nil } +func hasRequiredServerAssets(downloadURL, checksumURL, signatureURL, manifestURL, manifestSignatureURL string) bool { + return downloadURL != "" && checksumURL != "" && signatureURL != "" && manifestURL != "" && manifestSignatureURL != "" +} + // ValidateDownloadURL ensures the URL points to an expected GitHub release // asset for this repository. func (u *Updater) ValidateDownloadURL(url string) error { @@ -230,36 +277,66 @@ func (u *Updater) ValidateDownloadURL(url string) error { } // DownloadAndVerify downloads the release artifact from downloadURL, fetches -// the checksum file from checksumURL, and verifies the SHA256 hash matches. -// On Windows the asset is a single executable; on Linux it is a tar.gz -// archive containing a "chatserver" binary, which is extracted to destPath. -// On checksum mismatch, partial files are removed. -func (u *Updater) DownloadAndVerify(ctx context.Context, downloadURL, checksumURL, destPath string) error { +// the checksum file, the detached binary signature, and a signed release +// manifest, and verifies that the downloaded asset matches both the release +// version and the pinned signing key. On Windows the asset is a single +// 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 { if err := u.ValidateDownloadURL(downloadURL); err != nil { return err } if err := u.ValidateDownloadURL(checksumURL); err != nil { return fmt.Errorf("validating checksum URL: %w", err) } + if err := u.ValidateDownloadURL(signatureURL); err != nil { + return fmt.Errorf("validating signature URL: %w", err) + } + if err := u.ValidateDownloadURL(manifestURL); err != nil { + return fmt.Errorf("validating manifest URL: %w", err) + } + if err := u.ValidateDownloadURL(manifestSignatureURL); err != nil { + 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) } - - goos := runtime.GOOS - names := checksumEntryNamesForGOOS(goos) - if len(names) == 0 { - return fmt.Errorf("server auto-update is not supported on %s", goos) + signatureData, err := u.fetchBody(ctx, signatureURL) + if err != nil { + return fmt.Errorf("fetching signature: %w", err) } - expectedHash, err := u.parseChecksumFileAny(checksumData, names...) + manifestData, err := u.fetchBody(ctx, manifestURL) + if err != nil { + 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) + } + + assetFilename, err := assetFilenameFromURL(downloadURL) + if err != nil { + return fmt.Errorf("determining asset filename: %w", err) + } + manifest, err := u.VerifyReleaseManifest(manifestData, manifestSignatureData, latestVersion, assetFilename) + if err != nil { + return err + } + expectedHash, err := u.ParseChecksumFile(checksumData, assetFilename) if err != nil { return fmt.Errorf("parsing checksum file: %w", err) } + if !strings.EqualFold(expectedHash, manifest.SHA256) { + return fmt.Errorf("release manifest checksum mismatch for %s", assetFilename) + } + goos := runtime.GOOS switch goos { case "windows": - return u.downloadWindowsBinaryAndVerify(ctx, downloadURL, destPath, expectedHash) + return u.downloadWindowsBinaryAndVerify(ctx, downloadURL, destPath, expectedHash, signatureData) case "linux": return u.downloadLinuxTarballAndVerify(ctx, downloadURL, destPath, expectedHash) default: @@ -267,11 +344,19 @@ func (u *Updater) DownloadAndVerify(ctx context.Context, downloadURL, checksumUR } } -func (u *Updater) downloadWindowsBinaryAndVerify(ctx context.Context, downloadURL, destPath, expectedHash string) error { +func (u *Updater) downloadWindowsBinaryAndVerify(ctx context.Context, downloadURL, destPath, expectedHash string, signatureData []byte) error { if err := u.downloadFile(ctx, downloadURL, destPath); err != nil { return fmt.Errorf("downloading binary: %w", err) } + + if err := u.VerifySignature(destPath, signatureData); err != nil { + _ = os.Remove(destPath) + return err + } + + // Verify hash. if err := u.VerifyChecksum(destPath, expectedHash); err != nil { + // Remove the invalid file. _ = os.Remove(destPath) return err } @@ -404,6 +489,99 @@ func (u *Updater) parseChecksumFileAny(data []byte, names ...string) (string, er return "", fmt.Errorf("no checksum line for any of: %s", strings.Join(names, ", ")) } +// VerifyReleaseManifest checks the detached signature on the release manifest +// and ensures the manifest binds the downloaded asset to the expected version. +func (u *Updater) VerifyReleaseManifest(manifestData, signatureText []byte, expectedVersion, expectedAsset string) (releaseManifest, error) { + if err := u.verifySignatureReader(bytes.NewReader(manifestData), signatureText, manifestAsset); err != nil { + return releaseManifest{}, fmt.Errorf("verifying release manifest signature: %w", err) + } + + var manifest releaseManifest + if err := json.Unmarshal(manifestData, &manifest); err != nil { + return releaseManifest{}, fmt.Errorf("parsing release manifest: %w", err) + } + manifest.Version = ensureVPrefix(strings.TrimSpace(manifest.Version)) + manifest.Asset = strings.TrimSpace(manifest.Asset) + manifest.SHA256 = strings.ToLower(strings.TrimSpace(manifest.SHA256)) + + if manifest.Version == "" || manifest.Asset == "" || manifest.SHA256 == "" { + return releaseManifest{}, fmt.Errorf("release manifest is missing required fields") + } + if manifest.Version != ensureVPrefix(expectedVersion) { + return releaseManifest{}, fmt.Errorf("release manifest version %q does not match release %q", manifest.Version, ensureVPrefix(expectedVersion)) + } + if manifest.Asset != expectedAsset { + return releaseManifest{}, fmt.Errorf("release manifest asset %q does not match expected asset %q", manifest.Asset, expectedAsset) + } + if len(manifest.SHA256) != sha256.Size*2 { + return releaseManifest{}, fmt.Errorf("release manifest checksum for %s has invalid length", manifest.Asset) + } + if _, err := hex.DecodeString(manifest.SHA256); err != nil { + return releaseManifest{}, fmt.Errorf("release manifest checksum for %s is invalid: %w", manifest.Asset, err) + } + + return manifest, nil +} + +// VerifySignature checks whether the detached minisign signature matches the +// file contents using the pinned server-update public key. +func (u *Updater) VerifySignature(filePath string, signatureText []byte) error { + f, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("opening file for signature verification: %w", err) + } + defer f.Close() //nolint:errcheck + + return u.verifySignatureReader(f, signatureText, filepath.Base(filePath)) +} + +func (u *Updater) verifySignatureReader(reader io.Reader, signatureText []byte, subject string) error { + publicKey, err := u.serverSignaturePublicKey() + if err != nil { + return fmt.Errorf("loading update signing key: %w", err) + } + + verifier := minisign.NewReader(reader) + if _, err := io.Copy(io.Discard, verifier); err != nil { + return fmt.Errorf("reading file for signature verification: %w", err) + } + + normalizedSig := []byte(strings.TrimSpace(string(signatureText))) + var parsedSig minisign.Signature + if err := parsedSig.UnmarshalText(normalizedSig); err != nil { + return fmt.Errorf("invalid update signature format: %w", err) + } + + if !verifier.Verify(publicKey, normalizedSig) { + return fmt.Errorf("signature verification failed for %s", subject) + } + return nil +} + +func (u *Updater) serverSignaturePublicKey() (minisign.PublicKey, error) { + decoded, err := base64.StdEncoding.DecodeString(u.signingKeyText) + if err != nil { + return minisign.PublicKey{}, fmt.Errorf("decoding base64 public key: %w", err) + } + var publicKey minisign.PublicKey + if err := publicKey.UnmarshalText(decoded); err != nil { + return minisign.PublicKey{}, fmt.Errorf("parsing minisign public key: %w", err) + } + return publicKey, nil +} + +func assetFilenameFromURL(rawURL string) (string, error) { + parsed, err := neturl.Parse(rawURL) + if err != nil { + return "", err + } + filename := path.Base(parsed.Path) + if filename == "." || filename == "/" || filename == "" { + return "", fmt.Errorf("missing asset filename in URL %q", rawURL) + } + return filename, nil +} + // 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 { diff --git a/Server/updater/updater_test.go b/Server/updater/updater_test.go index 202a2fad..f8f546fb 100644 --- a/Server/updater/updater_test.go +++ b/Server/updater/updater_test.go @@ -6,9 +6,11 @@ import ( "compress/gzip" "context" "crypto/sha256" + "encoding/base64" "encoding/hex" "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "os" @@ -18,6 +20,8 @@ import ( "sync/atomic" "testing" "time" + + "aead.dev/minisign" ) // ghRelease mirrors the GitHub release API response shape. @@ -43,6 +47,9 @@ func newTestRelease(tag, body, htmlURL string, assetDownloadBase string) ghRelea {Name: "chatserver.exe", BrowserDownloadURL: assetDownloadBase + "/chatserver.exe"}, {Name: "chatserver-linux-amd64.tar.gz", BrowserDownloadURL: assetDownloadBase + "/chatserver-linux-amd64.tar.gz"}, {Name: "checksums.sha256", BrowserDownloadURL: assetDownloadBase + "/checksums.sha256"}, + {Name: "chatserver.exe.sig", BrowserDownloadURL: assetDownloadBase + "/chatserver.exe.sig"}, + {Name: "server-update-manifest.json", BrowserDownloadURL: assetDownloadBase + "/server-update-manifest.json"}, + {Name: "server-update-manifest.json.sig", BrowserDownloadURL: assetDownloadBase + "/server-update-manifest.json.sig"}, }, } } @@ -70,6 +77,30 @@ func newTestUpdater(baseURL, currentVersion string) *Updater { return u } +func newSignedTestUpdater(t *testing.T, baseURL, currentVersion string) (*Updater, minisign.PrivateKey) { + t.Helper() + publicKey, privateKey, err := minisign.GenerateKey(nil) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + publicKeyText, err := publicKey.MarshalText() + if err != nil { + t.Fatalf("MarshalText(public key): %v", err) + } + u := newTestUpdater(baseURL, currentVersion) + u.signingKeyText = base64.StdEncoding.EncodeToString(publicKeyText) + return u, privateKey +} + +func signTestAsset(t *testing.T, privateKey minisign.PrivateKey, content []byte) []byte { + t.Helper() + reader := minisign.NewReader(bytes.NewReader(content)) + if _, err := io.Copy(io.Discard, reader); err != nil { + t.Fatalf("signTestAsset io.Copy: %v", err) + } + return reader.SignWithComments(privateKey, "timestamp:1712016000\tfile:chatserver.exe", "untrusted comment: owncord test") +} + func TestCheckForUpdate_NewerVersionAvailable(t *testing.T) { release := newTestRelease("v1.2.0", "Bug fixes and improvements", "https://github.com/J3vb/OwnCord/releases/tag/v1.2.0", "https://github.com/J3vb/OwnCord/releases/download/v1.2.0") @@ -101,6 +132,44 @@ func TestCheckForUpdate_NewerVersionAvailable(t *testing.T) { } else if info.DownloadURL != "" { t.Error("expected empty DownloadURL on unsupported GOOS") } + if info.SignatureURL == "" { + t.Error("expected non-empty SignatureURL") + } + if info.ManifestURL == "" { + t.Error("expected non-empty ManifestURL") + } + if info.ManifestSignatureURL == "" { + t.Error("expected non-empty ManifestSignatureURL") + } + if !info.RequiredAssetsPresent { + t.Error("expected RequiredAssetsPresent=true") + } +} + +func TestCheckForUpdate_MissingRequiredAssetsSuppressesUpdate(t *testing.T) { + release := ghRelease{ + TagName: "v1.2.0", + Body: "Broken release", + HTMLURL: "https://github.com/J3vb/OwnCord/releases/tag/v1.2.0", + Assets: []ghAsset{ + {Name: "chatserver.exe", BrowserDownloadURL: "https://github.com/J3vb/OwnCord/releases/download/v1.2.0/chatserver.exe"}, + {Name: "checksums.sha256", BrowserDownloadURL: "https://github.com/J3vb/OwnCord/releases/download/v1.2.0/checksums.sha256"}, + }, + } + srv := newTestServer(t, release, http.StatusOK) + defer srv.Close() + + u := newTestUpdater(srv.URL, "1.0.0") + info, err := u.CheckForUpdate(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info.UpdateAvailable { + t.Fatal("expected UpdateAvailable=false for incomplete release") + } + if info.RequiredAssetsPresent { + t.Fatal("expected RequiredAssetsPresent=false for incomplete release") + } } func TestCheckForUpdate_UpToDate(t *testing.T) { @@ -427,6 +496,46 @@ func TestExtractChatserverFromTarGz(t *testing.T) { } } +func TestAssetFilenameFromURL(t *testing.T) { + got, err := assetFilenameFromURL("https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe") + if err != nil { + t.Fatalf("assetFilenameFromURL: %v", err) + } + if got != "chatserver.exe" { + t.Errorf("assetFilenameFromURL = %q, want chatserver.exe", got) + } +} + +func TestDefaultServerSignaturePublicKey_DiffersFromTauriUpdaterKey(t *testing.T) { + tauriConfigPath := filepath.Clean(filepath.Join("..", "..", "Client", "tauri-client", "src-tauri", "tauri.conf.json")) + raw, err := os.ReadFile(tauriConfigPath) + if err != nil { + t.Fatalf("ReadFile(%s): %v", tauriConfigPath, err) + } + + var cfg struct { + Plugins struct { + Updater struct { + PubKey string `json:"pubkey"` + } `json:"updater"` + } `json:"plugins"` + } + if err := json.Unmarshal(raw, &cfg); err != nil { + t.Fatalf("Unmarshal tauri.conf.json: %v", err) + } + + if cfg.Plugins.Updater.PubKey == defaultServerSignaturePublicKey { + t.Fatalf("server updater signing key must differ from tauri.conf.json updater pubkey") + } +} + +func TestDefaultServerSignaturePublicKey_Parseable(t *testing.T) { + u := NewUpdater("1.0.0", "", "J3vb", "OwnCord") + if _, err := u.serverSignaturePublicKey(); err != nil { + t.Fatalf("serverSignaturePublicKey: %v", err) + } +} + // ─── SetBaseURL ────────────────────────────────────────────────────────────── func TestSetBaseURL(t *testing.T) { @@ -554,6 +663,10 @@ func testDownloadAndVerifySuccessWindows(t *testing.T) { content := []byte("real binary content for verification") hash := sha256.Sum256(content) checksumHex := hex.EncodeToString(hash[:]) + u, privateKey := newSignedTestUpdater(t, "", "1.0.0") + signature := signTestAsset(t, privateKey, content) + manifest := []byte(`{"version":"v1.0.0","asset":"chatserver.exe","sha256":"` + checksumHex + `"}`) + manifestSignature := signTestAsset(t, privateKey, manifest) mux := http.NewServeMux() mux.HandleFunc("/download/chatserver.exe", func(w http.ResponseWriter, r *http.Request) { @@ -562,24 +675,35 @@ func testDownloadAndVerifySuccessWindows(t *testing.T) { mux.HandleFunc("/download/checksums.sha256", func(w http.ResponseWriter, r *http.Request) { _, _ = fmt.Fprintf(w, "%s chatserver.exe\n", checksumHex) }) + mux.HandleFunc("/download/chatserver.exe.sig", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(append(signature, []byte("\r\n")...)) + }) + mux.HandleFunc("/download/server-update-manifest.json", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(manifest) + }) + mux.HandleFunc("/download/server-update-manifest.json.sig", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(manifestSignature) + }) srv := httptest.NewServer(mux) defer srv.Close() tmpDir := t.TempDir() - dest := filepath.Join(tmpDir, "chatserver.exe") + dest := filepath.Join(tmpDir, "chatserver.exe.new") - u := NewUpdater("1.0.0", "", "J3vb", "OwnCord") u.baseURL = srv.URL downloadURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe" checksumURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/checksums.sha256" + signatureURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe.sig" + 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" // Override HTTP client to route GitHub URLs to our test server. u.httpClient = &http.Client{ Transport: &rewriteTransport{srv.URL}, } - err := u.DownloadAndVerify(context.Background(), downloadURL, checksumURL, dest) + err := u.DownloadAndVerify(context.Background(), "v1.0.0", downloadURL, checksumURL, signatureURL, manifestURL, manifestSignatureURL, dest) if err != nil { t.Fatalf("DownloadAndVerify: %v", err) } @@ -595,6 +719,9 @@ func testDownloadAndVerifySuccessLinux(t *testing.T) { tgz := mustBuildChatserverTarGz(t, inner) hash := sha256.Sum256(tgz) checksumHex := hex.EncodeToString(hash[:]) + u, privateKey := newSignedTestUpdater(t, "", "1.0.0") + manifest := []byte(`{"version":"v1.0.0","asset":"chatserver-linux-amd64.tar.gz","sha256":"` + checksumHex + `"}`) + manifestSignature := signTestAsset(t, privateKey, manifest) mux := http.NewServeMux() mux.HandleFunc("/download/chatserver-linux-amd64.tar.gz", func(w http.ResponseWriter, r *http.Request) { @@ -603,23 +730,36 @@ func testDownloadAndVerifySuccessLinux(t *testing.T) { mux.HandleFunc("/download/checksums.sha256", func(w http.ResponseWriter, r *http.Request) { _, _ = fmt.Fprintf(w, "%s linux/chatserver-linux-amd64.tar.gz\n", checksumHex) }) + mux.HandleFunc("/download/chatserver-linux-amd64.tar.gz.sig", func(w http.ResponseWriter, r *http.Request) { + // Linux tar.gz does not have a detached binary sig; return empty to satisfy URL validation. + // The signing flow only applies the manifest; the binary sig slot is unused on Linux. + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("/download/server-update-manifest.json", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(manifest) + }) + mux.HandleFunc("/download/server-update-manifest.json.sig", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(manifestSignature) + }) srv := httptest.NewServer(mux) defer srv.Close() tmpDir := t.TempDir() dest := filepath.Join(tmpDir, "chatserver") - u := NewUpdater("1.0.0", "", "J3vb", "OwnCord") u.baseURL = srv.URL downloadURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver-linux-amd64.tar.gz" checksumURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/checksums.sha256" + signatureURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver-linux-amd64.tar.gz.sig" + 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" u.httpClient = &http.Client{ Transport: &rewriteTransport{srv.URL}, } - err := u.DownloadAndVerify(context.Background(), downloadURL, checksumURL, dest) + err := u.DownloadAndVerify(context.Background(), "v1.0.0", downloadURL, checksumURL, signatureURL, manifestURL, manifestSignatureURL, dest) if err != nil { t.Fatalf("DownloadAndVerify: %v", err) } @@ -661,7 +801,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(), "https://evil.com/file", "https://evil.com/sum", "/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") } @@ -669,8 +809,8 @@ 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/" + serverDownloadAssetName(runtime.GOOS) - err := u.DownloadAndVerify(context.Background(), downloadURL, "https://evil.com/sum", "/tmp/out") + 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") if err == nil { t.Error("DownloadAndVerify should reject invalid checksum URL") } @@ -690,6 +830,12 @@ func TestDownloadAndVerify_ChecksumMismatch(t *testing.T) { func testDownloadAndVerifyChecksumMismatchWindows(t *testing.T) { content := []byte("binary content") wrongChecksum := "0000000000000000000000000000000000000000000000000000000000000000" + actualHash := sha256.Sum256(content) + actualChecksum := hex.EncodeToString(actualHash[:]) + u, privateKey := newSignedTestUpdater(t, "", "1.0.0") + signature := signTestAsset(t, privateKey, content) + manifest := []byte(`{"version":"v1.0.0","asset":"chatserver.exe","sha256":"` + actualChecksum + `"}`) + manifestSignature := signTestAsset(t, privateKey, manifest) mux := http.NewServeMux() mux.HandleFunc("/download/chatserver.exe", func(w http.ResponseWriter, r *http.Request) { @@ -698,19 +844,30 @@ func testDownloadAndVerifyChecksumMismatchWindows(t *testing.T) { mux.HandleFunc("/download/checksums.sha256", func(w http.ResponseWriter, r *http.Request) { _, _ = fmt.Fprintf(w, "%s chatserver.exe\n", wrongChecksum) }) + mux.HandleFunc("/download/chatserver.exe.sig", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(signature) + }) + mux.HandleFunc("/download/server-update-manifest.json", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(manifest) + }) + mux.HandleFunc("/download/server-update-manifest.json.sig", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(manifestSignature) + }) srv := httptest.NewServer(mux) defer srv.Close() tmpDir := t.TempDir() dest := filepath.Join(tmpDir, "chatserver.exe") - u := NewUpdater("1.0.0", "", "J3vb", "OwnCord") u.httpClient = &http.Client{Transport: &rewriteTransport{srv.URL}} downloadURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe" checksumURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/checksums.sha256" + signatureURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe.sig" + 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(), downloadURL, checksumURL, 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") } @@ -724,6 +881,11 @@ func testDownloadAndVerifyChecksumMismatchWindows(t *testing.T) { func testDownloadAndVerifyChecksumMismatchLinux(t *testing.T) { tgz := mustBuildChatserverTarGz(t, []byte("x")) wrongChecksum := "0000000000000000000000000000000000000000000000000000000000000000" + actualHash := sha256.Sum256(tgz) + actualChecksum := hex.EncodeToString(actualHash[:]) + u, privateKey := newSignedTestUpdater(t, "", "1.0.0") + manifest := []byte(`{"version":"v1.0.0","asset":"chatserver-linux-amd64.tar.gz","sha256":"` + actualChecksum + `"}`) + manifestSignature := signTestAsset(t, privateKey, manifest) mux := http.NewServeMux() mux.HandleFunc("/download/chatserver-linux-amd64.tar.gz", func(w http.ResponseWriter, r *http.Request) { @@ -732,19 +894,30 @@ func testDownloadAndVerifyChecksumMismatchLinux(t *testing.T) { mux.HandleFunc("/download/checksums.sha256", func(w http.ResponseWriter, r *http.Request) { _, _ = fmt.Fprintf(w, "%s linux/chatserver-linux-amd64.tar.gz\n", wrongChecksum) }) + mux.HandleFunc("/download/chatserver-linux-amd64.tar.gz.sig", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("/download/server-update-manifest.json", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(manifest) + }) + mux.HandleFunc("/download/server-update-manifest.json.sig", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(manifestSignature) + }) srv := httptest.NewServer(mux) defer srv.Close() tmpDir := t.TempDir() dest := filepath.Join(tmpDir, "chatserver") - u := NewUpdater("1.0.0", "", "J3vb", "OwnCord") u.httpClient = &http.Client{Transport: &rewriteTransport{srv.URL}} downloadURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver-linux-amd64.tar.gz" checksumURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/checksums.sha256" + signatureURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver-linux-amd64.tar.gz.sig" + 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(), downloadURL, checksumURL, 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") } @@ -754,6 +927,198 @@ func testDownloadAndVerifyChecksumMismatchLinux(t *testing.T) { } } +func TestDownloadAndVerify_MissingSignature(t *testing.T) { + content := []byte("real binary content for verification") + hash := sha256.Sum256(content) + checksumHex := hex.EncodeToString(hash[:]) + u, privateKey := newSignedTestUpdater(t, "", "1.0.0") + manifest := []byte(`{"version":"v1.0.0","asset":"chatserver.exe","sha256":"` + checksumHex + `"}`) + manifestSignature := signTestAsset(t, privateKey, manifest) + + mux := http.NewServeMux() + mux.HandleFunc("/download/chatserver.exe", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(content) + }) + mux.HandleFunc("/download/checksums.sha256", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprintf(w, "%s chatserver.exe\n", checksumHex) + }) + mux.HandleFunc("/download/server-update-manifest.json", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(manifest) + }) + mux.HandleFunc("/download/server-update-manifest.json.sig", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(manifestSignature) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + tmpDir := t.TempDir() + dest := filepath.Join(tmpDir, "chatserver.exe") + + u.httpClient = &http.Client{Transport: &rewriteTransport{srv.URL}} + + err := u.DownloadAndVerify( + context.Background(), + "v1.0.0", + "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe", + "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/checksums.sha256", + "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", + dest, + ) + if err == nil { + t.Fatal("DownloadAndVerify should fail when signature asset is missing") + } +} + +func TestDownloadAndVerify_InvalidSignature(t *testing.T) { + content := []byte("real binary content for verification") + otherContent := []byte("tampered bytes") + hash := sha256.Sum256(content) + checksumHex := hex.EncodeToString(hash[:]) + u, privateKey := newSignedTestUpdater(t, "", "1.0.0") + signature := signTestAsset(t, privateKey, otherContent) + manifest := []byte(`{"version":"v1.0.0","asset":"chatserver.exe","sha256":"` + checksumHex + `"}`) + manifestSignature := signTestAsset(t, privateKey, manifest) + + mux := http.NewServeMux() + mux.HandleFunc("/download/chatserver.exe", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(content) + }) + mux.HandleFunc("/download/checksums.sha256", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprintf(w, "%s chatserver.exe\n", checksumHex) + }) + mux.HandleFunc("/download/chatserver.exe.sig", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(signature) + }) + mux.HandleFunc("/download/server-update-manifest.json", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(manifest) + }) + mux.HandleFunc("/download/server-update-manifest.json.sig", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(manifestSignature) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + tmpDir := t.TempDir() + dest := filepath.Join(tmpDir, "chatserver.exe") + + u.httpClient = &http.Client{Transport: &rewriteTransport{srv.URL}} + err := u.DownloadAndVerify( + context.Background(), + "v1.0.0", + "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe", + "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/checksums.sha256", + "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", + dest, + ) + if err == nil { + t.Fatal("DownloadAndVerify should fail on invalid signature") + } + if _, statErr := os.Stat(dest); !os.IsNotExist(statErr) { + t.Error("file should be removed after signature verification failure") + } +} + +func TestDownloadAndVerify_MalformedSignature(t *testing.T) { + content := []byte("real binary content for verification") + hash := sha256.Sum256(content) + checksumHex := hex.EncodeToString(hash[:]) + u, privateKey := newSignedTestUpdater(t, "", "1.0.0") + manifest := []byte(`{"version":"v1.0.0","asset":"chatserver.exe","sha256":"` + checksumHex + `"}`) + manifestSignature := signTestAsset(t, privateKey, manifest) + + mux := http.NewServeMux() + mux.HandleFunc("/download/chatserver.exe", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(content) + }) + mux.HandleFunc("/download/checksums.sha256", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprintf(w, "%s chatserver.exe\n", checksumHex) + }) + mux.HandleFunc("/download/chatserver.exe.sig", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("not-a-valid-signature")) + }) + mux.HandleFunc("/download/server-update-manifest.json", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(manifest) + }) + mux.HandleFunc("/download/server-update-manifest.json.sig", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(manifestSignature) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + tmpDir := t.TempDir() + dest := filepath.Join(tmpDir, "chatserver.exe") + + u.httpClient = &http.Client{Transport: &rewriteTransport{srv.URL}} + err := u.DownloadAndVerify( + context.Background(), + "v1.0.0", + "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe", + "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/checksums.sha256", + "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", + dest, + ) + if err == nil { + t.Fatal("DownloadAndVerify should fail on malformed signature") + } + if _, statErr := os.Stat(dest); !os.IsNotExist(statErr) { + t.Error("file should be removed after malformed signature") + } +} + +func TestDownloadAndVerify_ManifestVersionMismatch(t *testing.T) { + content := []byte("real binary content for verification") + hash := sha256.Sum256(content) + checksumHex := hex.EncodeToString(hash[:]) + u, privateKey := newSignedTestUpdater(t, "", "1.0.0") + signature := signTestAsset(t, privateKey, content) + manifest := []byte(`{"version":"v0.9.0","asset":"chatserver.exe","sha256":"` + checksumHex + `"}`) + manifestSignature := signTestAsset(t, privateKey, manifest) + + mux := http.NewServeMux() + mux.HandleFunc("/download/chatserver.exe", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(content) + }) + mux.HandleFunc("/download/checksums.sha256", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprintf(w, "%s chatserver.exe\n", checksumHex) + }) + mux.HandleFunc("/download/chatserver.exe.sig", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(signature) + }) + mux.HandleFunc("/download/server-update-manifest.json", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(manifest) + }) + mux.HandleFunc("/download/server-update-manifest.json.sig", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(manifestSignature) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + dest := filepath.Join(t.TempDir(), "chatserver.exe") + u.httpClient = &http.Client{Transport: &rewriteTransport{srv.URL}} + err := u.DownloadAndVerify( + context.Background(), + "v1.0.0", + "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe", + "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/checksums.sha256", + "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", + dest, + ) + if err == nil { + t.Fatal("DownloadAndVerify should fail on mismatched signed manifest version") + } + if _, statErr := os.Stat(dest); !os.IsNotExist(statErr) { + t.Error("file should be removed after manifest verification failure") + } +} + // rewriteTransport rewrites GitHub release URLs to a local test server. type rewriteTransport struct { target string diff --git a/Server/ws/export_test.go b/Server/ws/export_test.go index 744feb69..f246be60 100644 --- a/Server/ws/export_test.go +++ b/Server/ws/export_test.go @@ -206,5 +206,5 @@ func (h *Hub) HandleWebhookParticipantLeftForTest(userID int64, channelID int64, Name: roomName, }, } - h.handleWebhookParticipantLeft(event) + h.handleWebhookParticipantLeft(context.Background(), event) } diff --git a/Server/ws/livekit_webhook.go b/Server/ws/livekit_webhook.go index 83787939..5ff3e774 100644 --- a/Server/ws/livekit_webhook.go +++ b/Server/ws/livekit_webhook.go @@ -80,9 +80,9 @@ func (h *Hub) NewLiveKitWebhookHandler(apiKey, apiSecret string) http.HandlerFun switch event.Event { case "participant_joined": - h.handleWebhookParticipantJoined(&event) + h.handleWebhookParticipantJoined(r.Context(), &event) case "participant_left": - h.handleWebhookParticipantLeft(&event) + h.handleWebhookParticipantLeft(r.Context(), &event) default: slog.Debug("livekit webhook: unhandled event", "event", event.Event) } @@ -122,7 +122,7 @@ func parseRoomChannelID(roomName string) (int64, error) { return strconv.ParseInt(roomName[8:], 10, 64) } -func (h *Hub) handleWebhookParticipantJoined(event *livekit.WebhookEvent) { +func (h *Hub) handleWebhookParticipantJoined(_ context.Context, event *livekit.WebhookEvent) { p := event.GetParticipant() room := event.GetRoom() if p == nil || room == nil { @@ -157,7 +157,7 @@ func (h *Hub) handleWebhookParticipantJoined(event *livekit.WebhookEvent) { slog.Warn("livekit webhook: rogue participant_joined — no matching voice state, removing", "user_id", userID, "channel_id", channelID) if h.livekit != nil { - if rmErr := h.livekit.RemoveParticipant(channelID, userID, joinToken); rmErr != nil { + if rmErr := h.livekit.RemoveParticipant(channelID, userID, joinToken); rmErr != nil { //nolint:contextcheck // RemoveParticipant manages its own timeout context slog.Error("livekit webhook: failed to remove rogue participant", "error", rmErr, "user_id", userID, "channel_id", channelID) } @@ -170,7 +170,7 @@ func (h *Hub) handleWebhookParticipantJoined(event *livekit.WebhookEvent) { "user_id", userID, "channel_id", channelID, "expected_token", state.JoinedAt, "got_token", joinToken) if h.livekit != nil { - if rmErr := h.livekit.RemoveParticipant(channelID, userID, joinToken); rmErr != nil { + if rmErr := h.livekit.RemoveParticipant(channelID, userID, joinToken); rmErr != nil { //nolint:contextcheck // RemoveParticipant manages its own timeout context slog.Error("livekit webhook: failed to remove stale participant", "error", rmErr, "user_id", userID, "channel_id", channelID) } @@ -180,7 +180,7 @@ func (h *Hub) handleWebhookParticipantJoined(event *livekit.WebhookEvent) { } } -func (h *Hub) handleWebhookParticipantLeft(event *livekit.WebhookEvent) { +func (h *Hub) handleWebhookParticipantLeft(ctx context.Context, event *livekit.WebhookEvent) { p := event.GetParticipant() room := event.GetRoom() if p == nil || room == nil { @@ -220,7 +220,7 @@ func (h *Hub) handleWebhookParticipantLeft(event *livekit.WebhookEvent) { c.clearVoiceState() if h.db != nil { - if err := leaveVoiceChannelWithRetry(context.Background(), h, userID, channelID, joinToken); err != nil { + if err := leaveVoiceChannelWithRetry(ctx, h, userID, channelID, joinToken); err != nil { slog.Error("livekit webhook: LeaveVoiceChannel exhausted retries", "error", err, "user_id", userID, "channel_id", channelID) } diff --git a/Server/ws/serve.go b/Server/ws/serve.go index f277fb70..2b5e731c 100644 --- a/Server/ws/serve.go +++ b/Server/ws/serve.go @@ -232,7 +232,7 @@ func (h *Hub) handleFreshConnect( // different identity and won't be removed. Use a hub-stop-aware // context to avoid goroutine leaks on shutdown. staleChID, staleUserID, staleJoinToken := vs.ChannelID, c.userID, vs.JoinedAt - go func() { + go func() { //nolint:contextcheck // goroutine intentionally detaches from request context; lifecycle managed via h.stop select { case <-h.stop: return diff --git a/docs/deployment.md b/docs/deployment.md index 178ce2ba..23fe0853 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -183,8 +183,10 @@ Restoring replaces the live database file. A pre-restore safety backup is create The server checks GitHub Releases for updates: - Compares semver versions - Results are cached for 1 hour -- Downloads `chatserver.exe` with SHA256 checksum verification -- On restart, the old binary is cleaned up +- Downloads `chatserver.exe` with detached Ed25519/minisign signature verification +- Verifies a signed `server-update-manifest.json` that binds the binary hash to the release version +- Cross-checks the binary SHA256 against `checksums.sha256` +- On restart, the current binary is rotated to `chatserver.exe.old` before the new binary takes its place Set `github.token` in config for higher API rate limits (5000/hr vs 60/hr unauthenticated). diff --git a/docs/security.md b/docs/security.md index 797dda2b..b54cfe7d 100644 --- a/docs/security.md +++ b/docs/security.md @@ -83,7 +83,7 @@ The Tauri desktop client implements the following security measures: ## Known Limitations -- No code signing yet -- binaries are verified via SHA256 checksums only +- Server auto-updates depend on a dedicated pinned minisign/Ed25519 server release key in [Server/updater/server_update_public_key.txt](Server/updater/server_update_public_key.txt) and a signed release manifest that binds the shipped binary hash to the release version; Windows Authenticode/SmartScreen code signing is still separate work - The Tenor API key is hardcoded (Google's public anonymous key) — consider build-time injection for production - CSP `connect-src` allows `https:` to any host (necessary for self-hosted server URLs not known at build time)