// Package releasemanifest defines and authenticates the release document // shared by the publisher, updater, and application installers. package releasemanifest import ( "crypto/ed25519" "encoding/base64" "encoding/json" "errors" "fmt" "net/url" "regexp" "strings" "time" ) const ( SchemaVersion = 2 StableChannel = "stable" ReleaseCandidateChannel = "rc" SignatureAlgorithmEd25519 = "ed25519" ManagerPortableComponentID = "manager-portable" MaxManifestBytes = 2 << 20 ManagerAssetFormat = "raw" ManagerInstallScope = "portable" ManagerUpdateStrategy = "external-helper" ) var ( semanticVersionPattern = regexp.MustCompile(`^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$`) blake3Pattern = regexp.MustCompile(`^blake3:[0-9a-f]{64}$`) keyIDPattern = regexp.MustCompile(`^[0-9A-Za-z][0-9A-Za-z._-]{0,63}$`) ) // Asset describes one independently verifiable release artifact. type Asset struct { GOOS string `json:"goos"` GOARCH string `json:"goarch"` CPUBaseline string `json:"cpu_baseline,omitempty"` Format string `json:"format"` URL string `json:"url"` BLAKE3 string `json:"blake3"` Size int64 `json:"size"` InstallScope string `json:"install_scope"` UpdateStrategy string `json:"update_strategy"` NativeSigning string `json:"native_signing,omitempty"` ExpectedProduct string `json:"expected_product"` ExpectedComponent string `json:"expected_component"` } // Component groups artifacts that implement one independently versioned // product component. type Component struct { Version string `json:"version"` Assets []Asset `json:"assets"` } // Signature authenticates the canonical manifest payload. Value is standard // base64 over an Ed25519 signature. type Signature struct { Algorithm string `json:"algorithm"` KeyID string `json:"key_id"` Value string `json:"value"` } // Manifest deliberately retains the legacy top-level application and manager // fields. That lets pre-v1.3 clients discover the v1.3 bootstrap release while // v1.3+ clients verify and select from Components. type Manifest struct { SchemaVersion int `json:"schema_version,omitempty"` Channel string `json:"channel,omitempty"` PublishedAt string `json:"published_at,omitempty"` MinimumUpdaterVersion string `json:"minimum_updater_version,omitempty"` Components map[string]Component `json:"components,omitempty"` Signature *Signature `json:"signature,omitempty"` Version string `json:"version"` Date string `json:"date"` MpvVersion string `json:"mpv-version"` UOSC struct { URL string `json:"url"` BLAKE3 string `json:"blake3"` ConfURL string `json:"conf_url"` ConfBLAKE3 string `json:"conf_blake3"` AppVersion string `json:"app_version"` } `json:"uosc"` ModernZ struct { ScriptURL string `json:"script_url"` ScriptBLAKE3 string `json:"script_blake3"` FontURL string `json:"font_url"` FontBLAKE3 string `json:"font_blake3"` ConfURL string `json:"conf_url"` ConfBLAKE3 string `json:"conf_blake3"` AppVersion string `json:"app_version"` } `json:"modernz"` MPCQT struct { X8664 struct{ URL, BLAKE3 string } `json:"x86-64"` AppVersion string `json:"app_version"` } `json:"mpc-qt"` IINA struct { ARM struct{ URL, BLAKE3 string } `json:"arm"` Intel struct{ URL, BLAKE3 string } `json:"intel"` AppVersion string `json:"app_version"` } `json:"iina"` Windows struct { X8664 struct{ URL, BLAKE3 string } `json:"x86-64"` X8664v3 struct{ URL, BLAKE3 string } `json:"x86-64-v3"` Aarch64 struct{ URL, BLAKE3 string } `json:"aarch64"` } `json:"windows"` MacOS struct { ARMLatest struct{ URL, BLAKE3 string } `json:"arm-latest"` ARM15 struct{ URL, BLAKE3 string } `json:"arm-15"` Intel15 struct{ URL, BLAKE3 string } `json:"intel-15"` } `json:"macos"` FFmpeg struct { X8664 struct{ URL, BLAKE3 string } `json:"x86-64"` X8664v3 struct{ URL, BLAKE3 string } `json:"x86-64-v3"` Aarch64 struct{ URL, BLAKE3 string } `json:"aarch64"` AppVersion string `json:"app_version"` } `json:"ffmpeg"` Manager struct { LinuxAMD64 struct{ URL, BLAKE3 string } `json:"linux-amd64"` LinuxARM64 struct{ URL, BLAKE3 string } `json:"linux-arm64"` WinX86_64 struct{ URL, BLAKE3 string } `json:"win-x86_64"` WinARM64 struct{ URL, BLAKE3 string } `json:"win-arm64"` MacosIntel struct{ URL, BLAKE3 string } `json:"macos-intel"` MacosARM struct{ URL, BLAKE3 string } `json:"macos-arm"` } `json:"manager"` } // MarshalJSON emits both the current mpv-version spelling and the historical // MpvVersion spelling. v1.1/v1.2 decoders use the latter exact JSON tag; the // duplicate is intentionally retained for the one-time v1.3 bootstrap. func (m Manifest) MarshalJSON() ([]byte, error) { type manifestAlias Manifest return json.Marshal(struct { manifestAlias LegacyMPVVersion string `json:"MpvVersion"` }{ manifestAlias: manifestAlias(m), LegacyMPVVersion: m.MpvVersion, }) } // UnmarshalJSON accepts the historical "MpvVersion" spelling as a bootstrap // compatibility alias, but rejects ambiguous documents containing both forms // with different values. func (m *Manifest) UnmarshalJSON(data []byte) error { type manifestAlias Manifest var wire struct { *manifestAlias LegacyMPVVersion string `json:"MpvVersion"` } wire.manifestAlias = (*manifestAlias)(m) if err := json.Unmarshal(data, &wire); err != nil { return err } if m.MpvVersion != "" && wire.LegacyMPVVersion != "" && m.MpvVersion != wire.LegacyMPVVersion { return errors.New("manifest contains conflicting mpv-version values") } if m.MpvVersion == "" { m.MpvVersion = wire.LegacyMPVVersion } return nil } // CanonicalPayload returns the compact deterministic JSON bytes covered by // the manifest signature. The Signature field itself is excluded. func (m Manifest) CanonicalPayload() ([]byte, error) { m.Signature = nil return json.Marshal(m) } // Sign adds an Ed25519 signature over CanonicalPayload. func (m *Manifest) Sign(keyID string, privateKey ed25519.PrivateKey) error { keyID = strings.TrimSpace(keyID) if !keyIDPattern.MatchString(keyID) { return errors.New("manifest signing key ID must be 1-64 letters, digits, dots, underscores, or hyphens") } if len(privateKey) != ed25519.PrivateKeySize { return fmt.Errorf("invalid Ed25519 private key length %d", len(privateKey)) } payload, err := m.CanonicalPayload() if err != nil { return fmt.Errorf("canonicalize manifest: %w", err) } m.Signature = &Signature{ Algorithm: SignatureAlgorithmEd25519, KeyID: keyID, Value: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, payload)), } return nil } // Verify authenticates the manifest against the trusted public key selected // by its key ID. func (m Manifest) Verify(trustedKeys map[string]ed25519.PublicKey) error { if m.SchemaVersion != SchemaVersion { return fmt.Errorf("unsupported release manifest schema %d", m.SchemaVersion) } if m.Signature == nil { return errors.New("release manifest is unsigned") } if m.Signature.Algorithm != SignatureAlgorithmEd25519 { return fmt.Errorf("unsupported manifest signature algorithm %q", m.Signature.Algorithm) } if !keyIDPattern.MatchString(m.Signature.KeyID) { return fmt.Errorf("invalid manifest signing key ID %q", m.Signature.KeyID) } publicKey, ok := trustedKeys[m.Signature.KeyID] if !ok { return fmt.Errorf("untrusted manifest signing key %q", m.Signature.KeyID) } if len(publicKey) != ed25519.PublicKeySize { return fmt.Errorf("trusted key %q has invalid length", m.Signature.KeyID) } signature, err := base64.StdEncoding.DecodeString(m.Signature.Value) if err != nil { return fmt.Errorf("decode manifest signature: %w", err) } payload, err := m.CanonicalPayload() if err != nil { return fmt.Errorf("canonicalize manifest: %w", err) } if !ed25519.Verify(publicKey, payload, signature) { return errors.New("release manifest signature is invalid") } return nil } // Validate checks the signed-schema fields that every producer and consumer // relies on before selecting an artifact. func (m Manifest) Validate() error { var problems []string if m.SchemaVersion != SchemaVersion { problems = append(problems, fmt.Sprintf("schema_version is %d, want %d", m.SchemaVersion, SchemaVersion)) } if m.Channel != StableChannel && m.Channel != ReleaseCandidateChannel { problems = append(problems, fmt.Sprintf("channel %q is unsupported", m.Channel)) } if m.Channel == StableChannel && ChannelForVersion(m.Version) != StableChannel { problems = append(problems, "stable channel cannot contain a prerelease version") } if !semanticVersionPattern.MatchString(m.Version) { problems = append(problems, fmt.Sprintf("version %q is not semantic", m.Version)) } if !semanticVersionPattern.MatchString(m.MinimumUpdaterVersion) { problems = append(problems, fmt.Sprintf("minimum_updater_version %q is not semantic", m.MinimumUpdaterVersion)) } if _, err := time.Parse(time.RFC3339, m.PublishedAt); err != nil { problems = append(problems, fmt.Sprintf("published_at %q is not RFC3339", m.PublishedAt)) } component, ok := m.Components[ManagerPortableComponentID] if !ok { problems = append(problems, "manager-portable component is missing") } else { if component.Version != m.Version { problems = append(problems, fmt.Sprintf("manager-portable version %q does not match release version %q", component.Version, m.Version)) } seenTargets := make(map[string]bool) for index, asset := range component.Assets { name := fmt.Sprintf("manager-portable asset %d", index) target := asset.GOOS + "/" + asset.GOARCH if asset.GOOS == "" || asset.GOARCH == "" { problems = append(problems, name+" has an empty target") } else if seenTargets[target] { problems = append(problems, "manager-portable has duplicate target "+target) } seenTargets[target] = true parsedURL, err := url.Parse(asset.URL) if err != nil || parsedURL.Scheme != "https" || parsedURL.Host == "" { problems = append(problems, fmt.Sprintf("%s URL %q is not absolute HTTPS", name, asset.URL)) } if !blake3Pattern.MatchString(asset.BLAKE3) { problems = append(problems, fmt.Sprintf("%s BLAKE3 digest is invalid", name)) } if asset.Size <= 0 { problems = append(problems, name+" size must be positive") } if asset.Format != ManagerAssetFormat { problems = append(problems, fmt.Sprintf("%s format %q is unsupported", name, asset.Format)) } if asset.InstallScope != ManagerInstallScope { problems = append(problems, fmt.Sprintf("%s install scope %q is unsupported", name, asset.InstallScope)) } if asset.UpdateStrategy != ManagerUpdateStrategy { problems = append(problems, fmt.Sprintf("%s update strategy %q is unsupported", name, asset.UpdateStrategy)) } expectedBaseline, baselineOK := managerCPUBaseline(asset.GOOS, asset.GOARCH) if baselineOK && asset.CPUBaseline != expectedBaseline { problems = append(problems, fmt.Sprintf("%s CPU baseline %q does not match required %q", name, asset.CPUBaseline, expectedBaseline)) } if asset.NativeSigning != "" { problems = append(problems, fmt.Sprintf("%s declares unsupported native-signing policy %q", name, asset.NativeSigning)) } if asset.ExpectedProduct != "mpv-manager" || asset.ExpectedComponent != ManagerPortableComponentID { problems = append(problems, name+" has incorrect expected identity") } legacyURL, legacyHash, legacyOK := m.legacyManagerAsset(asset.GOOS, asset.GOARCH) if legacyOK && (legacyURL != asset.URL || legacyHash != asset.BLAKE3) { problems = append(problems, fmt.Sprintf("%s does not match its legacy manager URL/hash", name)) } } for _, target := range []string{ "linux/amd64", "linux/arm64", "windows/amd64", "windows/arm64", "darwin/amd64", "darwin/arm64", } { if !seenTargets[target] { problems = append(problems, "manager-portable is missing target "+target) } } } if len(problems) > 0 { return fmt.Errorf("release manifest validation failed:\n - %s", strings.Join(problems, "\n - ")) } return nil } func managerCPUBaseline(goos, goarch string) (string, bool) { switch goos + "/" + goarch { case "linux/amd64", "windows/amd64", "darwin/amd64": return "x86-64-v2", true case "linux/arm64", "windows/arm64", "darwin/arm64": return "arm64", true default: return "", false } } func (m Manifest) legacyManagerAsset(goos, goarch string) (string, string, bool) { switch goos + "/" + goarch { case "linux/amd64": return m.Manager.LinuxAMD64.URL, m.Manager.LinuxAMD64.BLAKE3, true case "linux/arm64": return m.Manager.LinuxARM64.URL, m.Manager.LinuxARM64.BLAKE3, true case "windows/amd64": return m.Manager.WinX86_64.URL, m.Manager.WinX86_64.BLAKE3, true case "windows/arm64": return m.Manager.WinARM64.URL, m.Manager.WinARM64.BLAKE3, true case "darwin/amd64": return m.Manager.MacosIntel.URL, m.Manager.MacosIntel.BLAKE3, true case "darwin/arm64": return m.Manager.MacosARM.URL, m.Manager.MacosARM.BLAKE3, true default: return "", "", false } } // ParseTrustedKeys parses a comma-separated key ring in the form // "key-id=base64-public-key,key-id-2=base64-public-key". func ParseTrustedKeys(spec string) (map[string]ed25519.PublicKey, error) { keys := make(map[string]ed25519.PublicKey) if strings.TrimSpace(spec) == "" { return keys, nil } for _, item := range strings.Split(spec, ",") { keyID, encoded, ok := strings.Cut(strings.TrimSpace(item), "=") keyID = strings.TrimSpace(keyID) if !ok || !keyIDPattern.MatchString(keyID) || strings.TrimSpace(encoded) == "" { return nil, fmt.Errorf("invalid trusted manifest key entry %q", item) } decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded)) if err != nil { return nil, fmt.Errorf("decode trusted manifest key %q: %w", keyID, err) } if len(decoded) != ed25519.PublicKeySize { return nil, fmt.Errorf("trusted manifest key %q has length %d, want %d", keyID, len(decoded), ed25519.PublicKeySize) } if _, exists := keys[keyID]; exists { return nil, fmt.Errorf("duplicate trusted manifest key ID %q", keyID) } keys[keyID] = ed25519.PublicKey(decoded) } return keys, nil } // ParsePrivateKey accepts standard-base64 Ed25519 seed or private-key bytes. func ParsePrivateKey(encoded string) (ed25519.PrivateKey, error) { decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded)) if err != nil { return nil, fmt.Errorf("decode Ed25519 private key: %w", err) } switch len(decoded) { case ed25519.SeedSize: return ed25519.NewKeyFromSeed(decoded), nil case ed25519.PrivateKeySize: return ed25519.PrivateKey(decoded), nil default: return nil, fmt.Errorf("invalid Ed25519 private key length %d", len(decoded)) } } // SelectAsset returns the unique matching asset for a component and target. func (m Manifest) SelectAsset(componentID, goos, goarch string) (Component, Asset, error) { component, ok := m.Components[componentID] if !ok { return Component{}, Asset{}, fmt.Errorf("release manifest has no component %q", componentID) } var matches []Asset for _, asset := range component.Assets { if asset.GOOS == goos && asset.GOARCH == goarch { matches = append(matches, asset) } } if len(matches) != 1 { return Component{}, Asset{}, fmt.Errorf("component %q has %d assets for %s/%s, want exactly one", componentID, len(matches), goos, goarch) } return component, matches[0], nil } // ChannelForVersion routes prerelease builds to the opt-in RC feed. Build // metadata does not change the update channel. func ChannelForVersion(version string) string { core, _, _ := strings.Cut(strings.TrimSpace(version), "+") if strings.Contains(core, "-") { return ReleaseCandidateChannel } return StableChannel }