From e65a7d6a70e4e5371cc81ab1e56f1e7ed0aa5f5c Mon Sep 17 00:00:00 2001 From: J3vb Date: Thu, 2 Apr 2026 23:35:11 +0200 Subject: [PATCH] fix: harden server update signing --- .github/workflows/release.yml | 27 ++ README.md | 17 +- Server/admin/update_handlers.go | 9 +- Server/admin/update_handlers_test.go | 56 +++- Server/go.mod | 1 + Server/go.sum | 2 + Server/updater/server_update_public_key.txt | 1 + Server/updater/updater.go | 232 +++++++++++-- Server/updater/updater_test.go | 347 +++++++++++++++++++- docs/deployment.md | 6 +- docs/security.md | 2 +- 11 files changed, 654 insertions(+), 46 deletions(-) create mode 100644 Server/updater/server_update_public_key.txt diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 64382af4..1d93ac17 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -85,6 +85,30 @@ jobs: } $lines -join "`n" | Out-File -FilePath checksums.sha256 -Encoding utf8 -NoNewline + $manifest = @{ + version = "v$env:VERSION" + asset = "chatserver.exe" + sha256 = $serverHash + } | ConvertTo-Json -Compress + $manifest | Out-File -FilePath Server/server-update-manifest.json -Encoding utf8 -NoNewline + + - name: Sign server update assets + working-directory: Client/tauri-client + shell: pwsh + 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: | + $keyPath = Join-Path $env:RUNNER_TEMP 'owncord-server-update.key' + [System.IO.File]::WriteAllText($keyPath, $env:SERVER_UPDATE_SIGNING_PRIVATE_KEY) + try { + npx tauri signer sign -k $keyPath -p "$env:SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD" ../../Server/chatserver.exe + npx tauri signer sign -k $keyPath -p "$env:SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD" ../../Server/server-update-manifest.json + } + finally { + Remove-Item $keyPath -Force -ErrorAction SilentlyContinue + } + - name: Install root dependencies (changelogen) run: npm ci @@ -99,6 +123,9 @@ jobs: run: | ASSETS=( Server/chatserver.exe + Server/chatserver.exe.sig + Server/server-update-manifest.json + Server/server-update-manifest.json.sig "${{ steps.artifacts.outputs.installer_path }}" checksums.sha256 ) diff --git a/README.md b/README.md index 0e916b2b..eb3d1a09 100644 --- a/README.md +++ b/README.md @@ -306,13 +306,20 @@ Key settings: ## Auto-Updates The client checks for updates after connecting to the server. -Updates are Ed25519-signed and verified before install. +Client updates are Ed25519-signed and verified before install. +Server auto-updates use a separate minisign/Ed25519 signing key, verify `chatserver.exe.sig`, and require a signed `server-update-manifest.json` that binds the binary hash to the release version before apply. -To enable signed releases in CI, add these GitHub repository secrets: +For maintainers publishing signed releases from GitHub Actions, configure these repository secrets: -- `TAURI_SIGNING_PRIVATE_KEY` — Ed25519 private key - (via `npx tauri signer generate`) -- `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` — key password +- `TAURI_SIGNING_PRIVATE_KEY` — client updater private key + (via `npx tauri signer generate`) +- `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` — client updater key password +- `SERVER_UPDATE_SIGNING_PRIVATE_KEY` — server updater private key +- `SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD` — server updater key password + +These are secret names only. Do not commit private key material or passphrases to the repository. + +When rotating the server updater key, also update [Server/updater/server_update_public_key.txt](Server/updater/server_update_public_key.txt). For live deployments that rely on server auto-update continuity, treat key rotation as a staged rollover rather than a one-step secret swap. ## Documentation diff --git a/Server/admin/update_handlers.go b/Server/admin/update_handlers.go index 740a967f..3da3acb6 100644 --- a/Server/admin/update_handlers.go +++ b/Server/admin/update_handlers.go @@ -12,6 +12,7 @@ import ( "time" "github.com/owncord/server/updater" + "golang.org/x/mod/semver" ) // handleCheckUpdate returns the current update status. @@ -47,10 +48,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 } @@ -74,7 +79,7 @@ func handleApplyUpdate(u *updater.Updater, hub HubBroadcaster, _ string) http.Ha ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) defer cancel() - if err := u.DownloadAndVerify(ctx, info.DownloadURL, info.ChecksumURL, newPath); err != nil { + if err := u.DownloadAndVerify(ctx, info.Latest, info.DownloadURL, info.ChecksumURL, info.SignatureURL, info.ManifestURL, info.ManifestSignatureURL, newPath); err != nil { slog.Error("update download/verify failed", "err", err) writeErr(w, http.StatusBadGateway, "DOWNLOAD_FAILED", "download or verification failed — see server logs") return diff --git a/Server/admin/update_handlers_test.go b/Server/admin/update_handlers_test.go index cf1fbd14..0bcd14c2 100644 --- a/Server/admin/update_handlers_test.go +++ b/Server/admin/update_handlers_test.go @@ -21,6 +21,9 @@ func TestAdminAPI_CheckUpdate_OK(t *testing.T) { "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"}, + {"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"}, }, }) })) @@ -46,6 +49,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) { @@ -197,7 +239,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) { @@ -268,6 +310,18 @@ func TestAdminAPI_ApplyUpdate_DownloadFails(t *testing.T) { "name": "checksums.sha256", "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/checksums.sha256", }, + { + "name": "chatserver.exe.sig", + "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/chatserver.exe.sig", + }, + { + "name": "server-update-manifest.json", + "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/server-update-manifest.json", + }, + { + "name": "server-update-manifest.json.sig", + "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/server-update-manifest.json.sig", + }, }, }) _ = mockGHURL // suppress unused warning diff --git a/Server/go.mod b/Server/go.mod index ef7df5e8..a8865883 100644 --- a/Server/go.mod +++ b/Server/go.mod @@ -3,6 +3,7 @@ module github.com/owncord/server go 1.25.0 require ( + aead.dev/minisign v0.3.0 github.com/go-chi/chi/v5 v5.2.5 github.com/google/uuid v1.6.0 github.com/knadh/koanf/parsers/yaml v1.1.0 diff --git a/Server/go.sum b/Server/go.sum index 446feef5..f1a4e8e9 100644 --- a/Server/go.sum +++ b/Server/go.sum @@ -1,3 +1,5 @@ +aead.dev/minisign v0.3.0 h1:8Xafzy5PEVZqYDNP60yJHARlW1eOQtsKNp/Ph2c0vRA= +aead.dev/minisign v0.3.0/go.mod h1:NLvG3Uoq3skkRMDuc3YHpWUTMTrSExqm+Ij73W13F6Y= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 h1:PMmTMyvHScV9Mn8wc6ASge9uRcHy0jtqPd+fM35LmsQ= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= buf.build/go/protovalidate v1.1.2 h1:83vYHoY8f34hB8MeitGaYE3CGVPFxwdEUuskh5qQpA0= diff --git a/Server/updater/server_update_public_key.txt b/Server/updater/server_update_public_key.txt new file mode 100644 index 00000000..65e9d690 --- /dev/null +++ b/Server/updater/server_update_public_key.txt @@ -0,0 +1 @@ +dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEFCQjA3OEZEOEVCRkY1RkEKUldUNjliK08vWGl3cStHamIrVHhNbWNLT3Bwb3ppeTIwdDBkQkFlaytHSWVqZkExSmFxRHZDVVoK \ No newline at end of file diff --git a/Server/updater/updater.go b/Server/updater/updater.go index 209f6825..9ae733d6 100644 --- a/Server/updater/updater.go +++ b/Server/updater/updater.go @@ -3,8 +3,11 @@ package updater import ( + "bytes" "context" "crypto/sha256" + _ "embed" + "encoding/base64" "encoding/hex" "encoding/json" "fmt" @@ -12,33 +15,58 @@ import ( "net/http" neturl "net/url" "os" + "path" "path/filepath" "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 - binaryAsset = "chatserver.exe" - checksumAsset = "checksums.sha256" + defaultBaseURL = "https://api.github.com" + cacheTTL = 1 * time.Hour + errorCacheTTL = 5 * time.Minute + binaryAsset = "chatserver.exe" + checksumAsset = "checksums.sha256" + signatureAsset = binaryAsset + ".sig" + manifestAsset = "server-update-manifest.json" + manifestSigAsset = manifestAsset + ".sig" ) +// 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. @@ -81,6 +109,7 @@ type Updater struct { errCacheExpiry time.Time mu syncutil.Mutex httpClient *http.Client + signingKeyText string } // NewUpdater creates an Updater for the given repository. @@ -91,6 +120,7 @@ func NewUpdater(currentVersion, githubToken, repoOwner, repoName string) *Update repoOwner: repoOwner, repoName: repoName, httpClient: &http.Client{Timeout: 30 * time.Second}, + signingKeyText: defaultServerSignaturePublicKey, } } @@ -187,7 +217,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)) for _, asset := range release.Assets { assets = append(assets, Asset{ @@ -198,21 +228,37 @@ func (u *Updater) fetchLatestRelease(ctx context.Context) (UpdateInfo, error) { downloadURL = asset.BrowserDownloadURL } else if strings.EqualFold(asset.Name, checksumAsset) { checksumURL = asset.BrowserDownloadURL + } else if strings.EqualFold(asset.Name, signatureAsset) { + signatureURL = asset.BrowserDownloadURL + } else if strings.EqualFold(asset.Name, manifestAsset) { + manifestURL = asset.BrowserDownloadURL + } else if 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 { @@ -224,35 +270,72 @@ func (u *Updater) ValidateDownloadURL(url string) error { } // DownloadAndVerify downloads the binary from downloadURL, fetches the -// checksum file from checksumURL, and verifies the SHA256 hash matches. -// On checksum mismatch the downloaded file is removed. -func (u *Updater) DownloadAndVerify(ctx context.Context, downloadURL, checksumURL, destPath string) error { +// 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 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) + } // Fetch checksum file. checksumData, err := u.fetchBody(ctx, checksumURL) if err != nil { return fmt.Errorf("fetching checksums: %w", err) } + signatureData, err := u.fetchBody(ctx, signatureURL) + if err != nil { + return fmt.Errorf("fetching signature: %w", err) + } + 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) + } - destFilename := filepath.Base(destPath) - expectedHash, err := u.ParseChecksumFile(checksumData, destFilename) + 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) + } // Download the binary. 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 { + if err := u.VerifyChecksum(destPath, manifest.SHA256); err != nil { // Remove the invalid file. _ = os.Remove(destPath) return err @@ -261,6 +344,99 @@ func (u *Updater) DownloadAndVerify(ctx context.Context, downloadURL, checksumUR return nil } +// VerifyReleaseManifest checks the detached signature on the release manifest +// and ensures the manifest binds the downloaded asset to the expected version. +func (u *Updater) VerifyReleaseManifest(manifestData, signatureText []byte, expectedVersion, expectedAsset string) (releaseManifest, error) { + if err := u.verifySignatureReader(bytes.NewReader(manifestData), signatureText, manifestAsset); err != nil { + return releaseManifest{}, fmt.Errorf("verifying release manifest signature: %w", err) + } + + var manifest releaseManifest + if err := json.Unmarshal(manifestData, &manifest); err != nil { + return releaseManifest{}, fmt.Errorf("parsing release manifest: %w", err) + } + manifest.Version = ensureVPrefix(strings.TrimSpace(manifest.Version)) + manifest.Asset = strings.TrimSpace(manifest.Asset) + manifest.SHA256 = strings.ToLower(strings.TrimSpace(manifest.SHA256)) + + if manifest.Version == "" || manifest.Asset == "" || manifest.SHA256 == "" { + return releaseManifest{}, fmt.Errorf("release manifest is missing required fields") + } + if manifest.Version != ensureVPrefix(expectedVersion) { + return releaseManifest{}, fmt.Errorf("release manifest version %q does not match release %q", manifest.Version, ensureVPrefix(expectedVersion)) + } + if manifest.Asset != expectedAsset { + return releaseManifest{}, fmt.Errorf("release manifest asset %q does not match expected asset %q", manifest.Asset, expectedAsset) + } + if len(manifest.SHA256) != sha256.Size*2 { + return releaseManifest{}, fmt.Errorf("release manifest checksum for %s has invalid length", manifest.Asset) + } + if _, err := hex.DecodeString(manifest.SHA256); err != nil { + return releaseManifest{}, fmt.Errorf("release manifest checksum for %s is invalid: %w", manifest.Asset, err) + } + + return manifest, nil +} + +// VerifySignature checks whether the detached minisign signature matches the +// file contents using the pinned server-update public key. +func (u *Updater) VerifySignature(filePath string, signatureText []byte) error { + f, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("opening file for signature verification: %w", err) + } + defer f.Close() //nolint:errcheck + + return u.verifySignatureReader(f, signatureText, filepath.Base(filePath)) +} + +func (u *Updater) verifySignatureReader(reader io.Reader, signatureText []byte, subject string) error { + publicKey, err := u.serverSignaturePublicKey() + if err != nil { + return fmt.Errorf("loading update signing key: %w", err) + } + + verifier := minisign.NewReader(reader) + if _, err := io.Copy(io.Discard, verifier); err != nil { + return fmt.Errorf("reading file for signature verification: %w", err) + } + + normalizedSig := []byte(strings.TrimSpace(string(signatureText))) + var parsedSig minisign.Signature + if err := parsedSig.UnmarshalText(normalizedSig); err != nil { + return fmt.Errorf("invalid update signature format: %w", err) + } + + if !verifier.Verify(publicKey, normalizedSig) { + return fmt.Errorf("signature verification failed for %s", subject) + } + return nil +} + +func (u *Updater) serverSignaturePublicKey() (minisign.PublicKey, error) { + decoded, err := base64.StdEncoding.DecodeString(u.signingKeyText) + if err != nil { + return minisign.PublicKey{}, fmt.Errorf("decoding base64 public key: %w", err) + } + var publicKey minisign.PublicKey + if err := publicKey.UnmarshalText(decoded); err != nil { + return minisign.PublicKey{}, fmt.Errorf("parsing minisign public key: %w", err) + } + return publicKey, nil +} + +func assetFilenameFromURL(rawURL string) (string, error) { + parsed, err := neturl.Parse(rawURL) + if err != nil { + return "", err + } + filename := path.Base(parsed.Path) + if filename == "." || filename == "/" || filename == "" { + return "", fmt.Errorf("missing asset filename in URL %q", rawURL) + } + return filename, nil +} + // VerifyChecksum computes the SHA256 hash of the file at filePath and // compares it (case-insensitive) against expectedHash. func (u *Updater) VerifyChecksum(filePath, expectedHash string) error { diff --git a/Server/updater/updater_test.go b/Server/updater/updater_test.go index cad273be..92f0218c 100644 --- a/Server/updater/updater_test.go +++ b/Server/updater/updater_test.go @@ -4,9 +4,11 @@ import ( "bytes" "context" "crypto/sha256" + "encoding/base64" "encoding/hex" "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "os" @@ -14,6 +16,8 @@ import ( "sync/atomic" "testing" "time" + + "aead.dev/minisign" ) // ghRelease mirrors the GitHub release API response shape. @@ -38,6 +42,9 @@ func newTestRelease(tag, body, htmlURL string, assetDownloadBase string) ghRelea Assets: []ghAsset{ {Name: "chatserver.exe", BrowserDownloadURL: assetDownloadBase + "/chatserver.exe"}, {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"}, }, } } @@ -65,6 +72,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") @@ -91,6 +122,44 @@ func TestCheckForUpdate_NewerVersionAvailable(t *testing.T) { if info.ChecksumURL == "" { t.Error("expected non-empty ChecksumURL") } + 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) { @@ -265,6 +334,46 @@ func TestParseChecksumFile_FileNotFound(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) { @@ -381,6 +490,10 @@ func TestDownloadAndVerify_Success(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) { @@ -389,24 +502,35 @@ func TestDownloadAndVerify_Success(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) } @@ -420,7 +544,7 @@ func TestDownloadAndVerify_Success(t *testing.T) { 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") } @@ -429,7 +553,7 @@ func TestDownloadAndVerify_InvalidDownloadURL(t *testing.T) { func TestDownloadAndVerify_InvalidChecksumURL(t *testing.T) { u := NewUpdater("1.0.0", "", "J3vb", "OwnCord") downloadURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe" - err := u.DownloadAndVerify(context.Background(), downloadURL, "https://evil.com/sum", "/tmp/out") + err := u.DownloadAndVerify(context.Background(), "v1.0.0", downloadURL, "https://evil.com/sum", "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe.sig", "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json", "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json.sig", "/tmp/out") if err == nil { t.Error("DownloadAndVerify should reject invalid checksum URL") } @@ -438,6 +562,12 @@ func TestDownloadAndVerify_InvalidChecksumURL(t *testing.T) { func TestDownloadAndVerify_ChecksumMismatch(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) { @@ -446,19 +576,30 @@ func TestDownloadAndVerify_ChecksumMismatch(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") } @@ -469,6 +610,198 @@ func TestDownloadAndVerify_ChecksumMismatch(t *testing.T) { } } +func TestDownloadAndVerify_MissingSignature(t *testing.T) { + content := []byte("real binary content for verification") + hash := sha256.Sum256(content) + checksumHex := hex.EncodeToString(hash[:]) + u, privateKey := newSignedTestUpdater(t, "", "1.0.0") + manifest := []byte(`{"version":"v1.0.0","asset":"chatserver.exe","sha256":"` + checksumHex + `"}`) + manifestSignature := signTestAsset(t, privateKey, manifest) + + mux := http.NewServeMux() + mux.HandleFunc("/download/chatserver.exe", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(content) + }) + mux.HandleFunc("/download/checksums.sha256", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprintf(w, "%s chatserver.exe\n", checksumHex) + }) + mux.HandleFunc("/download/server-update-manifest.json", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(manifest) + }) + mux.HandleFunc("/download/server-update-manifest.json.sig", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(manifestSignature) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + tmpDir := t.TempDir() + dest := filepath.Join(tmpDir, "chatserver.exe") + + u.httpClient = &http.Client{Transport: &rewriteTransport{srv.URL}} + + err := u.DownloadAndVerify( + context.Background(), + "v1.0.0", + "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe", + "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/checksums.sha256", + "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe.sig", + "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json", + "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json.sig", + dest, + ) + if err == nil { + t.Fatal("DownloadAndVerify should fail when signature asset is missing") + } +} + +func TestDownloadAndVerify_InvalidSignature(t *testing.T) { + content := []byte("real binary content for verification") + otherContent := []byte("tampered bytes") + hash := sha256.Sum256(content) + checksumHex := hex.EncodeToString(hash[:]) + u, privateKey := newSignedTestUpdater(t, "", "1.0.0") + signature := signTestAsset(t, privateKey, otherContent) + manifest := []byte(`{"version":"v1.0.0","asset":"chatserver.exe","sha256":"` + checksumHex + `"}`) + manifestSignature := signTestAsset(t, privateKey, manifest) + + mux := http.NewServeMux() + mux.HandleFunc("/download/chatserver.exe", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(content) + }) + mux.HandleFunc("/download/checksums.sha256", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprintf(w, "%s chatserver.exe\n", checksumHex) + }) + mux.HandleFunc("/download/chatserver.exe.sig", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(signature) + }) + mux.HandleFunc("/download/server-update-manifest.json", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(manifest) + }) + mux.HandleFunc("/download/server-update-manifest.json.sig", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(manifestSignature) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + tmpDir := t.TempDir() + dest := filepath.Join(tmpDir, "chatserver.exe") + + u.httpClient = &http.Client{Transport: &rewriteTransport{srv.URL}} + err := u.DownloadAndVerify( + context.Background(), + "v1.0.0", + "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe", + "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/checksums.sha256", + "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe.sig", + "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json", + "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json.sig", + dest, + ) + if err == nil { + t.Fatal("DownloadAndVerify should fail on invalid signature") + } + if _, statErr := os.Stat(dest); !os.IsNotExist(statErr) { + t.Error("file should be removed after signature verification failure") + } +} + +func TestDownloadAndVerify_MalformedSignature(t *testing.T) { + content := []byte("real binary content for verification") + hash := sha256.Sum256(content) + checksumHex := hex.EncodeToString(hash[:]) + u, privateKey := newSignedTestUpdater(t, "", "1.0.0") + manifest := []byte(`{"version":"v1.0.0","asset":"chatserver.exe","sha256":"` + checksumHex + `"}`) + manifestSignature := signTestAsset(t, privateKey, manifest) + + mux := http.NewServeMux() + mux.HandleFunc("/download/chatserver.exe", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(content) + }) + mux.HandleFunc("/download/checksums.sha256", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprintf(w, "%s chatserver.exe\n", checksumHex) + }) + mux.HandleFunc("/download/chatserver.exe.sig", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("not-a-valid-signature")) + }) + mux.HandleFunc("/download/server-update-manifest.json", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(manifest) + }) + mux.HandleFunc("/download/server-update-manifest.json.sig", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(manifestSignature) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + tmpDir := t.TempDir() + dest := filepath.Join(tmpDir, "chatserver.exe") + + u.httpClient = &http.Client{Transport: &rewriteTransport{srv.URL}} + err := u.DownloadAndVerify( + context.Background(), + "v1.0.0", + "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe", + "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/checksums.sha256", + "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe.sig", + "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json", + "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json.sig", + dest, + ) + if err == nil { + t.Fatal("DownloadAndVerify should fail on malformed signature") + } + if _, statErr := os.Stat(dest); !os.IsNotExist(statErr) { + t.Error("file should be removed after malformed signature") + } +} + +func TestDownloadAndVerify_ManifestVersionMismatch(t *testing.T) { + content := []byte("real binary content for verification") + hash := sha256.Sum256(content) + checksumHex := hex.EncodeToString(hash[:]) + u, privateKey := newSignedTestUpdater(t, "", "1.0.0") + signature := signTestAsset(t, privateKey, content) + manifest := []byte(`{"version":"v0.9.0","asset":"chatserver.exe","sha256":"` + checksumHex + `"}`) + manifestSignature := signTestAsset(t, privateKey, manifest) + + mux := http.NewServeMux() + mux.HandleFunc("/download/chatserver.exe", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(content) + }) + mux.HandleFunc("/download/checksums.sha256", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprintf(w, "%s chatserver.exe\n", checksumHex) + }) + mux.HandleFunc("/download/chatserver.exe.sig", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(signature) + }) + mux.HandleFunc("/download/server-update-manifest.json", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(manifest) + }) + mux.HandleFunc("/download/server-update-manifest.json.sig", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(manifestSignature) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + dest := filepath.Join(t.TempDir(), "chatserver.exe") + u.httpClient = &http.Client{Transport: &rewriteTransport{srv.URL}} + err := u.DownloadAndVerify( + context.Background(), + "v1.0.0", + "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe", + "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/checksums.sha256", + "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe.sig", + "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json", + "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json.sig", + dest, + ) + if err == nil { + t.Fatal("DownloadAndVerify should fail on mismatched signed manifest version") + } + if _, statErr := os.Stat(dest); !os.IsNotExist(statErr) { + t.Error("file should be removed after manifest verification failure") + } +} + // rewriteTransport rewrites GitHub release URLs to a local test server. type rewriteTransport struct { target string diff --git a/docs/deployment.md b/docs/deployment.md index 178ce2ba..23fe0853 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -183,8 +183,10 @@ Restoring replaces the live database file. A pre-restore safety backup is create The server checks GitHub Releases for updates: - Compares semver versions - Results are cached for 1 hour -- Downloads `chatserver.exe` with SHA256 checksum verification -- On restart, the old binary is cleaned up +- Downloads `chatserver.exe` with detached Ed25519/minisign signature verification +- Verifies a signed `server-update-manifest.json` that binds the binary hash to the release version +- Cross-checks the binary SHA256 against `checksums.sha256` +- On restart, the current binary is rotated to `chatserver.exe.old` before the new binary takes its place Set `github.token` in config for higher API rate limits (5000/hr vs 60/hr unauthenticated). diff --git a/docs/security.md b/docs/security.md index 797dda2b..b54cfe7d 100644 --- a/docs/security.md +++ b/docs/security.md @@ -83,7 +83,7 @@ The Tauri desktop client implements the following security measures: ## Known Limitations -- No code signing yet -- binaries are verified via SHA256 checksums only +- Server auto-updates depend on a dedicated pinned minisign/Ed25519 server release key in [Server/updater/server_update_public_key.txt](Server/updater/server_update_public_key.txt) and a signed release manifest that binds the shipped binary hash to the release version; Windows Authenticode/SmartScreen code signing is still separate work - The Tenor API key is hardcoded (Google's public anonymous key) — consider build-time injection for production - CSP `connect-src` allows `https:` to any host (necessary for self-hosted server URLs not known at build time)