mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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"}`)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -206,5 +206,5 @@ func (h *Hub) HandleWebhookParticipantLeftForTest(userID int64, channelID int64,
|
||||
Name: roomName,
|
||||
},
|
||||
}
|
||||
h.handleWebhookParticipantLeft(event)
|
||||
h.handleWebhookParticipantLeft(context.Background(), event)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user