chore: merge dev — resolve conflicts between Linux support and signing hardening

- release.yml: integrate signing/manifest/changelog steps with new
  multi-platform artifact layout (windows/ + linux/ dirs)
- updater.go: combine Linux tar.gz support with existing signature
  verification; merge platform-aware asset matching into switch
- updater_test.go: keep PR Linux tests + dev signing/manifest tests
This commit is contained in:
J3vb
2026-04-03 08:59:57 +02:00
26 changed files with 787 additions and 95 deletions
+3 -2
View File
@@ -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
+42 -1
View File
@@ -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[@]}"
@@ -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,
@@ -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,
@@ -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,
@@ -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,
+12 -5
View File
@@ -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
+3 -3
View File
@@ -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
}
+8 -3
View File
@@ -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
+55 -1
View File
@@ -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
+2 -2
View File
@@ -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 {
+7
View File
@@ -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
)
+29 -2
View File
@@ -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
}
+6 -8
View File
@@ -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
}
}
}
+1 -1
View File
@@ -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"}`)
}
+3 -3
View File
@@ -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
+1
View File
@@ -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
+2
View File
@@ -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=
@@ -0,0 +1 @@
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEFCQjA3OEZEOEVCRkY1RkEKUldUNjliK08vWGl3cStHamIrVHhNbWNLT3Bwb3ppeTIwdDBkQkFlaytHSWVqZkExSmFxRHZDVVoK
+214 -36
View File
@@ -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 {
+377 -12
View File
@@ -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
+1 -1
View File
@@ -206,5 +206,5 @@ func (h *Hub) HandleWebhookParticipantLeftForTest(userID int64, channelID int64,
Name: roomName,
},
}
h.handleWebhookParticipantLeft(event)
h.handleWebhookParticipantLeft(context.Background(), event)
}
+7 -7
View File
@@ -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)
}
+1 -1
View File
@@ -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
+4 -2
View File
@@ -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).
+1 -1
View File
@@ -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)