mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
fix: harden server update signing
This commit is contained in:
@@ -0,0 +1 @@
|
||||
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEFCQjA3OEZEOEVCRkY1RkEKUldUNjliK08vWGl3cStHamIrVHhNbWNLT3Bwb3ppeTIwdDBkQkFlaytHSWVqZkExSmFxRHZDVVoK
|
||||
+204
-28
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user