Implement authorization import and revocation checks (T-503)

This commit is contained in:
ila
2026-07-20 09:07:30 +08:00
parent b4453130de
commit 76f6108496
36 changed files with 2197 additions and 68 deletions
+5
View File
@@ -47,6 +47,8 @@ type InstalledApp struct {
Entrypoint string `json:"entrypoint,omitempty"`
WorkingDirectory string `json:"working_directory,omitempty"`
MinOS string `json:"min_os,omitempty"`
ProductID string `json:"product_id,omitempty"`
SupportsTrial bool `json:"supports_trial"`
RequiresAdmin bool `json:"requires_admin"`
Files []InstalledFile `json:"files"`
}
@@ -332,6 +334,9 @@ func (record InstalledApp) validate() error {
record.MinOS != "windows-10" && record.MinOS != "windows-11" {
return fmt.Errorf("%w: min_os=%q", ErrInstalledAppInvalid, record.MinOS)
}
if record.ProductID != "" && !appIDPattern.MatchString(record.ProductID) {
return fmt.Errorf("%w: invalid product_id", ErrInstalledAppInvalid)
}
if record.Files == nil {
return fmt.Errorf("%w: files must be an array", ErrInstalledAppInvalid)
}
+1
View File
@@ -306,6 +306,7 @@ func validInstalledApp() InstalledApp {
Version: "1.2.0",
Architecture: "amd64",
Channel: "stable",
ProductID: "product-json-parser",
Files: []InstalledFile{
{
Path: "JsonParser.exe",
+360
View File
@@ -0,0 +1,360 @@
package storage
import (
"bytes"
"crypto/sha256"
"errors"
"io"
"os"
"path/filepath"
"regexp"
"sort"
"sync"
"softbox.local/core/licensing"
)
const MaxLicenseDocumentBytes int64 = 1 << 20
var (
ErrLicenseStoreInvalid = errors.New("license store is invalid")
ErrLicenseStoreUnsafe = errors.New("license store layout is unsafe")
)
var licenseDocumentNamePattern = regexp.MustCompile(`^[0-9a-f]{64}\.license$`)
// LicenseStore stores only documents that have already passed the caller's
// signature and machine-binding verification. It never returns source paths.
type LicenseStore struct {
root string
mu sync.Mutex
}
// NewLicenseStore creates a store rooted at the product-level licenses folder.
func NewLicenseStore(root string) *LicenseStore {
return &LicenseStore{root: root}
}
// Import verifies then atomically persists one license. imported is false when
// the same verified document is already present.
func (store *LicenseStore) Import(
document []byte,
verifier licensing.Verifier,
expectedMachineHash string,
) (license licensing.License, imported bool, err error) {
if store == nil {
return licensing.License{}, false, ErrLicenseStoreUnsafe
}
if len(document) == 0 || int64(len(document)) > MaxLicenseDocumentBytes {
return licensing.License{}, false, ErrLicenseStoreInvalid
}
license, err = verifier.Verify(document, expectedMachineHash)
if err != nil {
return licensing.License{}, false, err
}
digest := sha256.Sum256(document)
name := fmtLicenseDocumentName(digest)
store.mu.Lock()
defer store.mu.Unlock()
directory, err := store.ensureDocumentsDirectory()
if err != nil {
return licensing.License{}, false, err
}
target := filepath.Join(directory, name)
if existing, found, err := readStoredDocument(target); err != nil {
return licensing.License{}, false, err
} else if found {
if !bytes.Equal(existing, document) {
return licensing.License{}, false, ErrLicenseStoreInvalid
}
return license, false, nil
}
if err := writeNewLicenseDocument(directory, target, document); err != nil {
return licensing.License{}, false, err
}
return license, true, nil
}
// List revalidates every cached license before returning a detached list.
func (store *LicenseStore) List(
verifier licensing.Verifier,
expectedMachineHash string,
) ([]licensing.License, error) {
if store == nil {
return nil, ErrLicenseStoreUnsafe
}
store.mu.Lock()
defer store.mu.Unlock()
directory, exists, err := store.inspectDocumentsDirectory()
if err != nil || !exists {
return nil, err
}
entries, err := os.ReadDir(directory)
if err != nil {
return nil, ErrLicenseStoreInvalid
}
names := make([]string, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() || !licenseDocumentNamePattern.MatchString(entry.Name()) {
return nil, ErrLicenseStoreUnsafe
}
names = append(names, entry.Name())
}
sort.Strings(names)
licenses := make([]licensing.License, 0, len(names))
for _, name := range names {
document, found, err := readStoredDocument(filepath.Join(directory, name))
if err != nil || !found {
return nil, ErrLicenseStoreInvalid
}
digest := sha256.Sum256(document)
if name != fmtLicenseDocumentName(digest) {
return nil, ErrLicenseStoreInvalid
}
license, err := verifier.Verify(document, expectedMachineHash)
if err != nil {
return nil, err
}
license.Products = append([]string(nil), license.Products...)
licenses = append(licenses, license)
}
return licenses, nil
}
// StoreRevocations verifies and atomically replaces the only revocation cache.
func (store *LicenseStore) StoreRevocations(
document []byte,
verifier licensing.RevocationVerifier,
) (licensing.RevocationList, error) {
if store == nil {
return licensing.RevocationList{}, ErrLicenseStoreUnsafe
}
if len(document) == 0 || int64(len(document)) > MaxLicenseDocumentBytes {
return licensing.RevocationList{}, ErrLicenseStoreInvalid
}
list, err := verifier.Verify(document)
if err != nil {
return licensing.RevocationList{}, err
}
store.mu.Lock()
defer store.mu.Unlock()
root, err := store.ensureRoot()
if err != nil {
return licensing.RevocationList{}, err
}
if err := writeReplacementDocument(root, filepath.Join(root, "revocations-v1.json"), document); err != nil {
return licensing.RevocationList{}, err
}
list.RevokedLicenseIDs = append([]string(nil), list.RevokedLicenseIDs...)
return list, nil
}
// LoadRevocations revalidates the cached list. found is false only when no
// cache has ever been stored; malformed storage is never treated as absent.
func (store *LicenseStore) LoadRevocations(
verifier licensing.RevocationVerifier,
) (list licensing.RevocationList, found bool, err error) {
if store == nil {
return licensing.RevocationList{}, false, ErrLicenseStoreUnsafe
}
store.mu.Lock()
defer store.mu.Unlock()
root, exists, err := store.inspectRoot()
if err != nil || !exists {
return licensing.RevocationList{}, false, err
}
document, found, err := readStoredDocument(filepath.Join(root, "revocations-v1.json"))
if err != nil || !found {
return licensing.RevocationList{}, found, err
}
list, err = verifier.Verify(document)
if err != nil {
return licensing.RevocationList{}, true, err
}
list.RevokedLicenseIDs = append([]string(nil), list.RevokedLicenseIDs...)
return list, true, nil
}
func (store *LicenseStore) ensureDocumentsDirectory() (string, error) {
root, err := store.ensureRoot()
if err != nil {
return "", err
}
directory := filepath.Join(root, "v1")
if err := os.Mkdir(directory, 0o700); err != nil && !os.IsExist(err) {
return "", ErrLicenseStoreInvalid
}
if err := requireLicenseDirectory(directory); err != nil {
return "", err
}
return directory, nil
}
func (store *LicenseStore) inspectDocumentsDirectory() (string, bool, error) {
root, exists, err := store.inspectRoot()
if err != nil || !exists {
return "", false, err
}
directory := filepath.Join(root, "v1")
info, err := os.Lstat(directory)
if os.IsNotExist(err) {
return "", false, nil
}
if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return "", false, ErrLicenseStoreUnsafe
}
return directory, true, nil
}
func (store *LicenseStore) ensureRoot() (string, error) {
if store == nil || store.root == "" {
return "", ErrLicenseStoreUnsafe
}
root, err := filepath.Abs(store.root)
if err != nil {
return "", ErrLicenseStoreUnsafe
}
if err := os.MkdirAll(root, 0o700); err != nil {
return "", ErrLicenseStoreInvalid
}
if err := requireLicenseDirectory(root); err != nil {
return "", err
}
return root, nil
}
func (store *LicenseStore) inspectRoot() (string, bool, error) {
if store == nil || store.root == "" {
return "", false, ErrLicenseStoreUnsafe
}
root, err := filepath.Abs(store.root)
if err != nil {
return "", false, ErrLicenseStoreUnsafe
}
info, err := os.Lstat(root)
if os.IsNotExist(err) {
return "", false, nil
}
if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return "", false, ErrLicenseStoreUnsafe
}
return root, true, nil
}
func requireLicenseDirectory(path string) error {
info, err := os.Lstat(path)
if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return ErrLicenseStoreUnsafe
}
return nil
}
func readStoredDocument(path string) ([]byte, bool, error) {
info, err := os.Lstat(path)
if os.IsNotExist(err) {
return nil, false, nil
}
if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() ||
info.Size() <= 0 || info.Size() > MaxLicenseDocumentBytes {
return nil, false, ErrLicenseStoreUnsafe
}
file, err := os.Open(path)
if err != nil {
return nil, false, ErrLicenseStoreInvalid
}
defer file.Close()
document, err := io.ReadAll(io.LimitReader(file, MaxLicenseDocumentBytes+1))
if err != nil || len(document) == 0 || int64(len(document)) > MaxLicenseDocumentBytes {
return nil, false, ErrLicenseStoreInvalid
}
return document, true, nil
}
func writeNewLicenseDocument(directory, target string, document []byte) error {
temporary, err := os.CreateTemp(directory, ".license-*.tmp")
if err != nil {
return ErrLicenseStoreInvalid
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
if err := writeAndCloseLicenseDocument(temporary, document); err != nil {
return err
}
if err := os.Rename(temporaryPath, target); err != nil {
return ErrLicenseStoreInvalid
}
return nil
}
func writeReplacementDocument(directory, target string, document []byte) error {
temporary, err := os.CreateTemp(directory, ".revocations-*.tmp")
if err != nil {
return ErrLicenseStoreInvalid
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
if err := writeAndCloseLicenseDocument(temporary, document); err != nil {
return err
}
backup := target + ".backup"
if existing, found, err := readStoredDocument(target); err != nil {
return err
} else if found {
if len(existing) == 0 {
return ErrLicenseStoreInvalid
}
if info, err := os.Lstat(backup); err == nil {
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || os.Remove(backup) != nil {
return ErrLicenseStoreUnsafe
}
} else if !os.IsNotExist(err) {
return ErrLicenseStoreUnsafe
}
if err := os.Rename(target, backup); err != nil {
return ErrLicenseStoreInvalid
}
if err := os.Rename(temporaryPath, target); err != nil {
_ = os.Rename(backup, target)
return ErrLicenseStoreInvalid
}
if err := os.Remove(backup); err != nil && !os.IsNotExist(err) {
return ErrLicenseStoreInvalid
}
return nil
}
if err := os.Rename(temporaryPath, target); err != nil {
return ErrLicenseStoreInvalid
}
return nil
}
func writeAndCloseLicenseDocument(file *os.File, document []byte) error {
if err := file.Chmod(0o600); err != nil {
file.Close()
return ErrLicenseStoreInvalid
}
if _, err := file.Write(document); err != nil {
file.Close()
return ErrLicenseStoreInvalid
}
if err := file.Sync(); err != nil {
file.Close()
return ErrLicenseStoreInvalid
}
if err := file.Close(); err != nil {
return ErrLicenseStoreInvalid
}
return nil
}
func fmtLicenseDocumentName(digest [sha256.Size]byte) string {
const hex = "0123456789abcdef"
name := make([]byte, sha256.Size*2+len(".license"))
for index, value := range digest {
name[index*2] = hex[value>>4]
name[index*2+1] = hex[value&0x0f]
}
copy(name[sha256.Size*2:], ".license")
return string(name)
}
+188
View File
@@ -0,0 +1,188 @@
package storage
import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
"time"
"softbox.local/core/internal/canonicaljson"
"softbox.local/core/licensing"
)
const storageTestMachineHash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
func TestLicenseStoreImportsRevalidatesAndDeduplicates(t *testing.T) {
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
verifier, err := licensing.NewVerifier(publicKey)
if err != nil {
t.Fatal(err)
}
store := NewLicenseStore(filepath.Join(t.TempDir(), "licenses"))
document := storageSignedLicense(t, privateKey, "lic-storage-test", []string{"product-test"})
license, imported, err := store.Import(document, verifier, storageTestMachineHash)
if err != nil || !imported || !license.AuthorizesProduct("product-test") {
t.Fatalf("Import() = (%#v, %t, %v)", license, imported, err)
}
if _, imported, err := store.Import(document, verifier, storageTestMachineHash); err != nil || imported {
t.Fatalf("duplicate Import() = (%t, %v), want false, nil", imported, err)
}
licenses, err := store.List(verifier, storageTestMachineHash)
if err != nil || len(licenses) != 1 || !licenses[0].AuthorizesProduct("product-test") {
t.Fatalf("List() = (%#v, %v)", licenses, err)
}
licenses[0].Products[0] = "mutated"
again, err := store.List(verifier, storageTestMachineHash)
if err != nil || !again[0].AuthorizesProduct("product-test") {
t.Fatalf("List() after output mutation = (%#v, %v)", again, err)
}
entries, err := os.ReadDir(filepath.Join(store.root, "v1"))
if err != nil || len(entries) != 1 {
t.Fatalf("stored entries = %#v, %v", entries, err)
}
if err := os.WriteFile(filepath.Join(store.root, "v1", entries[0].Name()), []byte("{}"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := store.List(verifier, storageTestMachineHash); !errors.Is(err, ErrLicenseStoreInvalid) {
// List returns the verifier's stable failure, not a partial authorization set.
t.Fatalf("List(tampered) error = %v", err)
}
}
func TestLicenseStoreRejectsDigestNameMismatch(t *testing.T) {
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
verifier, err := licensing.NewVerifier(publicKey)
if err != nil {
t.Fatal(err)
}
store := NewLicenseStore(filepath.Join(t.TempDir(), "licenses"))
document := storageSignedLicense(t, privateKey, "lic-storage-test", []string{"product-test"})
if _, _, err := store.Import(document, verifier, storageTestMachineHash); err != nil {
t.Fatal(err)
}
directory := filepath.Join(store.root, "v1")
entries, err := os.ReadDir(directory)
if err != nil || len(entries) != 1 {
t.Fatalf("stored entries = %#v, %v", entries, err)
}
if err := os.Rename(filepath.Join(directory, entries[0].Name()), filepath.Join(directory, "0000000000000000000000000000000000000000000000000000000000000000.license")); err != nil {
t.Fatal(err)
}
if _, err := store.List(verifier, storageTestMachineHash); !errors.Is(err, ErrLicenseStoreInvalid) {
t.Fatalf("List(digest mismatch) error = %v", err)
}
}
func TestLicenseStoreRevocationCacheIsVerifiedAndAtomic(t *testing.T) {
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
verifier, err := licensing.NewRevocationVerifier(publicKey)
if err != nil {
t.Fatal(err)
}
store := NewLicenseStore(filepath.Join(t.TempDir(), "licenses"))
generated := time.Date(2026, 7, 20, 0, 0, 0, 0, time.UTC)
document := storageSignedRevocations(t, privateKey, generated, generated.Add(time.Hour), []string{"lic-storage-test"})
stored, err := store.StoreRevocations(document, verifier)
if err != nil || len(stored.RevokedLicenseIDs) != 1 {
t.Fatalf("StoreRevocations() = (%#v, %v)", stored, err)
}
loaded, found, err := store.LoadRevocations(verifier)
if err != nil || !found || loaded.RevokedLicenseIDs[0] != "lic-storage-test" {
t.Fatalf("LoadRevocations() = (%#v, %t, %v)", loaded, found, err)
}
if _, err := store.StoreRevocations([]byte("{}"), verifier); !errors.Is(err, licensing.ErrRevocationSignatureMissing) {
t.Fatalf("StoreRevocations(invalid) error = %v", err)
}
again, found, err := store.LoadRevocations(verifier)
if err != nil || !found || again.RevokedLicenseIDs[0] != "lic-storage-test" {
t.Fatalf("invalid update changed cache: (%#v, %t, %v)", again, found, err)
}
}
func TestLicenseStoreRejectsUnsafeCachedEntry(t *testing.T) {
publicKey, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
verifier, err := licensing.NewVerifier(publicKey)
if err != nil {
t.Fatal(err)
}
root := filepath.Join(t.TempDir(), "licenses")
if err := os.MkdirAll(filepath.Join(root, "v1"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "v1", "unexpected.txt"), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := NewLicenseStore(root).List(verifier, storageTestMachineHash); !errors.Is(err, ErrLicenseStoreUnsafe) {
t.Fatalf("List(unsafe entry) error = %v", err)
}
}
func storageSignedLicense(t *testing.T, privateKey ed25519.PrivateKey, licenseID string, products []string) []byte {
t.Helper()
payload := map[string]any{
"schema_version": json.Number("1"),
"license_id": licenseID,
"machine_hash": storageTestMachineHash,
"products": storageStringSliceAsAny(products),
"issued_at": "2026-07-20T00:00:00Z",
"perpetual": true,
"update_policy": "updates-until-2027-12-31",
"rebind_policy": "self-service-1-per-90d",
}
canonical, err := canonicaljson.Marshal(payload)
if err != nil {
t.Fatal(err)
}
payload["signature"] = base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, canonical))
document, err := canonicaljson.Marshal(payload)
if err != nil {
t.Fatal(err)
}
return document
}
func storageSignedRevocations(t *testing.T, privateKey ed25519.PrivateKey, generated, expires time.Time, ids []string) []byte {
t.Helper()
payload := map[string]any{
"schema_version": json.Number("1"),
"generated_at": generated.Format("2006-01-02T15:04:05Z"),
"expires_at": expires.Format("2006-01-02T15:04:05Z"),
"revoked_license_ids": storageStringSliceAsAny(ids),
}
canonical, err := canonicaljson.Marshal(payload)
if err != nil {
t.Fatal(err)
}
payload["signature"] = base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, canonical))
document, err := canonicaljson.Marshal(payload)
if err != nil {
t.Fatal(err)
}
return document
}
func storageStringSliceAsAny(values []string) []any {
items := make([]any, len(values))
for index, value := range values {
items[index] = value
}
return items
}