package manifest import ( "crypto/ed25519" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "io" "os" "regexp" "sort" "strings" ) var pluginIDPattern = regexp.MustCompile(`^[a-z0-9]+([._-][a-z0-9]+)+$`) var versionPattern = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$`) var requiredAllowlist = []string{ "POST /api/v1/auth/login", "POST /api/v1/auth/login/2fa", "POST /api/v1/auth/refresh", "POST /api/v1/auth/logout", "GET /api/v1/auth/me", "GET /api/v1/settings/public", "GET /api/v1/admin/payment/plans", "GET /api/v1/admin/subscriptions", "GET /api/v1/admin/subscriptions/{id}", "GET /api/v1/admin/users/{id}", "GET /api/v1/admin/users/{id}/subscriptions", } type Manifest struct { SchemaVersion int `json:"schema_version"` PluginID string `json:"plugin_id"` Name string `json:"name"` Version string `json:"version"` CoreAPIBaseline string `json:"core_api_baseline"` Capabilities []string `json:"capabilities"` TestedCoreVersions []string `json:"tested_core_versions"` Backend struct { HealthPath string `json:"health_path"` ReadinessPath string `json:"readiness_path"` ListenEnv string `json:"listen_env"` } `json:"backend"` UI struct { Entrypoint string `json:"entrypoint"` Menu struct { ID string `json:"id"` Label string `json:"label"` Visibility string `json:"visibility"` SortOrder int `json:"sort_order"` } `json:"menu"` } `json:"ui"` Publisher struct { KeyID string `json:"key_id"` } `json:"publisher"` CoreAPIAllowlist []string `json:"core_api_allowlist"` Files map[string]string `json:"files,omitempty"` } type Signature struct { Algorithm string `json:"algorithm"` KeyID string `json:"key_id"` Signature string `json:"signature"` } func Load(path string) (Manifest, []byte, error) { raw, err := os.ReadFile(path) if err != nil { return Manifest{}, nil, err } var m Manifest dec := json.NewDecoder(strings.NewReader(string(raw))) dec.DisallowUnknownFields() if err := dec.Decode(&m); err != nil { return Manifest{}, nil, fmt.Errorf("decode manifest: %w", err) } var trailing any if err := dec.Decode(&trailing); err != io.EOF { if err == nil { return Manifest{}, nil, errors.New("manifest contains trailing JSON") } return Manifest{}, nil, fmt.Errorf("decode manifest trailing data: %w", err) } if err := Validate(m); err != nil { return Manifest{}, nil, err } return m, raw, nil } func Validate(m Manifest) error { if m.SchemaVersion != 1 { return fmt.Errorf("schema_version must be 1") } if !pluginIDPattern.MatchString(m.PluginID) { return fmt.Errorf("invalid plugin_id") } if strings.TrimSpace(m.Name) == "" || len(m.Name) > 160 { return fmt.Errorf("name is required and must be at most 160 characters") } if !versionPattern.MatchString(strings.TrimPrefix(m.Version, "v")) { return fmt.Errorf("invalid plugin version") } baseline := strings.TrimPrefix(m.CoreAPIBaseline, "sub2api-") if !versionPattern.MatchString(strings.TrimPrefix(baseline, "v")) { return fmt.Errorf("invalid core_api_baseline") } if len(m.Capabilities) != 1 || m.Capabilities[0] != "subscription.admin.v1" { return fmt.Errorf("capabilities must contain subscription.admin.v1 only") } for _, version := range m.TestedCoreVersions { if !versionPattern.MatchString(strings.TrimPrefix(version, "v")) { return fmt.Errorf("invalid tested_core_versions entry") } } if m.Backend.HealthPath != "/healthz" || m.Backend.ReadinessPath != "/readyz" || m.Backend.ListenEnv != "PLUGIN_PORT" { return fmt.Errorf("backend health_path/listen_env do not match V1 contract") } if (m.UI.Entrypoint != "/admin" && m.UI.Entrypoint != "/admin/" && m.UI.Entrypoint != "ui/index.html") || m.UI.Menu.ID != m.PluginID || strings.TrimSpace(m.UI.Menu.Label) == "" || m.UI.Menu.Visibility != "admin" || m.UI.Menu.SortOrder < 0 || m.Publisher.KeyID == "" { return fmt.Errorf("ui.entrypoint/menu and publisher.key_id are required") } if len(m.CoreAPIAllowlist) != len(requiredAllowlist) { return fmt.Errorf("core_api_allowlist must contain exactly %d entries", len(requiredAllowlist)) } seen := make(map[string]struct{}, len(m.CoreAPIAllowlist)) for _, entry := range m.CoreAPIAllowlist { if _, ok := seen[entry]; ok { return fmt.Errorf("duplicate allowlist entry: %s", entry) } seen[entry] = struct{}{} } for _, required := range requiredAllowlist { if _, ok := seen[required]; !ok { return fmt.Errorf("missing allowlist entry: %s", required) } } for file, hash := range m.Files { if strings.TrimSpace(file) == "" || strings.HasPrefix(strings.ReplaceAll(file, "\\", "/"), "/") || strings.Contains(strings.ReplaceAll(file, "\\", "/"), "..") { return fmt.Errorf("invalid file declaration: %s", file) } if _, err := hex.DecodeString(hash); err != nil || len(hash) != 64 { return fmt.Errorf("invalid file hash declaration: %s", file) } } return nil } // DeclaredFiles returns file paths in deterministic order for package tooling. func DeclaredFiles(m Manifest) []string { files := make([]string, 0, len(m.Files)) for file := range m.Files { files = append(files, file) } sort.Strings(files) return files } func VerifySignature(manifestBytes, signatureBytes, publicKeyBytes []byte) error { var signature Signature dec := json.NewDecoder(strings.NewReader(string(signatureBytes))) dec.DisallowUnknownFields() if err := dec.Decode(&signature); err != nil { return fmt.Errorf("decode signature: %w", err) } var trailing any if err := dec.Decode(&trailing); err != io.EOF { if err == nil { return errors.New("signature contains trailing JSON") } return fmt.Errorf("decode signature trailing data: %w", err) } if signature.Algorithm != "ed25519" || signature.KeyID == "" { return errors.New("signature must use ed25519 and include key_id") } publicKey, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(publicKeyBytes))) if err != nil || len(publicKey) != ed25519.PublicKeySize { return errors.New("invalid base64 ed25519 public key") } sig, err := base64.StdEncoding.DecodeString(signature.Signature) if err != nil || len(sig) != ed25519.SignatureSize { return errors.New("invalid base64 ed25519 signature") } if !ed25519.Verify(ed25519.PublicKey(publicKey), manifestBytes, sig) { return errors.New("manifest signature verification failed") } return nil } func VerifyKeyID(signatureBytes []byte, expectedKeyID string) error { var signature Signature if err := json.Unmarshal(signatureBytes, &signature); err != nil { return fmt.Errorf("decode signature: %w", err) } if strings.TrimSpace(expectedKeyID) == "" || signature.KeyID != expectedKeyID { return errors.New("signature key_id does not match manifest publisher") } return nil } func RequiredAllowlist() []string { return append([]string(nil), requiredAllowlist...) }