chore: initialize standalone business plugin repository
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
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...)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func validManifest() Manifest {
|
||||
var m Manifest
|
||||
m.SchemaVersion = 1
|
||||
m.PluginID = "qiu.subscription-admin"
|
||||
m.Name = "Subscription Admin"
|
||||
m.Version = "0.1.0"
|
||||
m.CoreAPIBaseline = "sub2api-0.1.183"
|
||||
m.Capabilities = []string{"subscription.admin.v1"}
|
||||
m.TestedCoreVersions = []string{"0.1.183"}
|
||||
m.Backend.HealthPath = "/healthz"
|
||||
m.Backend.ReadinessPath = "/readyz"
|
||||
m.Backend.ListenEnv = "PLUGIN_PORT"
|
||||
m.UI.Entrypoint = "ui/index.html"
|
||||
m.UI.Menu.ID = m.PluginID
|
||||
m.UI.Menu.Label = "订阅管理"
|
||||
m.UI.Menu.Visibility = "admin"
|
||||
m.UI.Menu.SortOrder = 200
|
||||
m.Publisher.KeyID = "test-key"
|
||||
m.CoreAPIAllowlist = RequiredAllowlist()
|
||||
return m
|
||||
}
|
||||
|
||||
func TestValidateManifest(t *testing.T) {
|
||||
if err := Validate(validManifest()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := validManifest()
|
||||
m.CoreAPIAllowlist = append(m.CoreAPIAllowlist, "GET /api/v1/admin/users")
|
||||
if err := Validate(m); err == nil {
|
||||
t.Fatal("expected exact allowlist validation failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifySignature(t *testing.T) {
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifestBytes := []byte(`{"schema_version":1}`)
|
||||
signature := Signature{Algorithm: "ed25519", KeyID: "test-key", Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, manifestBytes))}
|
||||
signatureBytes, err := json.Marshal(signature)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
publicKeyBytes := []byte(base64.StdEncoding.EncodeToString(publicKey))
|
||||
if err := VerifySignature(manifestBytes, signatureBytes, publicKeyBytes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifestBytes[0] = '{'
|
||||
if err := VerifySignature([]byte(`{"schema_version":2}`), signatureBytes, publicKeyBytes); err == nil {
|
||||
t.Fatal("expected tamper failure")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user