package version import ( "encoding/hex" "encoding/json" "errors" "fmt" "os" "path/filepath" "strconv" "strings" "time" "gitgud.io/mike/mpv-manager/pkg/config" "gitgud.io/mike/mpv-manager/pkg/releasemanifest" "lukechampine.com/blake3" ) const ( releaseTrustStateSchema = 1 releaseTrustStateName = "release-trust-state.json" releaseTrustStateLockName = ".release-trust-state.lock" manifestFutureClockSkew = 24 * time.Hour manifestBuildTimeClockSkew = 5 * time.Minute ) var releaseTrustStateDirectory = func() string { return config.GetConfigDir() } type releaseTrustState struct { SchemaVersion int `json:"schema_version"` Channel string `json:"channel"` Version string `json:"version"` PublishedAt time.Time `json:"published_at"` ManifestHash string `json:"manifest_hash"` KeyID string `json:"key_id"` } type manifestKeyEpoch struct { NotBefore time.Time NotAfter time.Time } func enforceReleaseTrustLifecycle(manifest *ReleaseInfo) error { if manifest == nil || manifest.Signature == nil { return errors.New("release trust lifecycle requires a verified signed manifest") } publishedAt, err := time.Parse(time.RFC3339, manifest.PublishedAt) if err != nil { return fmt.Errorf("parse authenticated manifest publication time: %w", err) } publishedAt = publishedAt.UTC() if publishedAt.After(time.Now().UTC().Add(manifestFutureClockSkew)) { return fmt.Errorf("release manifest publication time is implausibly far in the future") } if buildTime, err := time.Parse(time.RFC3339, BuildTime); err == nil && publishedAt.Before(buildTime.UTC().Add(-manifestBuildTimeClockSkew)) { return fmt.Errorf("release manifest predates this updater build and is rejected as stale") } if isRevokedManifestKey(manifest.Signature.KeyID) { return fmt.Errorf("release manifest signing key %q is revoked by this build", manifest.Signature.KeyID) } keyEpochs, err := parseManifestKeyEpochs(ManifestKeyValidity) if err != nil { return fmt.Errorf("configure release manifest key validity: %w", err) } if len(keyEpochs) > 0 { epoch, ok := keyEpochs[manifest.Signature.KeyID] if !ok { return fmt.Errorf("release manifest key %q has no configured validity epoch", manifest.Signature.KeyID) } if publishedAt.Before(epoch.NotBefore) || publishedAt.After(epoch.NotAfter) { return fmt.Errorf("release manifest publication time is outside key %q's validity epoch", manifest.Signature.KeyID) } } payload, err := manifest.CanonicalPayload() if err != nil { return fmt.Errorf("canonicalize release trust state: %w", err) } digest := blake3.Sum256(payload) incoming := releaseTrustState{ SchemaVersion: releaseTrustStateSchema, Channel: manifest.Channel, Version: manifest.Version, PublishedAt: publishedAt, ManifestHash: hex.EncodeToString(digest[:]), KeyID: manifest.Signature.KeyID, } directory := releaseTrustStateDirectory() if err := os.MkdirAll(directory, 0o755); err != nil { return fmt.Errorf("create release trust state directory: %w", err) } lock, err := acquireUpdateLockWithRetry(filepath.Join(directory, releaseTrustStateLockName), 5*time.Second) if err != nil { return fmt.Errorf("lock release trust state: %w", err) } defer lock.release() stateName := releaseTrustStateName if manifest.Channel == releasemanifest.ReleaseCandidateChannel { stateName = "release-trust-state-rc.json" } statePath := filepath.Join(directory, stateName) previous, err := readReleaseTrustState(statePath) if err != nil { return err } if previous != nil { if previous.Channel != incoming.Channel { return fmt.Errorf("release trust state channel mismatch") } if incoming.PublishedAt.Before(previous.PublishedAt) { return fmt.Errorf("release manifest is older than the highest accepted publication") } if CompareVersions(incoming.Version, previous.Version) < 0 { return fmt.Errorf("release manifest version %s rolls back highest accepted version %s", incoming.Version, previous.Version) } if incoming.PublishedAt.Equal(previous.PublishedAt) && incoming.ManifestHash != previous.ManifestHash { return fmt.Errorf("release manifest equivocation detected at publication time %s", incoming.PublishedAt.Format(time.RFC3339)) } if incoming.PublishedAt.Equal(previous.PublishedAt) && incoming.ManifestHash == previous.ManifestHash { return nil } } if err := writeJSONAtomicDurable(statePath, incoming, 0o600); err != nil { return fmt.Errorf("persist release trust state: %w", err) } return nil } func readReleaseTrustState(path string) (*releaseTrustState, error) { data, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { return nil, nil } if err != nil { return nil, fmt.Errorf("read release trust state: %w", err) } var state releaseTrustState decoder := json.NewDecoder(strings.NewReader(string(data))) decoder.DisallowUnknownFields() if err := decoder.Decode(&state); err != nil { return nil, fmt.Errorf("decode release trust state: %w", err) } if state.SchemaVersion != releaseTrustStateSchema || (state.Channel != releasemanifest.StableChannel && state.Channel != releasemanifest.ReleaseCandidateChannel) || state.Version == "" || state.PublishedAt.IsZero() || len(state.ManifestHash) != 64 || state.KeyID == "" { return nil, fmt.Errorf("release trust state is invalid") } return &state, nil } func parseManifestKeyEpochs(spec string) (map[string]manifestKeyEpoch, error) { epochs := make(map[string]manifestKeyEpoch) if strings.TrimSpace(spec) == "" { return epochs, nil } for _, entry := range strings.Split(spec, ",") { keyID, bounds, ok := strings.Cut(strings.TrimSpace(entry), "=") notBeforeText, notAfterText, boundsOK := strings.Cut(bounds, ":") notBeforeUnix, beforeErr := strconv.ParseInt(notBeforeText, 10, 64) notAfterUnix, afterErr := strconv.ParseInt(notAfterText, 10, 64) if !ok || !boundsOK || strings.TrimSpace(keyID) == "" || beforeErr != nil || afterErr != nil || notBeforeUnix >= notAfterUnix { return nil, fmt.Errorf("invalid manifest key validity entry %q", entry) } if _, exists := epochs[keyID]; exists { return nil, fmt.Errorf("duplicate manifest key validity entry %q", keyID) } epochs[keyID] = manifestKeyEpoch{NotBefore: time.Unix(notBeforeUnix, 0).UTC(), NotAfter: time.Unix(notAfterUnix, 0).UTC()} } return epochs, nil } func isRevokedManifestKey(keyID string) bool { for _, revoked := range strings.Split(ManifestRevokedKeyIDs, ",") { if strings.TrimSpace(revoked) == keyID { return true } } return false }