Files
OwnCord/Server/updater/verify.go
T
J3vb 9c9b8be669 feat(b2-2): protocol epoch and negotiation (slim) (#1438)
* feat(b2-2): declare protocol_epoch in the schema and generate both constants

protocol/schema.json gains protocol_epoch (1). genprotocol emits
ws.ProtocolEpoch and PROTOCOL_EPOCH from it; the contract test pins the Go
constant to the schema so a stale regeneration fails the required check.

* feat(b2-2): check the client's protocol epoch in the auth handshake

The auth payload gains epoch (absent = 0). Outside [minClientEpoch,
ProtocolEpoch] the server answers one auth_error with code
protocol_epoch_unsupported, the client/server/min epochs, and a message
naming which side to update, then closes 1008 like every other handshake
failure. minClientEpoch is 0 for epoch 1 only so alpha.4 clients keep
connecting; the epoch-1 fixtures are unchanged.

* feat(b2-2): send the protocol epoch and offer the update on a refused connect

ws.ts sends epoch: PROTOCOL_EPOCH in the auth frame (contract test extended
on purpose). On auth_error code protocol_epoch_unsupported with a newer
server the dispatcher records the host in ui.store.updateRequiredHost and
main.ts mounts the UpdateNotifier on the connect page, so a refused client
gets the same Update Now banner it would have had on the main page.

* feat(b2-2): withhold client releases newer than the server's protocol epoch

The signed server-update manifest gains protocol_epoch (release.yml reads it
from protocol/schema.json). Updater.ReleaseProtocolEpoch verifies the
manifest and reads it; the client-update endpoint answers 204 when the
release's epoch is newer than ws.ProtocolEpoch or the manifest does not
verify. Releases without a manifest are epoch 0 and advertised as before.
Docs: protocol.md Compatibility section, api.md, deployment.md, protocol
README, CHANGELOG Unreleased.

* docs(b2-2): record the slim B2-2 decision and evidence; fold B2-3/B2-4 into it

* ci: prove the protocol_epoch manifest read on every PR, not only at tag time

* fix(b2-2): offer the update on an already-mounted connect page and keep the credential on a protocol refusal

Codex P1: on a first login or startup auto-login no overlay exists before
auth_ok, so a refusal never re-rendered the connect page and the one-time
read of updateRequiredHost missed it. The connect page now subscribes to
it, and a later refusal replaces the banner.

Codex P2: a refusal on reconnect went through the generic logout and
deleted the stored credential although the token is still valid.
clearAuth gets a protocol_epoch reason; main.ts keeps the credential on it
(the skip-auto-login flag is still set and, being sessionStorage, does not
survive the relaunch the update triggers).
2026-08-29 07:23:06 +02:00

335 lines
12 KiB
Go

package updater
import (
"bytes"
"context"
"crypto/sha256"
_ "embed"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"aead.dev/minisign"
)
// 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)
type releaseManifest struct {
Version string `json:"version"`
// Asset/SHA256 bind a single artifact. Releases before the multi-OS
// manifest bound only this pair; newer releases keep it pointing at the
// Windows binary so already-deployed servers can still verify and update.
Asset string `json:"asset"`
SHA256 string `json:"sha256"`
// Assets binds every server artifact the release ships (one per OS).
Assets []releaseManifestAsset `json:"assets,omitempty"`
// ProtocolEpoch is the wire epoch the release's client speaks
// (protocol/schema.json protocol_epoch). Absent on releases up to
// v1.2.0-alpha.4, which is epoch 0.
ProtocolEpoch int `json:"protocol_epoch,omitempty"`
}
// releaseManifestAsset is one artifact binding in a multi-OS release manifest.
type releaseManifestAsset struct {
Asset string `json:"asset"`
SHA256 string `json:"sha256"`
}
// checksumEntryNamesForGOOS returns sha256sum line suffixes to look up in
// checksums.sha256 (matches GitHub Actions release layout).
func checksumEntryNamesForGOOS(goos string) []string {
switch goos {
case "windows":
return []string{"windows/chatserver.exe", "chatserver.exe"}
case "linux":
return []string{"linux/chatserver-linux-amd64.tar.gz", "chatserver-linux-amd64.tar.gz"}
default:
return nil
}
}
func (u *Updater) parseChecksumFileAny(data []byte, names ...string) (string, error) {
for _, name := range names {
hash, err := u.ParseChecksumFile(data, name)
if err == nil {
return hash, nil
}
}
return "", fmt.Errorf("no checksum line for any of: %s", strings.Join(names, ", "))
}
// VerifyReleaseManifest checks the detached signature on the release manifest
// and ensures the manifest binds the downloaded asset to the expected version.
func (u *Updater) VerifyReleaseManifest(manifestData, signatureText []byte, expectedVersion, expectedAsset string) (releaseManifest, error) {
if err := u.verifySignatureReader(bytes.NewReader(manifestData), signatureText, manifestAsset); err != nil {
return releaseManifest{}, fmt.Errorf("verifying release manifest signature: %w", err)
}
var manifest releaseManifest
if err := json.Unmarshal(manifestData, &manifest); err != nil {
return releaseManifest{}, fmt.Errorf("parsing release manifest: %w", err)
}
manifest.Version = ensureVPrefix(strings.TrimSpace(manifest.Version))
if manifest.Version == "v" {
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))
}
// Candidate bindings: the per-OS assets list plus the legacy single-asset
// pair (the only binding manifests from older releases carry).
candidates := append([]releaseManifestAsset{}, manifest.Assets...)
candidates = append(candidates, releaseManifestAsset{Asset: manifest.Asset, SHA256: manifest.SHA256})
for _, c := range candidates {
asset := strings.TrimSpace(c.Asset)
if asset == "" || asset != expectedAsset {
continue
}
sum := strings.ToLower(strings.TrimSpace(c.SHA256))
if len(sum) != sha256.Size*2 {
return releaseManifest{}, fmt.Errorf("release manifest checksum for %s has invalid length", asset)
}
if _, err := hex.DecodeString(sum); err != nil {
return releaseManifest{}, fmt.Errorf("release manifest checksum for %s is invalid: %w", asset, err)
}
// Normalize the returned binding to the matched entry so callers can
// keep reading manifest.Asset/manifest.SHA256 regardless of schema.
manifest.Asset = asset
manifest.SHA256 = sum
return manifest, nil
}
return releaseManifest{}, fmt.Errorf("release manifest does not bind expected asset %q", expectedAsset)
}
// 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 := normalizeSignatureText(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
}
// normalizeSignatureText returns the raw minisign signature document from
// signatureText. `tauri signer sign` emits .sig files that are base64-wrapped
// minisign documents (the same wrapping used for the pinned public key file);
// raw minisign documents pass through unchanged.
func normalizeSignatureText(signatureText []byte) []byte {
trimmed := []byte(strings.TrimSpace(string(signatureText)))
if bytes.HasPrefix(trimmed, []byte("untrusted comment:")) {
return trimmed
}
if decoded, err := base64.StdEncoding.DecodeString(string(trimmed)); err == nil {
return []byte(strings.TrimSpace(string(decoded)))
}
return trimmed
}
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
}
// readerSHA256 returns the hex-encoded SHA256 of everything read from r.
func readerSHA256(r io.Reader) (string, error) {
h := sha256.New()
if _, err := io.Copy(h, r); err != nil {
return "", fmt.Errorf("computing checksum: %w", err)
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// fileSHA256 returns the hex-encoded SHA256 of the file at path.
func fileSHA256(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", fmt.Errorf("opening file for checksum: %w", err)
}
defer f.Close() //nolint:errcheck
return readerSHA256(f)
}
// 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 {
actual, err := fileSHA256(filePath)
if err != nil {
return err
}
if !strings.EqualFold(actual, expectedHash) {
return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedHash, actual)
}
return nil
}
// StagedBinary is an open handle to a staged update binary whose contents
// were verified through that same handle. Because the hash check and Commit's
// same-file check use one open file, a swap of the on-disk path between
// verification and rename is detected instead of silently executed (the
// update TOCTOU window, W3-3).
type StagedBinary struct {
f *os.File
closed bool
}
// OpenVerifiedBinary opens stagedPath exactly once and verifies the SHA256 of
// its contents through that handle against expectedHash (hex,
// case-insensitive). On success the returned StagedBinary keeps the handle
// open for Commit; the caller must Close it.
func OpenVerifiedBinary(stagedPath, expectedHash string) (*StagedBinary, error) {
f, err := os.Open(stagedPath)
if err != nil {
return nil, fmt.Errorf("opening staged binary: %w", err)
}
actual, err := readerSHA256(f)
if err != nil {
_ = f.Close()
return nil, fmt.Errorf("hashing staged binary: %w", err)
}
if !strings.EqualFold(actual, expectedHash) {
_ = f.Close()
return nil, fmt.Errorf("staged binary checksum mismatch: expected %s, got %s", expectedHash, actual)
}
return &StagedBinary{f: f}, nil
}
// Commit renames the staged file to destPath and confirms the file now at
// destPath is the very file the hash was verified through (os.SameFile
// against the verification handle's identity). If the staged path was swapped
// after verification, the rename moves the impostor, the same-file check
// fails, and Commit returns an error; the caller must then treat destPath as
// unverified and restore or remove it.
func (s *StagedBinary) Commit(destPath string) error {
verified, err := s.f.Stat()
if err != nil {
return fmt.Errorf("stat of verified handle: %w", err)
}
if runtime.GOOS == "windows" {
// Windows cannot rename a file Go holds open (os.Open does not share
// delete) — until here that lock itself blocks swaps of the staged
// path. The stat captured above carries the NTFS file ID, which
// travels with the file across the rename, so the same-file check
// below still detects a swap in the close→rename window.
if err := s.Close(); err != nil {
return fmt.Errorf("closing verified handle: %w", err)
}
}
// On Unix the handle stays open through the rename: a held fd also pins
// the verified inode, so its number cannot be reused by another file.
if err := os.Rename(s.f.Name(), destPath); err != nil {
return fmt.Errorf("renaming staged binary: %w", err)
}
committed, err := os.Lstat(destPath)
if err != nil {
return fmt.Errorf("stat of committed binary: %w", err)
}
if !os.SameFile(verified, committed) {
return fmt.Errorf("staged binary was replaced after verification (refusing to run it)")
}
return nil
}
// Close releases the verification handle. Safe to call more than once.
func (s *StagedBinary) Close() error {
if s.closed {
return nil
}
s.closed = true
return s.f.Close()
}
// ParseChecksumFile parses a sha256sum-format checksum file (lines of
// "<hash> <filename>") and returns the hash for the given filename.
func (u *Updater) ParseChecksumFile(data []byte, filename string) (string, error) {
lines := strings.SplitSeq(string(data), "\n")
for line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// sha256sum format: "<hash> <filename>" (two spaces)
// Also handle single-space separation for robustness.
parts := strings.Fields(line)
if len(parts) >= 2 && parts[len(parts)-1] == filename {
return parts[0], nil
}
}
return "", fmt.Errorf("file %q not found in checksum data", filename)
}
// ReleaseProtocolEpoch returns the protocol epoch a release's signed
// server-update manifest declares. A release with no manifest predates the
// epoch and is 0; a manifest whose signature does not verify is an error,
// never a guess. Both fetches go through the text-asset cache, so the
// unauthenticated client-update endpoint costs no outbound request per call.
func (u *Updater) ReleaseProtocolEpoch(ctx context.Context, info UpdateInfo) (int, error) {
if info.ManifestURL == "" || info.ManifestSignatureURL == "" {
return 0, nil
}
manifestText, err := u.FetchTextAssetCached(ctx, info.ManifestURL)
if err != nil {
return 0, fmt.Errorf("fetching release manifest: %w", err)
}
sigText, err := u.FetchTextAssetCached(ctx, info.ManifestSignatureURL)
if err != nil {
return 0, fmt.Errorf("fetching release manifest signature: %w", err)
}
if err := u.verifySignatureReader(strings.NewReader(manifestText), []byte(sigText), manifestAsset); err != nil {
return 0, fmt.Errorf("verifying release manifest signature: %w", err)
}
var manifest releaseManifest
if err := json.Unmarshal([]byte(manifestText), &manifest); err != nil {
return 0, fmt.Errorf("parsing release manifest: %w", err)
}
return manifest.ProtocolEpoch, nil
}