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
@@ -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