add mosis-portal Go project with package signing and validation

This commit is contained in:
2026-01-18 20:56:06 +01:00
parent d76627ebc3
commit 2eb6292dc2
14 changed files with 1671 additions and 10 deletions

View File

@@ -0,0 +1,185 @@
// Package mospkg provides functionality for Mosis app packages (.mosis files)
package mospkg
import (
"encoding/json"
"fmt"
"regexp"
)
// Manifest represents the app manifest (manifest.json)
type Manifest struct {
Schema string `json:"$schema,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
VersionCode int `json:"version_code"`
Entry string `json:"entry"`
MinMosisVersion string `json:"min_mosis_version"`
Description string `json:"description,omitempty"`
Author *Author `json:"author,omitempty"`
Permissions []string `json:"permissions,omitempty"`
Icons Icons `json:"icons,omitempty"`
TargetMosisVer string `json:"target_mosis_version,omitempty"`
Category string `json:"category,omitempty"`
Tags []string `json:"tags,omitempty"`
Orientation string `json:"orientation,omitempty"`
BackgroundColor string `json:"background_color,omitempty"`
Locales []string `json:"locales,omitempty"`
DefaultLocale string `json:"default_locale,omitempty"`
}
// Author represents the app author information
type Author struct {
Name string `json:"name"`
Email string `json:"email,omitempty"`
URL string `json:"url,omitempty"`
}
// Icons represents app icon paths by size
type Icons struct {
Size32 string `json:"32,omitempty"`
Size64 string `json:"64,omitempty"`
Size128 string `json:"128,omitempty"`
}
// ParseManifest parses a manifest.json from bytes
func ParseManifest(data []byte) (*Manifest, error) {
var m Manifest
if err := json.Unmarshal(data, &m); err != nil {
return nil, fmt.Errorf("parse manifest: %w", err)
}
return &m, nil
}
// Validate checks if the manifest contains all required fields with valid values
func (m *Manifest) Validate() []ValidationError {
var errors []ValidationError
// Required fields
if m.ID == "" {
errors = append(errors, ValidationError{
Code: "MISSING_ID",
Message: "Manifest is missing required field: id",
})
} else if !isValidPackageID(m.ID) {
errors = append(errors, ValidationError{
Code: "INVALID_ID",
Message: "Package ID must be in reverse domain format (e.g., com.developer.app)",
File: "manifest.json",
})
}
if m.Name == "" {
errors = append(errors, ValidationError{
Code: "MISSING_NAME",
Message: "Manifest is missing required field: name",
})
} else if len(m.Name) > 30 {
errors = append(errors, ValidationError{
Code: "NAME_TOO_LONG",
Message: "App name must be 30 characters or less",
})
}
if m.Version == "" {
errors = append(errors, ValidationError{
Code: "MISSING_VERSION",
Message: "Manifest is missing required field: version",
})
} else if !isValidSemver(m.Version) {
errors = append(errors, ValidationError{
Code: "INVALID_VERSION",
Message: "Version must be in semantic version format (X.Y.Z)",
})
}
if m.VersionCode <= 0 {
errors = append(errors, ValidationError{
Code: "INVALID_VERSION_CODE",
Message: "version_code must be a positive integer",
})
}
if m.Entry == "" {
errors = append(errors, ValidationError{
Code: "MISSING_ENTRY",
Message: "Manifest is missing required field: entry",
})
}
if m.MinMosisVersion == "" {
errors = append(errors, ValidationError{
Code: "MISSING_MIN_MOSIS_VERSION",
Message: "Manifest is missing required field: min_mosis_version",
})
}
// Validate permissions
for _, perm := range m.Permissions {
if !isValidPermission(perm) {
errors = append(errors, ValidationError{
Code: "INVALID_PERMISSION",
Message: fmt.Sprintf("Unknown permission: %s", perm),
})
}
}
// Validate orientation if specified
if m.Orientation != "" {
validOrientations := map[string]bool{"portrait": true, "landscape": true, "any": true}
if !validOrientations[m.Orientation] {
errors = append(errors, ValidationError{
Code: "INVALID_ORIENTATION",
Message: "Orientation must be one of: portrait, landscape, any",
})
}
}
// Validate background color if specified
if m.BackgroundColor != "" && !isValidHexColor(m.BackgroundColor) {
errors = append(errors, ValidationError{
Code: "INVALID_BACKGROUND_COLOR",
Message: "background_color must be a valid hex color (e.g., #FFFFFF)",
})
}
return errors
}
// Package ID pattern: reverse domain notation
var packageIDPattern = regexp.MustCompile(`^[a-z][a-z0-9]*(\.[a-z][a-z0-9_]*)+$`)
func isValidPackageID(id string) bool {
return packageIDPattern.MatchString(id)
}
// Semver pattern: X.Y.Z
var semverPattern = regexp.MustCompile(`^\d+\.\d+\.\d+$`)
func isValidSemver(version string) bool {
return semverPattern.MatchString(version)
}
// Hex color pattern: #RGB or #RRGGBB
var hexColorPattern = regexp.MustCompile(`^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$`)
func isValidHexColor(color string) bool {
return hexColorPattern.MatchString(color)
}
// Valid permissions
var validPermissions = map[string]bool{
"storage": true,
"network": true,
"camera": true,
"microphone": true,
"location": true,
"contacts": true,
"calendar": true,
"sensors": true,
}
func isValidPermission(perm string) bool {
return validPermissions[perm]
}

360
portal/pkg/mospkg/signer.go Normal file
View File

@@ -0,0 +1,360 @@
// Package mospkg provides functionality for Mosis app packages (.mosis files)
package mospkg
import (
"archive/zip"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"io"
"os"
"sort"
"strings"
)
// KeyPair represents an Ed25519 signing keypair
type KeyPair struct {
PrivateKey ed25519.PrivateKey
PublicKey ed25519.PublicKey
}
// GenerateKeyPair generates a new Ed25519 keypair
func GenerateKeyPair() (*KeyPair, error) {
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, fmt.Errorf("generate key: %w", err)
}
return &KeyPair{
PrivateKey: privateKey,
PublicKey: publicKey,
}, nil
}
// Fingerprint returns the SHA256 fingerprint of the public key
func (kp *KeyPair) Fingerprint() string {
hash := sha256.Sum256(kp.PublicKey)
return fmt.Sprintf("SHA256:%s", base64.StdEncoding.EncodeToString(hash[:]))
}
// PrivateKeyPEM returns the private key in PEM format
func (kp *KeyPair) PrivateKeyPEM() ([]byte, error) {
pkcs8, err := x509.MarshalPKCS8PrivateKey(kp.PrivateKey)
if err != nil {
return nil, fmt.Errorf("marshal private key: %w", err)
}
return pem.EncodeToMemory(&pem.Block{
Type: "PRIVATE KEY",
Bytes: pkcs8,
}), nil
}
// PublicKeyPEM returns the public key in PEM format
func (kp *KeyPair) PublicKeyPEM() ([]byte, error) {
pkix, err := x509.MarshalPKIXPublicKey(kp.PublicKey)
if err != nil {
return nil, fmt.Errorf("marshal public key: %w", err)
}
return pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY",
Bytes: pkix,
}), nil
}
// LoadPrivateKey loads an Ed25519 private key from PEM data
func LoadPrivateKey(pemData []byte) (ed25519.PrivateKey, error) {
block, _ := pem.Decode(pemData)
if block == nil {
return nil, fmt.Errorf("failed to decode PEM block")
}
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse private key: %w", err)
}
ed25519Key, ok := key.(ed25519.PrivateKey)
if !ok {
return nil, fmt.Errorf("key is not Ed25519")
}
return ed25519Key, nil
}
// LoadPublicKey loads an Ed25519 public key from PEM data
func LoadPublicKey(pemData []byte) (ed25519.PublicKey, error) {
block, _ := pem.Decode(pemData)
if block == nil {
return nil, fmt.Errorf("failed to decode PEM block")
}
key, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse public key: %w", err)
}
ed25519Key, ok := key.(ed25519.PublicKey)
if !ok {
return nil, fmt.Errorf("key is not Ed25519")
}
return ed25519Key, nil
}
// PublicKeyFingerprint returns the SHA256 fingerprint of a public key
func PublicKeyFingerprint(publicKey ed25519.PublicKey) string {
hash := sha256.Sum256(publicKey)
return fmt.Sprintf("SHA256:%s", base64.StdEncoding.EncodeToString(hash[:]))
}
// GenerateManifestMF generates MANIFEST.MF content for a package
func GenerateManifestMF(packagePath string) ([]byte, error) {
reader, err := zip.OpenReader(packagePath)
if err != nil {
return nil, fmt.Errorf("open package: %w", err)
}
defer reader.Close()
var lines []string
lines = append(lines, "Manifest-Version: 1.0")
lines = append(lines, "Created-By: mosis-portal")
lines = append(lines, "")
// Sort files for consistent ordering
var fileNames []string
for _, file := range reader.File {
if file.FileInfo().IsDir() {
continue
}
// Skip META-INF files
if strings.HasPrefix(file.Name, "META-INF/") {
continue
}
fileNames = append(fileNames, file.Name)
}
sort.Strings(fileNames)
// Generate hash for each file
for _, name := range fileNames {
for _, file := range reader.File {
if file.Name != name {
continue
}
rc, err := file.Open()
if err != nil {
return nil, fmt.Errorf("open file %s: %w", name, err)
}
hasher := sha256.New()
if _, err := io.Copy(hasher, rc); err != nil {
rc.Close()
return nil, fmt.Errorf("hash file %s: %w", name, err)
}
rc.Close()
digest := base64.StdEncoding.EncodeToString(hasher.Sum(nil))
lines = append(lines, fmt.Sprintf("Name: %s", name))
lines = append(lines, fmt.Sprintf("SHA-256-Digest: %s", digest))
lines = append(lines, "")
}
}
return []byte(strings.Join(lines, "\n")), nil
}
// SignManifest signs MANIFEST.MF content with an Ed25519 private key
func SignManifest(manifestMF []byte, privateKey ed25519.PrivateKey) []byte {
return ed25519.Sign(privateKey, manifestMF)
}
// VerifySignature verifies a signature against MANIFEST.MF using a public key
func VerifySignature(manifestMF, signature []byte, publicKey ed25519.PublicKey) bool {
return ed25519.Verify(publicKey, manifestMF, signature)
}
// SignPackage signs a .mosis package by adding META-INF/MANIFEST.MF and META-INF/CERT.SIG
func SignPackage(packagePath, outputPath string, privateKey ed25519.PrivateKey) error {
// Generate MANIFEST.MF
manifestMF, err := GenerateManifestMF(packagePath)
if err != nil {
return fmt.Errorf("generate manifest: %w", err)
}
// Sign manifest
signature := SignManifest(manifestMF, privateKey)
// Open source package
srcReader, err := zip.OpenReader(packagePath)
if err != nil {
return fmt.Errorf("open source package: %w", err)
}
defer srcReader.Close()
// Create output package
outFile, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("create output: %w", err)
}
defer outFile.Close()
writer := zip.NewWriter(outFile)
defer writer.Close()
// Copy existing files (except META-INF)
for _, file := range srcReader.File {
if strings.HasPrefix(file.Name, "META-INF/") {
continue
}
// Copy file to new archive
destFile, err := writer.CreateHeader(&file.FileHeader)
if err != nil {
return fmt.Errorf("create header: %w", err)
}
srcFile, err := file.Open()
if err != nil {
return fmt.Errorf("open source file: %w", err)
}
if _, err := io.Copy(destFile, srcFile); err != nil {
srcFile.Close()
return fmt.Errorf("copy file: %w", err)
}
srcFile.Close()
}
// Add META-INF/MANIFEST.MF
manifestWriter, err := writer.Create("META-INF/MANIFEST.MF")
if err != nil {
return fmt.Errorf("create MANIFEST.MF: %w", err)
}
if _, err := manifestWriter.Write(manifestMF); err != nil {
return fmt.Errorf("write MANIFEST.MF: %w", err)
}
// Add META-INF/CERT.SIG (base64 encoded signature)
sigWriter, err := writer.Create("META-INF/CERT.SIG")
if err != nil {
return fmt.Errorf("create CERT.SIG: %w", err)
}
if _, err := sigWriter.Write([]byte(base64.StdEncoding.EncodeToString(signature))); err != nil {
return fmt.Errorf("write CERT.SIG: %w", err)
}
return nil
}
// VerifyPackageSignature verifies the signature of a signed .mosis package
func VerifyPackageSignature(packagePath string, publicKey ed25519.PublicKey) (bool, error) {
reader, err := zip.OpenReader(packagePath)
if err != nil {
return false, fmt.Errorf("open package: %w", err)
}
defer reader.Close()
// Find MANIFEST.MF and CERT.SIG
var manifestMF []byte
var signature []byte
for _, file := range reader.File {
switch file.Name {
case "META-INF/MANIFEST.MF":
rc, err := file.Open()
if err != nil {
return false, fmt.Errorf("open MANIFEST.MF: %w", err)
}
manifestMF, err = io.ReadAll(rc)
rc.Close()
if err != nil {
return false, fmt.Errorf("read MANIFEST.MF: %w", err)
}
case "META-INF/CERT.SIG":
rc, err := file.Open()
if err != nil {
return false, fmt.Errorf("open CERT.SIG: %w", err)
}
sigB64, err := io.ReadAll(rc)
rc.Close()
if err != nil {
return false, fmt.Errorf("read CERT.SIG: %w", err)
}
signature, err = base64.StdEncoding.DecodeString(string(sigB64))
if err != nil {
return false, fmt.Errorf("decode signature: %w", err)
}
}
}
if manifestMF == nil {
return false, fmt.Errorf("MANIFEST.MF not found")
}
if signature == nil {
return false, fmt.Errorf("CERT.SIG not found")
}
// Verify signature
if !VerifySignature(manifestMF, signature, publicKey) {
return false, nil
}
// Verify file hashes
hashes, err := parseManifestMF(manifestMF)
if err != nil {
return false, fmt.Errorf("parse MANIFEST.MF: %w", err)
}
for _, file := range reader.File {
if file.FileInfo().IsDir() || strings.HasPrefix(file.Name, "META-INF/") {
continue
}
expectedHash, ok := hashes[file.Name]
if !ok {
return false, fmt.Errorf("file not in manifest: %s", file.Name)
}
rc, err := file.Open()
if err != nil {
return false, fmt.Errorf("open file %s: %w", file.Name, err)
}
hasher := sha256.New()
if _, err := io.Copy(hasher, rc); err != nil {
rc.Close()
return false, fmt.Errorf("hash file %s: %w", file.Name, err)
}
rc.Close()
actualHash := base64.StdEncoding.EncodeToString(hasher.Sum(nil))
if actualHash != expectedHash {
return false, fmt.Errorf("hash mismatch for %s", file.Name)
}
}
return true, nil
}
// parseManifestMF parses MANIFEST.MF content and returns a map of filename -> SHA256 hash
func parseManifestMF(data []byte) (map[string]string, error) {
hashes := make(map[string]string)
lines := strings.Split(string(data), "\n")
var currentFile string
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Name: ") {
currentFile = strings.TrimPrefix(line, "Name: ")
} else if strings.HasPrefix(line, "SHA-256-Digest: ") && currentFile != "" {
hashes[currentFile] = strings.TrimPrefix(line, "SHA-256-Digest: ")
currentFile = ""
}
}
return hashes, nil
}

View File

@@ -0,0 +1,308 @@
// Package mospkg provides functionality for Mosis app packages (.mosis files)
package mospkg
import (
"archive/zip"
"fmt"
"io"
"path/filepath"
"strings"
)
// Size limits
const (
MaxPackageSize = 50 * 1024 * 1024 // 50 MB
MaxFileSize = 10 * 1024 * 1024 // 10 MB
MaxFileCount = 1000
MaxPathLength = 256
MaxManifestSize = 64 * 1024 // 64 KB
)
// ValidationError represents a validation error
type ValidationError struct {
Code string `json:"code"`
Message string `json:"message"`
File string `json:"file,omitempty"`
Line int `json:"line,omitempty"`
}
// ValidationWarning represents a non-blocking validation warning
type ValidationWarning struct {
Code string `json:"code"`
Message string `json:"message"`
File string `json:"file,omitempty"`
}
// ValidationResult holds the result of package validation
type ValidationResult struct {
Valid bool `json:"valid"`
Errors []ValidationError `json:"errors,omitempty"`
Warnings []ValidationWarning `json:"warnings,omitempty"`
Manifest *Manifest `json:"manifest,omitempty"`
}
// AllowedExtensions are file extensions permitted in packages
var AllowedExtensions = map[string]bool{
".rml": true,
".rcss": true,
".lua": true,
".png": true,
".jpg": true,
".jpeg": true,
".tga": true,
".webp": true,
".ttf": true,
".otf": true,
".json": true,
".ogg": true,
".wav": true,
".mp3": true,
}
// ForbiddenExtensions are file extensions not allowed in packages
var ForbiddenExtensions = map[string]bool{
".exe": true,
".dll": true,
".so": true,
".dylib": true,
".sh": true,
".bat": true,
".ps1": true,
".py": true,
".js": true,
".zip": true,
".tar": true,
".gz": true,
}
// ValidatePackage validates a .mosis package file
func ValidatePackage(path string) (*ValidationResult, error) {
result := &ValidationResult{Valid: true}
// Open ZIP archive
reader, err := zip.OpenReader(path)
if err != nil {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Code: "INVALID_ZIP",
Message: fmt.Sprintf("Failed to open package: %v", err),
})
return result, nil
}
defer reader.Close()
// Check file count
if len(reader.File) > MaxFileCount {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Code: "TOO_MANY_FILES",
Message: fmt.Sprintf("Package contains %d files, maximum is %d", len(reader.File), MaxFileCount),
})
return result, nil
}
var manifestData []byte
seenFiles := make(map[string]bool)
var totalSize int64
for _, file := range reader.File {
name := file.Name
// Check for path traversal
if strings.Contains(name, "..") {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Code: "PATH_TRAVERSAL",
Message: "Path traversal detected",
File: name,
})
continue
}
// Check for absolute paths
if filepath.IsAbs(name) {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Code: "ABSOLUTE_PATH",
Message: "Absolute path not allowed",
File: name,
})
continue
}
// Check path length
if len(name) > MaxPathLength {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Code: "PATH_TOO_LONG",
Message: fmt.Sprintf("Path exceeds %d characters", MaxPathLength),
File: name,
})
continue
}
// Check for duplicates
normalizedName := strings.ToLower(name)
if seenFiles[normalizedName] {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Code: "DUPLICATE_FILE",
Message: "Duplicate file detected",
File: name,
})
continue
}
seenFiles[normalizedName] = true
// Skip directories
if file.FileInfo().IsDir() {
continue
}
// Check individual file size
if file.UncompressedSize64 > uint64(MaxFileSize) {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Code: "FILE_TOO_LARGE",
Message: fmt.Sprintf("File exceeds %d MB limit", MaxFileSize/(1024*1024)),
File: name,
})
continue
}
totalSize += int64(file.UncompressedSize64)
// Check file extension
ext := strings.ToLower(filepath.Ext(name))
if ForbiddenExtensions[ext] {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Code: "FORBIDDEN_EXTENSION",
Message: fmt.Sprintf("File type %s is not allowed", ext),
File: name,
})
continue
}
// Skip META-INF files for extension check
if !strings.HasPrefix(name, "META-INF/") && ext != "" && !AllowedExtensions[ext] {
result.Warnings = append(result.Warnings, ValidationWarning{
Code: "UNKNOWN_EXTENSION",
Message: fmt.Sprintf("Unknown file extension: %s", ext),
File: name,
})
}
// Read manifest.json
if name == "manifest.json" {
rc, err := file.Open()
if err != nil {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Code: "MANIFEST_READ_ERROR",
Message: fmt.Sprintf("Failed to read manifest: %v", err),
})
continue
}
manifestData, err = io.ReadAll(io.LimitReader(rc, MaxManifestSize))
rc.Close()
if err != nil {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Code: "MANIFEST_READ_ERROR",
Message: fmt.Sprintf("Failed to read manifest: %v", err),
})
}
}
}
// Check total size
if totalSize > MaxPackageSize {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Code: "PACKAGE_TOO_LARGE",
Message: fmt.Sprintf("Package exceeds %d MB limit", MaxPackageSize/(1024*1024)),
})
}
// Validate manifest
if manifestData == nil {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Code: "MISSING_MANIFEST",
Message: "Package is missing manifest.json",
})
} else {
manifest, err := ParseManifest(manifestData)
if err != nil {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Code: "INVALID_MANIFEST",
Message: fmt.Sprintf("Invalid manifest.json: %v", err),
})
} else {
result.Manifest = manifest
manifestErrors := manifest.Validate()
if len(manifestErrors) > 0 {
result.Valid = false
result.Errors = append(result.Errors, manifestErrors...)
}
// Check that entry point exists
entryExists := false
for _, file := range reader.File {
if file.Name == manifest.Entry {
entryExists = true
break
}
}
if !entryExists {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Code: "MISSING_ENTRY",
Message: fmt.Sprintf("Entry point file not found: %s", manifest.Entry),
})
}
// Check icons
if result.Manifest.Icons.Size32 != "" {
if !fileExistsInZip(reader, result.Manifest.Icons.Size32) {
result.Errors = append(result.Errors, ValidationError{
Code: "MISSING_ICON",
Message: "Icon file not found",
File: result.Manifest.Icons.Size32,
})
}
}
if result.Manifest.Icons.Size64 != "" {
if !fileExistsInZip(reader, result.Manifest.Icons.Size64) {
result.Errors = append(result.Errors, ValidationError{
Code: "MISSING_ICON",
Message: "Icon file not found",
File: result.Manifest.Icons.Size64,
})
}
}
if result.Manifest.Icons.Size128 != "" {
if !fileExistsInZip(reader, result.Manifest.Icons.Size128) {
result.Errors = append(result.Errors, ValidationError{
Code: "MISSING_ICON",
Message: "Icon file not found",
File: result.Manifest.Icons.Size128,
})
}
}
}
}
return result, nil
}
func fileExistsInZip(reader *zip.ReadCloser, name string) bool {
for _, file := range reader.File {
if file.Name == name {
return true
}
}
return false
}