Implement authorization import and revocation checks (T-503)
This commit is contained in:
@@ -0,0 +1,418 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"softbox.local/core/licensing"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAuthorizationConfig = errors.New("authorization configuration is invalid")
|
||||
ErrAuthorizationSourceUnconfigured = errors.New("authorization source is unconfigured")
|
||||
ErrAuthorizationUnavailable = errors.New("authorization is unavailable")
|
||||
ErrAuthorizationImport = errors.New("authorization import failed")
|
||||
ErrAuthorizationEventPayload = errors.New("authorization event payload is invalid")
|
||||
)
|
||||
|
||||
var authorizationProductIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
|
||||
|
||||
// AuthorizationState is the sanitized state shown to adapters and callers.
|
||||
type AuthorizationState string
|
||||
|
||||
const (
|
||||
AuthorizationStateReady AuthorizationState = "ready"
|
||||
AuthorizationStateGrace AuthorizationState = "grace"
|
||||
AuthorizationStateNoLicense AuthorizationState = "no_license"
|
||||
AuthorizationStateRevoked AuthorizationState = "revoked"
|
||||
AuthorizationStateUnavailable AuthorizationState = "unavailable"
|
||||
AuthorizationStateUnconfigured AuthorizationState = "unconfigured"
|
||||
AuthorizationStateImportFailed AuthorizationState = "import_failed"
|
||||
)
|
||||
|
||||
// LicenseKind presents only the signed perpetual flag; it does not invent a
|
||||
// trial expiration which License v1 does not carry.
|
||||
type LicenseKind string
|
||||
|
||||
const (
|
||||
LicenseKindPerpetual LicenseKind = "perpetual"
|
||||
LicenseKindNonPerpetual LicenseKind = "non_perpetual"
|
||||
)
|
||||
|
||||
// AuthorizedProduct contains no license ID, source path, signature or source
|
||||
// document. The product ID is the same signed package identity used at launch.
|
||||
type AuthorizedProduct struct {
|
||||
ProductID string
|
||||
Kind LicenseKind
|
||||
RebindPolicy string
|
||||
}
|
||||
|
||||
// AuthorizationSnapshot is an IO-free UI/launcher authorization view.
|
||||
type AuthorizationSnapshot struct {
|
||||
State AuthorizationState
|
||||
MachineHash string
|
||||
Products []AuthorizedProduct
|
||||
}
|
||||
|
||||
// AuthorizationEvent is the typed LicenseChanged payload accepted by adapters.
|
||||
type AuthorizationEvent struct {
|
||||
Type EventType
|
||||
Snapshot AuthorizationSnapshot
|
||||
}
|
||||
|
||||
// AuthorizationStore is the narrow persistence boundary required by the
|
||||
// authorization application service.
|
||||
type AuthorizationStore interface {
|
||||
Import([]byte, licensing.Verifier, string) (licensing.License, bool, error)
|
||||
List(licensing.Verifier, string) ([]licensing.License, error)
|
||||
StoreRevocations([]byte, licensing.RevocationVerifier) (licensing.RevocationList, error)
|
||||
LoadRevocations(licensing.RevocationVerifier) (licensing.RevocationList, bool, error)
|
||||
}
|
||||
|
||||
// LicenseImportSource reads one user-selected document outside Gio Layout.
|
||||
// Platform file dialogs and file I/O belong to its composition implementation.
|
||||
type LicenseImportSource interface {
|
||||
ReadLicense(context.Context) ([]byte, error)
|
||||
}
|
||||
|
||||
// AuthorizationServiceConfig makes every trust and local dependency explicit.
|
||||
type AuthorizationServiceConfig struct {
|
||||
Store AuthorizationStore
|
||||
LicenseVerifier licensing.Verifier
|
||||
RevocationVerifier licensing.RevocationVerifier
|
||||
MachineHash string
|
||||
Clock func() time.Time
|
||||
}
|
||||
|
||||
// AuthorizationService revalidates cached licenses/revocations for every
|
||||
// snapshot and authorization decision. It is core-only and has no Gio or
|
||||
// platform imports.
|
||||
type AuthorizationService struct {
|
||||
store AuthorizationStore
|
||||
licenseVerifier licensing.Verifier
|
||||
revocationVerifier licensing.RevocationVerifier
|
||||
machineHash string
|
||||
clock func() time.Time
|
||||
}
|
||||
|
||||
// NewAuthorizationService creates the configured offline authorization use
|
||||
// case. Invalid verifier values fail during a use rather than falling back.
|
||||
func NewAuthorizationService(config AuthorizationServiceConfig) (*AuthorizationService, error) {
|
||||
if config.Store == nil || !machineHashForAuthorization(config.MachineHash) {
|
||||
return nil, ErrAuthorizationConfig
|
||||
}
|
||||
if config.Clock == nil {
|
||||
config.Clock = time.Now
|
||||
}
|
||||
return &AuthorizationService{
|
||||
store: config.Store,
|
||||
licenseVerifier: config.LicenseVerifier,
|
||||
revocationVerifier: config.RevocationVerifier,
|
||||
machineHash: config.MachineHash,
|
||||
clock: config.Clock,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Snapshot loads a detached, sanitized authorization view.
|
||||
func (service *AuthorizationService) Snapshot(ctx context.Context) (AuthorizationSnapshot, error) {
|
||||
if service == nil || service.store == nil || service.clock == nil {
|
||||
return AuthorizationSnapshot{}, ErrAuthorizationConfig
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return AuthorizationSnapshot{}, err
|
||||
}
|
||||
licenses, err := service.store.List(service.licenseVerifier, service.machineHash)
|
||||
if err != nil {
|
||||
return unavailableAuthorizationSnapshot(service.machineHash), fmt.Errorf("%w: cache", ErrAuthorizationUnavailable)
|
||||
}
|
||||
list, found, err := service.store.LoadRevocations(service.revocationVerifier)
|
||||
if err != nil || !found {
|
||||
return unavailableAuthorizationSnapshot(service.machineHash), fmt.Errorf("%w: revocation cache", ErrAuthorizationUnavailable)
|
||||
}
|
||||
snapshot := authorizationSnapshotFromVerified(licenses, list, service.machineHash, service.clock())
|
||||
if snapshot.State == AuthorizationStateUnavailable {
|
||||
return snapshot, ErrAuthorizationUnavailable
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
// Import validates and persists one document before returning the refreshed
|
||||
// authorization view. No unverified document is retained.
|
||||
func (service *AuthorizationService) Import(ctx context.Context, document []byte) (AuthorizationSnapshot, error) {
|
||||
if service == nil || service.store == nil {
|
||||
return AuthorizationSnapshot{}, ErrAuthorizationConfig
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return AuthorizationSnapshot{}, err
|
||||
}
|
||||
if _, _, err := service.store.Import(document, service.licenseVerifier, service.machineHash); err != nil {
|
||||
return unavailableAuthorizationSnapshot(service.machineHash), fmt.Errorf("%w", ErrAuthorizationImport)
|
||||
}
|
||||
return service.Snapshot(ctx)
|
||||
}
|
||||
|
||||
// UpdateRevocations validates and persists a signed list then refreshes state.
|
||||
func (service *AuthorizationService) UpdateRevocations(ctx context.Context, document []byte) (AuthorizationSnapshot, error) {
|
||||
if service == nil || service.store == nil {
|
||||
return AuthorizationSnapshot{}, ErrAuthorizationConfig
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return AuthorizationSnapshot{}, err
|
||||
}
|
||||
if _, err := service.store.StoreRevocations(document, service.revocationVerifier); err != nil {
|
||||
return unavailableAuthorizationSnapshot(service.machineHash), fmt.Errorf("%w", ErrAuthorizationUnavailable)
|
||||
}
|
||||
return service.Snapshot(ctx)
|
||||
}
|
||||
|
||||
// IsAuthorized implements the launch AuthorizationChecker contract for a
|
||||
// package product ID. Missing/stale revocations are unavailable, never allow.
|
||||
func (service *AuthorizationService) IsAuthorized(productID string) (bool, error) {
|
||||
if !authorizationProductIDPattern.MatchString(productID) {
|
||||
return false, ErrAuthorizationUnavailable
|
||||
}
|
||||
snapshot, err := service.Snapshot(context.Background())
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if snapshot.State != AuthorizationStateReady && snapshot.State != AuthorizationStateGrace {
|
||||
return false, nil
|
||||
}
|
||||
for _, product := range snapshot.Products {
|
||||
if product.ProductID == productID {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func authorizationSnapshotFromVerified(
|
||||
licenses []licensing.License,
|
||||
list licensing.RevocationList,
|
||||
machineHash string,
|
||||
now time.Time,
|
||||
) AuthorizationSnapshot {
|
||||
if now.Before(list.GeneratedAt) || now.After(list.ExpiresAt.Add(licensing.RevocationGrace)) {
|
||||
return unavailableAuthorizationSnapshot(machineHash)
|
||||
}
|
||||
products := make(map[string]AuthorizedProduct)
|
||||
hasGrace := false
|
||||
hasRevoked := false
|
||||
for _, license := range licenses {
|
||||
switch list.StateFor(license.LicenseID, now) {
|
||||
case licensing.RevocationStateCurrent:
|
||||
addAuthorizedProducts(products, license)
|
||||
case licensing.RevocationStateGrace:
|
||||
hasGrace = true
|
||||
addAuthorizedProducts(products, license)
|
||||
case licensing.RevocationStateRevoked:
|
||||
hasRevoked = true
|
||||
}
|
||||
}
|
||||
if len(products) == 0 {
|
||||
state := AuthorizationStateNoLicense
|
||||
if hasRevoked {
|
||||
state = AuthorizationStateRevoked
|
||||
}
|
||||
return AuthorizationSnapshot{State: state, MachineHash: machineHash, Products: []AuthorizedProduct{}}
|
||||
}
|
||||
ordered := make([]AuthorizedProduct, 0, len(products))
|
||||
for _, product := range products {
|
||||
ordered = append(ordered, product)
|
||||
}
|
||||
sort.Slice(ordered, func(left, right int) bool {
|
||||
return ordered[left].ProductID < ordered[right].ProductID
|
||||
})
|
||||
state := AuthorizationStateReady
|
||||
if hasGrace {
|
||||
state = AuthorizationStateGrace
|
||||
}
|
||||
return AuthorizationSnapshot{State: state, MachineHash: machineHash, Products: ordered}
|
||||
}
|
||||
|
||||
func addAuthorizedProducts(products map[string]AuthorizedProduct, license licensing.License) {
|
||||
kind := LicenseKindNonPerpetual
|
||||
if license.Perpetual {
|
||||
kind = LicenseKindPerpetual
|
||||
}
|
||||
for _, productID := range license.Products {
|
||||
candidate := AuthorizedProduct{ProductID: productID, Kind: kind, RebindPolicy: license.RebindPolicy}
|
||||
if existing, exists := products[productID]; !exists ||
|
||||
(existing.Kind == LicenseKindNonPerpetual && candidate.Kind == LicenseKindPerpetual) {
|
||||
products[productID] = candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func unavailableAuthorizationSnapshot(machineHash string) AuthorizationSnapshot {
|
||||
return AuthorizationSnapshot{
|
||||
State: AuthorizationStateUnavailable,
|
||||
MachineHash: machineHash,
|
||||
Products: []AuthorizedProduct{},
|
||||
}
|
||||
}
|
||||
|
||||
func machineHashForAuthorization(value string) bool {
|
||||
if len(value) != 64 {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if !(character >= '0' && character <= '9') && !(character >= 'a' && character <= 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// AuthorizationSnapshotLoader supplies one background-loaded authorization
|
||||
// state. It permits the explicit unconfigured default without test-key fallback.
|
||||
type AuthorizationSnapshotLoader interface {
|
||||
LoadAuthorizationSnapshot(context.Context) (AuthorizationSnapshot, error)
|
||||
}
|
||||
|
||||
// LoadAuthorizationSnapshot adapts AuthorizationService for bootstrap use.
|
||||
func (service *AuthorizationService) LoadAuthorizationSnapshot(ctx context.Context) (AuthorizationSnapshot, error) {
|
||||
return service.Snapshot(ctx)
|
||||
}
|
||||
|
||||
// UnconfiguredAuthorizationLoader is the safe default until release
|
||||
// composition supplies a trusted key, machine hash and revocation source.
|
||||
type UnconfiguredAuthorizationLoader struct{}
|
||||
|
||||
func (UnconfiguredAuthorizationLoader) LoadAuthorizationSnapshot(ctx context.Context) (AuthorizationSnapshot, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return AuthorizationSnapshot{}, err
|
||||
}
|
||||
return AuthorizationSnapshot{State: AuthorizationStateUnconfigured, Products: []AuthorizedProduct{}}, ErrAuthorizationSourceUnconfigured
|
||||
}
|
||||
|
||||
// AuthorizationBootstrap publishes exactly one sanitized startup state.
|
||||
type AuthorizationBootstrap struct {
|
||||
loader AuthorizationSnapshotLoader
|
||||
publisher EventPublisher
|
||||
}
|
||||
|
||||
func NewAuthorizationBootstrap(loader AuthorizationSnapshotLoader, publisher EventPublisher) *AuthorizationBootstrap {
|
||||
return &AuthorizationBootstrap{loader: loader, publisher: publisher}
|
||||
}
|
||||
|
||||
func (bootstrap *AuthorizationBootstrap) Run(ctx context.Context) error {
|
||||
if bootstrap == nil || bootstrap.loader == nil || bootstrap.publisher == nil {
|
||||
return ErrAuthorizationConfig
|
||||
}
|
||||
snapshot, err := bootstrap.loader.LoadAuthorizationSnapshot(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrAuthorizationSourceUnconfigured) {
|
||||
snapshot = AuthorizationSnapshot{State: AuthorizationStateUnconfigured, Products: []AuthorizedProduct{}}
|
||||
} else if !validAuthorizationSnapshot(snapshot) {
|
||||
snapshot = AuthorizationSnapshot{State: AuthorizationStateUnavailable, Products: []AuthorizedProduct{}}
|
||||
}
|
||||
}
|
||||
publishErr := bootstrap.publisher.Publish(ctx, NewAuthorizationEvent(snapshot))
|
||||
if publishErr != nil {
|
||||
return errors.Join(err, publishErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// LicenseImport publishes a refreshed authorization snapshot from one
|
||||
// background-only source read.
|
||||
type LicenseImport struct {
|
||||
service *AuthorizationService
|
||||
source LicenseImportSource
|
||||
publisher EventPublisher
|
||||
}
|
||||
|
||||
func NewLicenseImport(service *AuthorizationService, source LicenseImportSource, publisher EventPublisher) *LicenseImport {
|
||||
return &LicenseImport{service: service, source: source, publisher: publisher}
|
||||
}
|
||||
|
||||
func (useCase *LicenseImport) Run(ctx context.Context) error {
|
||||
if useCase == nil || useCase.service == nil || useCase.source == nil || useCase.publisher == nil {
|
||||
return ErrAuthorizationConfig
|
||||
}
|
||||
document, err := useCase.source.ReadLicense(ctx)
|
||||
if err == nil {
|
||||
_, err = useCase.service.Import(ctx, document)
|
||||
}
|
||||
snapshot, snapshotErr := useCase.service.Snapshot(ctx)
|
||||
if snapshotErr != nil || err != nil {
|
||||
snapshot = AuthorizationSnapshot{State: AuthorizationStateImportFailed, MachineHash: useCase.service.machineHash, Products: []AuthorizedProduct{}}
|
||||
}
|
||||
publishErr := useCase.publisher.Publish(ctx, NewAuthorizationEvent(snapshot))
|
||||
return errors.Join(err, snapshotErr, publishErr)
|
||||
}
|
||||
|
||||
// NewAuthorizationEvent deep-copies a validated snapshot into LicenseChanged.
|
||||
func NewAuthorizationEvent(snapshot AuthorizationSnapshot) Event {
|
||||
return Event{Type: EventLicenseChanged, Payload: AuthorizationEvent{
|
||||
Type: EventLicenseChanged,
|
||||
Snapshot: cloneAuthorizationSnapshot(snapshot),
|
||||
}}
|
||||
}
|
||||
|
||||
// ParseAuthorizationEvent validates and deep-copies a LicenseChanged payload.
|
||||
func ParseAuthorizationEvent(event Event) (AuthorizationEvent, bool, error) {
|
||||
if event.Type != EventLicenseChanged {
|
||||
return AuthorizationEvent{}, false, nil
|
||||
}
|
||||
payload, ok := event.Payload.(AuthorizationEvent)
|
||||
if !ok || payload.Type != EventLicenseChanged || event.RequestID != "" || event.AppID != "" ||
|
||||
!validAuthorizationSnapshot(payload.Snapshot) {
|
||||
return AuthorizationEvent{}, true, ErrAuthorizationEventPayload
|
||||
}
|
||||
payload.Snapshot = cloneAuthorizationSnapshot(payload.Snapshot)
|
||||
return payload, true, nil
|
||||
}
|
||||
|
||||
func cloneAuthorizationSnapshot(snapshot AuthorizationSnapshot) AuthorizationSnapshot {
|
||||
products := make([]AuthorizedProduct, len(snapshot.Products))
|
||||
copy(products, snapshot.Products)
|
||||
snapshot.Products = products
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func validAuthorizationSnapshot(snapshot AuthorizationSnapshot) bool {
|
||||
if !snapshot.State.valid() {
|
||||
return false
|
||||
}
|
||||
if snapshot.State == AuthorizationStateUnconfigured && snapshot.MachineHash != "" {
|
||||
return false
|
||||
}
|
||||
if snapshot.State != AuthorizationStateUnconfigured && !machineHashForAuthorization(snapshot.MachineHash) {
|
||||
return false
|
||||
}
|
||||
if snapshot.Products == nil {
|
||||
return false
|
||||
}
|
||||
seen := make(map[string]struct{}, len(snapshot.Products))
|
||||
for _, product := range snapshot.Products {
|
||||
if !authorizationProductIDPattern.MatchString(product.ProductID) ||
|
||||
(product.Kind != LicenseKindPerpetual && product.Kind != LicenseKindNonPerpetual) ||
|
||||
product.RebindPolicy == "" {
|
||||
return false
|
||||
}
|
||||
if _, exists := seen[product.ProductID]; exists {
|
||||
return false
|
||||
}
|
||||
seen[product.ProductID] = struct{}{}
|
||||
}
|
||||
if (snapshot.State == AuthorizationStateReady || snapshot.State == AuthorizationStateGrace) && len(snapshot.Products) == 0 {
|
||||
return false
|
||||
}
|
||||
if snapshot.State != AuthorizationStateReady && snapshot.State != AuthorizationStateGrace && len(snapshot.Products) != 0 {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (state AuthorizationState) valid() bool {
|
||||
return state == AuthorizationStateReady || state == AuthorizationStateGrace ||
|
||||
state == AuthorizationStateNoLicense || state == AuthorizationStateRevoked ||
|
||||
state == AuthorizationStateUnavailable || state == AuthorizationStateUnconfigured ||
|
||||
state == AuthorizationStateImportFailed
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"softbox.local/core/licensing"
|
||||
)
|
||||
|
||||
const applicationTestMachineHash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
|
||||
func TestAuthorizationServiceUsesCurrentGraceAndRevocationStates(t *testing.T) {
|
||||
now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
|
||||
store := &authorizationStoreFake{
|
||||
licenses: []licensing.License{{
|
||||
LicenseID: "lic-active", Products: []string{"product-active"}, Perpetual: true, RebindPolicy: "support-only",
|
||||
}, {
|
||||
LicenseID: "lic-revoked", Products: []string{"product-revoked"}, RebindPolicy: "support-only",
|
||||
}},
|
||||
revocations: licensing.RevocationList{
|
||||
GeneratedAt: now.Add(-2 * time.Hour), ExpiresAt: now.Add(time.Hour), RevokedLicenseIDs: []string{"lic-revoked"},
|
||||
},
|
||||
found: true,
|
||||
}
|
||||
service := newAuthorizationServiceForTest(t, store, now)
|
||||
snapshot, err := service.Snapshot(context.Background())
|
||||
if err != nil || snapshot.State != AuthorizationStateReady || len(snapshot.Products) != 1 || snapshot.Products[0].ProductID != "product-active" {
|
||||
t.Fatalf("Snapshot() = (%#v, %v)", snapshot, err)
|
||||
}
|
||||
if authorized, err := service.IsAuthorized("product-active"); err != nil || !authorized {
|
||||
t.Fatalf("IsAuthorized(active) = (%t, %v)", authorized, err)
|
||||
}
|
||||
if authorized, err := service.IsAuthorized("product-revoked"); err != nil || authorized {
|
||||
t.Fatalf("IsAuthorized(revoked) = (%t, %v)", authorized, err)
|
||||
}
|
||||
|
||||
store.revocations.ExpiresAt = now.Add(-time.Hour)
|
||||
snapshot, err = service.Snapshot(context.Background())
|
||||
if err != nil || snapshot.State != AuthorizationStateGrace {
|
||||
t.Fatalf("Snapshot(grace) = (%#v, %v)", snapshot, err)
|
||||
}
|
||||
store.revocations.ExpiresAt = now.Add(-licensing.RevocationGrace - time.Second)
|
||||
snapshot, err = service.Snapshot(context.Background())
|
||||
if !errors.Is(err, ErrAuthorizationUnavailable) || snapshot.State != AuthorizationStateUnavailable {
|
||||
t.Fatalf("Snapshot(stale) = (%#v, %v)", snapshot, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizationServiceFailsClosedWithoutRevocations(t *testing.T) {
|
||||
now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
|
||||
store := &authorizationStoreFake{licenses: []licensing.License{{
|
||||
LicenseID: "lic-active", Products: []string{"product-active"}, RebindPolicy: "support-only",
|
||||
}}}
|
||||
service := newAuthorizationServiceForTest(t, store, now)
|
||||
snapshot, err := service.Snapshot(context.Background())
|
||||
if !errors.Is(err, ErrAuthorizationUnavailable) || snapshot.State != AuthorizationStateUnavailable {
|
||||
t.Fatalf("Snapshot() = (%#v, %v)", snapshot, err)
|
||||
}
|
||||
if authorized, err := service.IsAuthorized("product-active"); authorized || !errors.Is(err, ErrAuthorizationUnavailable) {
|
||||
t.Fatalf("IsAuthorized() = (%t, %v)", authorized, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizationEventsAndBootstrapAreSanitized(t *testing.T) {
|
||||
snapshot := AuthorizationSnapshot{
|
||||
State: AuthorizationStateReady, MachineHash: applicationTestMachineHash,
|
||||
Products: []AuthorizedProduct{{ProductID: "product-test", Kind: LicenseKindPerpetual, RebindPolicy: "support-only"}},
|
||||
}
|
||||
event := NewAuthorizationEvent(snapshot)
|
||||
snapshot.Products[0].ProductID = "mutated"
|
||||
parsed, handled, err := ParseAuthorizationEvent(event)
|
||||
if err != nil || !handled || parsed.Snapshot.Products[0].ProductID != "product-test" {
|
||||
t.Fatalf("ParseAuthorizationEvent() = (%#v, %t, %v)", parsed, handled, err)
|
||||
}
|
||||
if _, _, err := ParseAuthorizationEvent(Event{Type: EventLicenseChanged, Payload: "raw license"}); !errors.Is(err, ErrAuthorizationEventPayload) {
|
||||
t.Fatalf("ParseAuthorizationEvent(bad) error = %v", err)
|
||||
}
|
||||
|
||||
runtime := NewRuntime(1)
|
||||
err = NewAuthorizationBootstrap(UnconfiguredAuthorizationLoader{}, runtime).Run(context.Background())
|
||||
if !errors.Is(err, ErrAuthorizationSourceUnconfigured) {
|
||||
t.Fatalf("Bootstrap.Run() error = %v", err)
|
||||
}
|
||||
event = <-runtime.Events()
|
||||
payload, handled, err := ParseAuthorizationEvent(event)
|
||||
if err != nil || !handled || payload.Snapshot.State != AuthorizationStateUnconfigured {
|
||||
t.Fatalf("unconfigured event = (%#v, %t, %v)", payload, handled, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLicenseImportPublishesSanitizedFailure(t *testing.T) {
|
||||
now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
|
||||
service := newAuthorizationServiceForTest(t, &authorizationStoreFake{}, now)
|
||||
runtime := NewRuntime(1)
|
||||
err := NewLicenseImport(service, licenseSourceFake{document: []byte(`{"secret":"never publish"}`)}, runtime).Run(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("Run() unexpectedly succeeded")
|
||||
}
|
||||
payload, handled, parseErr := ParseAuthorizationEvent(<-runtime.Events())
|
||||
if parseErr != nil || !handled || payload.Snapshot.State != AuthorizationStateImportFailed ||
|
||||
payload.Snapshot.MachineHash != applicationTestMachineHash || len(payload.Snapshot.Products) != 0 {
|
||||
t.Fatalf("import event = (%#v, %t, %v)", payload, handled, parseErr)
|
||||
}
|
||||
}
|
||||
|
||||
func newAuthorizationServiceForTest(t *testing.T, store *authorizationStoreFake, now time.Time) *AuthorizationService {
|
||||
t.Helper()
|
||||
service, err := NewAuthorizationService(AuthorizationServiceConfig{
|
||||
Store: store, MachineHash: applicationTestMachineHash, Clock: func() time.Time { return now },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
type authorizationStoreFake struct {
|
||||
licenses []licensing.License
|
||||
revocations licensing.RevocationList
|
||||
found bool
|
||||
listErr error
|
||||
revokedErr error
|
||||
}
|
||||
|
||||
type licenseSourceFake struct {
|
||||
document []byte
|
||||
err error
|
||||
}
|
||||
|
||||
func (source licenseSourceFake) ReadLicense(context.Context) ([]byte, error) {
|
||||
return append([]byte(nil), source.document...), source.err
|
||||
}
|
||||
|
||||
func (store *authorizationStoreFake) Import(_ []byte, _ licensing.Verifier, _ string) (licensing.License, bool, error) {
|
||||
return licensing.License{}, false, errors.New("not used")
|
||||
}
|
||||
|
||||
func (store *authorizationStoreFake) List(_ licensing.Verifier, _ string) ([]licensing.License, error) {
|
||||
if store.listErr != nil {
|
||||
return nil, store.listErr
|
||||
}
|
||||
licenses := append([]licensing.License(nil), store.licenses...)
|
||||
for index := range licenses {
|
||||
licenses[index].Products = append([]string(nil), licenses[index].Products...)
|
||||
}
|
||||
return licenses, nil
|
||||
}
|
||||
|
||||
func (store *authorizationStoreFake) StoreRevocations(_ []byte, _ licensing.RevocationVerifier) (licensing.RevocationList, error) {
|
||||
return licensing.RevocationList{}, errors.New("not used")
|
||||
}
|
||||
|
||||
func (store *authorizationStoreFake) LoadRevocations(_ licensing.RevocationVerifier) (licensing.RevocationList, bool, error) {
|
||||
if store.revokedErr != nil {
|
||||
return licensing.RevocationList{}, false, store.revokedErr
|
||||
}
|
||||
list := store.revocations
|
||||
list.RevokedLicenseIDs = append([]string(nil), list.RevokedLicenseIDs...)
|
||||
return list, store.found, nil
|
||||
}
|
||||
@@ -198,6 +198,8 @@ func (service *InstallService) Install(request InstallRequest) (InstallResult, e
|
||||
record.Entrypoint = expectation.App.Entrypoint
|
||||
record.WorkingDirectory = extracted.WorkingDir
|
||||
record.MinOS = expectation.App.MinOS
|
||||
record.ProductID = extracted.ProductID
|
||||
record.SupportsTrial = extracted.SupportsTrial
|
||||
record.RequiresAdmin = expectation.App.RequiresAdmin
|
||||
|
||||
var recordWriteErr error
|
||||
|
||||
@@ -46,7 +46,8 @@ func TestInstallServiceInstallsVerifiedPackageAndRecordsPayloadFiles(t *testing.
|
||||
t.Fatalf("record = %#v", record)
|
||||
}
|
||||
if record.Entrypoint != "bin/App.exe" || record.WorkingDirectory != "." ||
|
||||
record.MinOS != "windows-10" || record.RequiresAdmin {
|
||||
record.MinOS != "windows-10" || record.ProductID != "test-product" ||
|
||||
record.SupportsTrial || record.RequiresAdmin {
|
||||
t.Fatalf("launch metadata = %#v", record)
|
||||
}
|
||||
if record.Files[0].Path != "bin/App.exe" || record.Files[0].Size != int64(len("new executable")) {
|
||||
|
||||
@@ -14,20 +14,21 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrLaunchConfig = errors.New("invalid launch service configuration")
|
||||
ErrLaunchRequest = errors.New("invalid launch request")
|
||||
ErrAppNotInstalled = errors.New("app is not installed")
|
||||
ErrLaunchMetadata = errors.New("installed launch metadata is invalid")
|
||||
ErrLaunchTargetUnsafe = errors.New("installed launch target is unsafe")
|
||||
ErrEntrypointMissing = errors.New("installed entrypoint is missing")
|
||||
ErrCompatibilityCheck = errors.New("system compatibility check failed")
|
||||
ErrAppIncompatible = errors.New("installed app is incompatible with this system")
|
||||
ErrAuthorizationCheck = errors.New("launch authorization check failed")
|
||||
ErrLaunchUnauthorized = errors.New("launch is not authorized")
|
||||
ErrTargetStateCheck = errors.New("launch target state check failed")
|
||||
ErrAppRunning = errors.New("installed app is already running")
|
||||
ErrProcessStart = errors.New("start installed app")
|
||||
launchAppIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
|
||||
ErrLaunchConfig = errors.New("invalid launch service configuration")
|
||||
ErrLaunchRequest = errors.New("invalid launch request")
|
||||
ErrAppNotInstalled = errors.New("app is not installed")
|
||||
ErrLaunchMetadata = errors.New("installed launch metadata is invalid")
|
||||
ErrLaunchTargetUnsafe = errors.New("installed launch target is unsafe")
|
||||
ErrEntrypointMissing = errors.New("installed entrypoint is missing")
|
||||
ErrCompatibilityCheck = errors.New("system compatibility check failed")
|
||||
ErrAppIncompatible = errors.New("installed app is incompatible with this system")
|
||||
ErrAuthorizationCheck = errors.New("launch authorization check failed")
|
||||
ErrLaunchUnauthorized = errors.New("launch is not authorized")
|
||||
ErrTargetStateCheck = errors.New("launch target state check failed")
|
||||
ErrAppRunning = errors.New("installed app is already running")
|
||||
ErrProcessStart = errors.New("start installed app")
|
||||
launchAppIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
|
||||
launchProductIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
|
||||
)
|
||||
|
||||
// FailureCode is the stable, non-localized result of a launch attempt.
|
||||
@@ -91,10 +92,10 @@ type CompatibilityChecker interface {
|
||||
IsCompatible(minOS string) (bool, error)
|
||||
}
|
||||
|
||||
// AuthorizationChecker decides whether the user may launch one app. It is a
|
||||
// required boundary; license policy is implemented by the later licensing task.
|
||||
// AuthorizationChecker decides whether the user may launch one signed
|
||||
// package product. It is a required fail-closed boundary.
|
||||
type AuthorizationChecker interface {
|
||||
IsAuthorized(appID string) (bool, error)
|
||||
IsAuthorized(productID string) (bool, error)
|
||||
}
|
||||
|
||||
// TargetStateChecker reports whether this precise entrypoint is running.
|
||||
@@ -177,7 +178,10 @@ func (service *Service) Start(request Request) (Result, error) {
|
||||
if !compatible {
|
||||
return Result{}, launchError(ErrAppIncompatible)
|
||||
}
|
||||
authorized, err := service.authorization.IsAuthorized(record.ID)
|
||||
if !launchProductIDPattern.MatchString(record.ProductID) {
|
||||
return Result{}, launchError(ErrAuthorizationCheck)
|
||||
}
|
||||
authorized, err := service.authorization.IsAuthorized(record.ProductID)
|
||||
if err != nil {
|
||||
return Result{}, launchError(fmt.Errorf("%w: %w", ErrAuthorizationCheck, err))
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ func TestServiceStartsOnlyVerifiedCurrentEntrypoint(t *testing.T) {
|
||||
store, appRoot := seedInstalledApp(t)
|
||||
launcher := &recordingLauncher{pid: 42}
|
||||
service := newService(t, store, launcher)
|
||||
var checkedProduct string
|
||||
service.authorization = authorizationFunc(func(productID string) (bool, error) {
|
||||
checkedProduct = productID
|
||||
return true, nil
|
||||
})
|
||||
|
||||
result, err := service.Start(Request{AppID: "test-app"})
|
||||
if err != nil {
|
||||
@@ -27,6 +32,9 @@ func TestServiceStartsOnlyVerifiedCurrentEntrypoint(t *testing.T) {
|
||||
!launcher.command.RequiresAdmin {
|
||||
t.Fatalf("command = %#v", launcher.command)
|
||||
}
|
||||
if checkedProduct != "test-product" {
|
||||
t.Fatalf("authorization product = %q, want test-product", checkedProduct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceRejectsUnsafeOrUnavailableLaunchStates(t *testing.T) {
|
||||
@@ -49,6 +57,14 @@ func TestServiceRejectsUnsafeOrUnavailableLaunchStates(t *testing.T) {
|
||||
wantErr: ErrLaunchMetadata,
|
||||
wantCode: FailureCodeLaunchMetadataInvalid,
|
||||
},
|
||||
{
|
||||
name: "missing legacy product metadata",
|
||||
mutate: func(record *storage.InstalledApp, _ string) {
|
||||
record.ProductID = ""
|
||||
},
|
||||
wantErr: ErrAuthorizationCheck,
|
||||
wantCode: FailureCodeAuthorizationFailed,
|
||||
},
|
||||
{
|
||||
name: "entrypoint is absent",
|
||||
mutate: func(_ *storage.InstalledApp, appRoot string) {
|
||||
@@ -202,6 +218,7 @@ func seedInstalledApp(t *testing.T) (*storage.InstalledAppStore, string) {
|
||||
Entrypoint: "bin/App.exe",
|
||||
WorkingDirectory: "bin",
|
||||
MinOS: "windows-10",
|
||||
ProductID: "test-product",
|
||||
RequiresAdmin: true,
|
||||
Files: []storage.InstalledFile{{
|
||||
Path: "bin/App.exe",
|
||||
|
||||
@@ -49,6 +49,8 @@ type ExtractResult struct {
|
||||
Bytes int64
|
||||
EntrypointPath string
|
||||
WorkingDir string
|
||||
ProductID string
|
||||
SupportsTrial bool
|
||||
PayloadFiles []ExtractedFile
|
||||
}
|
||||
|
||||
|
||||
@@ -198,6 +198,8 @@ func (extractor Extractor) ExtractVerifiedFileWithCheck(
|
||||
return ExtractResult{}, packageError(PackageStageExtract, err)
|
||||
}
|
||||
result.WorkingDir = manifest.WorkingDir
|
||||
result.ProductID = manifest.ProductID
|
||||
result.SupportsTrial = manifest.SupportsTrial
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
package licensing
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"softbox.local/core/internal/canonicaljson"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxRevocationValidity is the maximum signed freshness interval accepted
|
||||
// from a Revocation List v1 document.
|
||||
MaxRevocationValidity = 31 * 24 * time.Hour
|
||||
// RevocationGrace is the bounded offline period after a cached list expires.
|
||||
RevocationGrace = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidRevocation = errors.New("revocation list is invalid")
|
||||
ErrRevocationSignatureMissing = errors.New("revocation list signature is missing")
|
||||
ErrRevocationSignatureInvalid = errors.New("revocation list signature is invalid")
|
||||
)
|
||||
|
||||
var revocationFields = map[string]struct{}{
|
||||
"schema_version": {},
|
||||
"generated_at": {},
|
||||
"expires_at": {},
|
||||
"revoked_license_ids": {},
|
||||
"signature": {},
|
||||
}
|
||||
|
||||
// RevocationList is a verified Revocation List v1 document. It deliberately
|
||||
// excludes its source document, signature and signing key.
|
||||
type RevocationList struct {
|
||||
GeneratedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
RevokedLicenseIDs []string
|
||||
}
|
||||
|
||||
// RevocationState is the non-sensitive authorization result of one cached
|
||||
// revocation list at a caller-supplied instant.
|
||||
type RevocationState string
|
||||
|
||||
const (
|
||||
RevocationStateCurrent RevocationState = "current"
|
||||
RevocationStateGrace RevocationState = "grace"
|
||||
RevocationStateUnavailable RevocationState = "unavailable"
|
||||
RevocationStateRevoked RevocationState = "revoked"
|
||||
)
|
||||
|
||||
// RevocationVerifier validates Revocation List v1 documents with one copied
|
||||
// Ed25519 public key. Callers may inject the same controlled authorization key
|
||||
// used for License v1, but no fallback key exists.
|
||||
type RevocationVerifier struct {
|
||||
publicKey ed25519.PublicKey
|
||||
}
|
||||
|
||||
// NewRevocationVerifier copies and validates a Revocation List v1 signing key.
|
||||
func NewRevocationVerifier(publicKey []byte) (RevocationVerifier, error) {
|
||||
if len(publicKey) != ed25519.PublicKeySize {
|
||||
return RevocationVerifier{}, ErrPublicKeyInvalid
|
||||
}
|
||||
return RevocationVerifier{publicKey: append(ed25519.PublicKey(nil), publicKey...)}, nil
|
||||
}
|
||||
|
||||
// Verify validates one Revocation List v1 document.
|
||||
func (verifier RevocationVerifier) Verify(document []byte) (RevocationList, error) {
|
||||
if len(verifier.publicKey) != ed25519.PublicKeySize {
|
||||
return RevocationList{}, ErrPublicKeyInvalid
|
||||
}
|
||||
|
||||
rootValue, err := canonicaljson.Parse(document)
|
||||
if err != nil {
|
||||
return RevocationList{}, revocationCanonicalError(err)
|
||||
}
|
||||
root, ok := rootValue.(map[string]any)
|
||||
if !ok {
|
||||
return RevocationList{}, ErrInvalidRevocation
|
||||
}
|
||||
signatureValue, exists := root["signature"]
|
||||
if !exists {
|
||||
return RevocationList{}, ErrRevocationSignatureMissing
|
||||
}
|
||||
signatureText, ok := signatureValue.(string)
|
||||
if !ok {
|
||||
return RevocationList{}, ErrRevocationSignatureInvalid
|
||||
}
|
||||
hasExactFields := hasExactRevocationShape(root)
|
||||
delete(root, "signature")
|
||||
signedPayload, err := canonicaljson.Marshal(root)
|
||||
if err != nil {
|
||||
return RevocationList{}, revocationCanonicalError(err)
|
||||
}
|
||||
signature, err := decodeRevocationSignature(signatureText)
|
||||
if err != nil || !ed25519.Verify(verifier.publicKey, signedPayload, signature) {
|
||||
return RevocationList{}, ErrRevocationSignatureInvalid
|
||||
}
|
||||
if !hasExactFields {
|
||||
return RevocationList{}, ErrInvalidRevocation
|
||||
}
|
||||
|
||||
wire, err := decodeRevocationList(document)
|
||||
if err != nil {
|
||||
return RevocationList{}, err
|
||||
}
|
||||
return validateRevocationList(wire)
|
||||
}
|
||||
|
||||
// StateFor reports whether one license ID is safe to use under this verified
|
||||
// cache. A future-dated list is unavailable rather than trusted.
|
||||
func (list RevocationList) StateFor(licenseID string, now time.Time) RevocationState {
|
||||
if now.Before(list.GeneratedAt) || !list.ExpiresAt.After(list.GeneratedAt) ||
|
||||
list.ExpiresAt.Sub(list.GeneratedAt) > MaxRevocationValidity {
|
||||
return RevocationStateUnavailable
|
||||
}
|
||||
for _, revokedID := range list.RevokedLicenseIDs {
|
||||
if revokedID == licenseID {
|
||||
return RevocationStateRevoked
|
||||
}
|
||||
}
|
||||
if !now.After(list.ExpiresAt) {
|
||||
return RevocationStateCurrent
|
||||
}
|
||||
if !now.After(list.ExpiresAt.Add(RevocationGrace)) {
|
||||
return RevocationStateGrace
|
||||
}
|
||||
return RevocationStateUnavailable
|
||||
}
|
||||
|
||||
func hasExactRevocationShape(root map[string]any) bool {
|
||||
if len(root) != len(revocationFields) {
|
||||
return false
|
||||
}
|
||||
for field := range revocationFields {
|
||||
if _, exists := root[field]; !exists {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if _, ok := root["schema_version"].(json.Number); !ok {
|
||||
return false
|
||||
}
|
||||
if _, ok := root["revoked_license_ids"].([]any); !ok {
|
||||
return false
|
||||
}
|
||||
for _, field := range []string{"generated_at", "expires_at", "signature"} {
|
||||
if _, ok := root[field].(string); !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type revocationWire struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
RevokedLicenseIDs []string `json:"revoked_license_ids"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
|
||||
func decodeRevocationList(document []byte) (revocationWire, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(document))
|
||||
decoder.DisallowUnknownFields()
|
||||
decoder.UseNumber()
|
||||
var wire revocationWire
|
||||
if err := decoder.Decode(&wire); err != nil {
|
||||
return revocationWire{}, ErrInvalidRevocation
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
return revocationWire{}, ErrInvalidRevocation
|
||||
}
|
||||
return wire, nil
|
||||
}
|
||||
|
||||
func validateRevocationList(wire revocationWire) (RevocationList, error) {
|
||||
if wire.SchemaVersion != 1 || wire.Signature == "" {
|
||||
return RevocationList{}, ErrInvalidRevocation
|
||||
}
|
||||
generatedAt, err := time.Parse(timestampLayout, wire.GeneratedAt)
|
||||
if err != nil || generatedAt.Format(timestampLayout) != wire.GeneratedAt {
|
||||
return RevocationList{}, ErrInvalidRevocation
|
||||
}
|
||||
expiresAt, err := time.Parse(timestampLayout, wire.ExpiresAt)
|
||||
if err != nil || expiresAt.Format(timestampLayout) != wire.ExpiresAt ||
|
||||
!expiresAt.After(generatedAt) || expiresAt.Sub(generatedAt) > MaxRevocationValidity {
|
||||
return RevocationList{}, ErrInvalidRevocation
|
||||
}
|
||||
ids := make([]string, len(wire.RevokedLicenseIDs))
|
||||
seen := make(map[string]struct{}, len(wire.RevokedLicenseIDs))
|
||||
for index, licenseID := range wire.RevokedLicenseIDs {
|
||||
if !licenseIDPattern.MatchString(licenseID) {
|
||||
return RevocationList{}, ErrInvalidRevocation
|
||||
}
|
||||
if _, exists := seen[licenseID]; exists {
|
||||
return RevocationList{}, ErrInvalidRevocation
|
||||
}
|
||||
seen[licenseID] = struct{}{}
|
||||
ids[index] = licenseID
|
||||
}
|
||||
return RevocationList{
|
||||
GeneratedAt: generatedAt,
|
||||
ExpiresAt: expiresAt,
|
||||
RevokedLicenseIDs: ids,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func decodeRevocationSignature(value string) ([]byte, error) {
|
||||
signature, err := base64.StdEncoding.Strict().DecodeString(value)
|
||||
if err != nil || base64.StdEncoding.EncodeToString(signature) != value ||
|
||||
len(signature) != ed25519.SignatureSize {
|
||||
return nil, ErrRevocationSignatureInvalid
|
||||
}
|
||||
return signature, nil
|
||||
}
|
||||
|
||||
func revocationCanonicalError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, canonicaljson.ErrDuplicateField):
|
||||
return ErrDuplicateField
|
||||
case errors.Is(err, canonicaljson.ErrUnsupportedNumber):
|
||||
return ErrUnsupportedNumber
|
||||
default:
|
||||
return ErrInvalidRevocation
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package licensing
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"softbox.local/core/internal/canonicaljson"
|
||||
)
|
||||
|
||||
func TestRevocationVerifierAndStateBoundaries(t *testing.T) {
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
verifier, err := NewRevocationVerifier(publicKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
generated := time.Date(2026, 7, 20, 0, 0, 0, 0, time.UTC)
|
||||
document := signedRevocationDocument(t, privateKey, generated, generated.Add(24*time.Hour), []string{"lic-revoked"})
|
||||
list, err := verifier.Verify(document)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify() error = %v", err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
licenseID string
|
||||
now time.Time
|
||||
want RevocationState
|
||||
}{
|
||||
{"current", "lic-active", generated.Add(time.Hour), RevocationStateCurrent},
|
||||
{"revoked", "lic-revoked", generated.Add(time.Hour), RevocationStateRevoked},
|
||||
{"at expiry", "lic-active", generated.Add(24 * time.Hour), RevocationStateCurrent},
|
||||
{"at grace end", "lic-active", generated.Add(24*time.Hour + RevocationGrace), RevocationStateGrace},
|
||||
{"after grace", "lic-active", generated.Add(24*time.Hour + RevocationGrace + time.Second), RevocationStateUnavailable},
|
||||
{"before generated", "lic-active", generated.Add(-time.Second), RevocationStateUnavailable},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := list.StateFor(test.licenseID, test.now); got != test.want {
|
||||
t.Fatalf("StateFor() = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
list.RevokedLicenseIDs[0] = "lic-mutated"
|
||||
if again, err := verifier.Verify(document); err != nil || again.RevokedLicenseIDs[0] != "lic-revoked" {
|
||||
t.Fatalf("Verify() after output mutation = %#v, %v", again, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevocationVerifierFailsClosed(t *testing.T) {
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
verifier, err := NewRevocationVerifier(publicKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
generated := time.Date(2026, 7, 20, 0, 0, 0, 0, time.UTC)
|
||||
valid := signedRevocationDocument(t, privateKey, generated, generated.Add(time.Hour), nil)
|
||||
tampered := append([]byte(nil), valid...)
|
||||
tampered[5] = 'x'
|
||||
tooLong := signedRevocationDocument(t, privateKey, generated, generated.Add(MaxRevocationValidity+time.Second), nil)
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
document []byte
|
||||
want error
|
||||
}{
|
||||
{"empty", nil, ErrInvalidRevocation},
|
||||
{"tampered", tampered, ErrRevocationSignatureInvalid},
|
||||
{"interval too long", tooLong, ErrInvalidRevocation},
|
||||
{"duplicate", []byte(`{"schema_version":1,"schema_version":1}`), ErrDuplicateField},
|
||||
{"missing signature", []byte(`{"schema_version":1,"generated_at":"2026-07-20T00:00:00Z","expires_at":"2026-07-20T01:00:00Z","revoked_license_ids":[]}`), ErrRevocationSignatureMissing},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if _, err := verifier.Verify(test.document); !errors.Is(err, test.want) {
|
||||
t.Fatalf("Verify() error = %v, want %v", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := NewRevocationVerifier(make([]byte, ed25519.PublicKeySize-1)); !errors.Is(err, ErrPublicKeyInvalid) {
|
||||
t.Fatalf("NewRevocationVerifier(short) error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func signedRevocationDocument(
|
||||
t *testing.T,
|
||||
privateKey ed25519.PrivateKey,
|
||||
generated time.Time,
|
||||
expires time.Time,
|
||||
revoked []string,
|
||||
) []byte {
|
||||
t.Helper()
|
||||
payload := map[string]any{
|
||||
"schema_version": json.Number("1"),
|
||||
"generated_at": generated.Format(timestampLayout),
|
||||
"expires_at": expires.Format(timestampLayout),
|
||||
"revoked_license_ids": stringSliceAsAny(revoked),
|
||||
}
|
||||
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 stringSliceAsAny(values []string) []any {
|
||||
items := make([]any, len(values))
|
||||
for index, value := range values {
|
||||
items[index] = value
|
||||
}
|
||||
return items
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -306,6 +306,7 @@ func validInstalledApp() InstalledApp {
|
||||
Version: "1.2.0",
|
||||
Architecture: "amd64",
|
||||
Channel: "stable",
|
||||
ProductID: "product-json-parser",
|
||||
Files: []InstalledFile{
|
||||
{
|
||||
Path: "JsonParser.exe",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user