fix: harden server update signing

This commit is contained in:
J3vb
2026-04-02 23:35:11 +02:00
parent 8184283ab0
commit e65a7d6a70
11 changed files with 654 additions and 46 deletions
+27
View File
@@ -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
)
+12 -5
View File
@@ -306,13 +306,20 @@ Key settings:
## Auto-Updates
The client checks for updates after connecting to the server.
Updates are Ed25519-signed and verified before install.
Client updates are Ed25519-signed and verified before install.
Server auto-updates use a separate minisign/Ed25519 signing key, verify `chatserver.exe.sig`, and require a signed `server-update-manifest.json` that binds the binary hash to the release version before apply.
To enable signed releases in CI, add these GitHub repository secrets:
For maintainers publishing signed releases from GitHub Actions, configure these repository secrets:
- `TAURI_SIGNING_PRIVATE_KEY` — Ed25519 private key
(via `npx tauri signer generate`)
- `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` — key password
- `TAURI_SIGNING_PRIVATE_KEY` — client updater private key
(via `npx tauri signer generate`)
- `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` — client updater key password
- `SERVER_UPDATE_SIGNING_PRIVATE_KEY` — server updater private key
- `SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD` — server updater key password
These are secret names only. Do not commit private key material or passphrases to the repository.
When rotating the server updater key, also update [Server/updater/server_update_public_key.txt](Server/updater/server_update_public_key.txt). For live deployments that rely on server auto-update continuity, treat key rotation as a staged rollover rather than a one-step secret swap.
## Documentation
+7 -2
View File
@@ -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
+55 -1
View File
@@ -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
+1
View File
@@ -3,6 +3,7 @@ module github.com/owncord/server
go 1.25.0
require (
aead.dev/minisign v0.3.0
github.com/go-chi/chi/v5 v5.2.5
github.com/google/uuid v1.6.0
github.com/knadh/koanf/parsers/yaml v1.1.0
+2
View File
@@ -1,3 +1,5 @@
aead.dev/minisign v0.3.0 h1:8Xafzy5PEVZqYDNP60yJHARlW1eOQtsKNp/Ph2c0vRA=
aead.dev/minisign v0.3.0/go.mod h1:NLvG3Uoq3skkRMDuc3YHpWUTMTrSExqm+Ij73W13F6Y=
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 h1:PMmTMyvHScV9Mn8wc6ASge9uRcHy0jtqPd+fM35LmsQ=
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM=
buf.build/go/protovalidate v1.1.2 h1:83vYHoY8f34hB8MeitGaYE3CGVPFxwdEUuskh5qQpA0=
@@ -0,0 +1 @@
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEFCQjA3OEZEOEVCRkY1RkEKUldUNjliK08vWGl3cStHamIrVHhNbWNLT3Bwb3ppeTIwdDBkQkFlaytHSWVqZkExSmFxRHZDVVoK
+204 -28
View File
@@ -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 {
+340 -7
View File
@@ -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
+4 -2
View File
@@ -183,8 +183,10 @@ Restoring replaces the live database file. A pre-restore safety backup is create
The server checks GitHub Releases for updates:
- Compares semver versions
- Results are cached for 1 hour
- Downloads `chatserver.exe` with SHA256 checksum verification
- On restart, the old binary is cleaned up
- Downloads `chatserver.exe` with detached Ed25519/minisign signature verification
- Verifies a signed `server-update-manifest.json` that binds the binary hash to the release version
- Cross-checks the binary SHA256 against `checksums.sha256`
- On restart, the current binary is rotated to `chatserver.exe.old` before the new binary takes its place
Set `github.token` in config for higher API rate limits (5000/hr vs 60/hr unauthenticated).
+1 -1
View File
@@ -83,7 +83,7 @@ The Tauri desktop client implements the following security measures:
## Known Limitations
- No code signing yet -- binaries are verified via SHA256 checksums only
- Server auto-updates depend on a dedicated pinned minisign/Ed25519 server release key in [Server/updater/server_update_public_key.txt](Server/updater/server_update_public_key.txt) and a signed release manifest that binds the shipped binary hash to the release version; Windows Authenticode/SmartScreen code signing is still separate work
- The Tenor API key is hardcoded (Google's public anonymous key) — consider build-time injection for production
- CSP `connect-src` allows `https:` to any host (necessary for self-hosted server URLs not known at build time)