Implement authorization import and revocation checks (T-503)
This commit is contained in:
@@ -35,10 +35,28 @@ func main() {
|
||||
}
|
||||
|
||||
func run() error {
|
||||
return runWithCatalogLoader(application.UnconfiguredCatalogLoader{})
|
||||
return runWithLoaders(
|
||||
application.UnconfiguredCatalogLoader{},
|
||||
application.UnconfiguredAuthorizationLoader{},
|
||||
)
|
||||
}
|
||||
|
||||
func runWithCatalogLoader(loader application.CatalogSnapshotLoader) error {
|
||||
return runWithLoaders(loader, application.UnconfiguredAuthorizationLoader{})
|
||||
}
|
||||
|
||||
func runWithLoaders(
|
||||
loader application.CatalogSnapshotLoader,
|
||||
authorizationLoader application.AuthorizationSnapshotLoader,
|
||||
) error {
|
||||
return runWithCompositions(loader, authorizationLoader, nil)
|
||||
}
|
||||
|
||||
func runWithCompositions(
|
||||
loader application.CatalogSnapshotLoader,
|
||||
authorizationLoader application.AuthorizationSnapshotLoader,
|
||||
licenseImport *application.LicenseImport,
|
||||
) error {
|
||||
platform := windows.New()
|
||||
window := new(app.Window)
|
||||
window.Option(
|
||||
@@ -64,6 +82,7 @@ func runWithCatalogLoader(loader application.CatalogSnapshotLoader) error {
|
||||
)
|
||||
}()
|
||||
catalogDone := startCatalogBootstrap(eventContext, runtime, loader)
|
||||
authorizationDone := startAuthorizationBootstrap(eventContext, runtime, authorizationLoader)
|
||||
defer func() {
|
||||
cancelEvents()
|
||||
runtime.Close()
|
||||
@@ -75,6 +94,13 @@ func runWithCatalogLoader(loader application.CatalogSnapshotLoader) error {
|
||||
!errors.Is(bootstrapErr, application.ErrEventRelayClosed) {
|
||||
log.Printf("%s catalog bootstrap failed", core.ProductName)
|
||||
}
|
||||
if authorizationErr := <-authorizationDone; authorizationErr != nil &&
|
||||
!errors.Is(authorizationErr, context.Canceled) &&
|
||||
!errors.Is(authorizationErr, application.ErrAuthorizationSourceUnconfigured) &&
|
||||
!errors.Is(authorizationErr, application.ErrRuntimeClosed) &&
|
||||
!errors.Is(authorizationErr, application.ErrEventRelayClosed) {
|
||||
log.Printf("%s authorization bootstrap failed", core.ProductName)
|
||||
}
|
||||
if pumpErr := <-pumpDone; pumpErr != nil &&
|
||||
!errors.Is(pumpErr, context.Canceled) &&
|
||||
!errors.Is(pumpErr, application.ErrEventRelayClosed) {
|
||||
@@ -82,6 +108,7 @@ func runWithCatalogLoader(loader application.CatalogSnapshotLoader) error {
|
||||
}
|
||||
}()
|
||||
var operations op.Ops
|
||||
importing := make(chan struct{}, 1)
|
||||
|
||||
for {
|
||||
switch event := window.Event().(type) {
|
||||
@@ -91,9 +118,24 @@ func runWithCatalogLoader(loader application.CatalogSnapshotLoader) error {
|
||||
if err := relay.Drain(shell.ApplyEvent); err != nil {
|
||||
log.Printf("apply application event: %v", err)
|
||||
}
|
||||
context := app.NewContext(&operations, event)
|
||||
shell.Layout(context, theme)
|
||||
event.Frame(context.Ops)
|
||||
gtx := app.NewContext(&operations, event)
|
||||
shell.Layout(gtx, theme)
|
||||
if shell.TakeLicenseImportRequest() && licenseImport != nil {
|
||||
select {
|
||||
case importing <- struct{}{}:
|
||||
go func() {
|
||||
defer func() { <-importing }()
|
||||
if importErr := licenseImport.Run(eventContext); importErr != nil &&
|
||||
!errors.Is(importErr, context.Canceled) &&
|
||||
!errors.Is(importErr, application.ErrRuntimeClosed) &&
|
||||
!errors.Is(importErr, application.ErrEventRelayClosed) {
|
||||
log.Printf("%s license import failed", core.ProductName)
|
||||
}
|
||||
}()
|
||||
default:
|
||||
}
|
||||
}
|
||||
event.Frame(gtx.Ops)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,3 +151,15 @@ func startCatalogBootstrap(
|
||||
}()
|
||||
return done
|
||||
}
|
||||
|
||||
func startAuthorizationBootstrap(
|
||||
ctx context.Context,
|
||||
runtime *application.Runtime,
|
||||
loader application.AuthorizationSnapshotLoader,
|
||||
) <-chan error {
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- application.NewAuthorizationBootstrap(loader, runtime).Run(ctx)
|
||||
}()
|
||||
return done
|
||||
}
|
||||
|
||||
@@ -59,6 +59,9 @@ func (shell *AppShell) ApplyEvent(event application.Event) error {
|
||||
if handled, err := shell.applyCatalogEvent(event); err != nil || handled {
|
||||
return err
|
||||
}
|
||||
if handled, err := shell.applyAuthorizationEvent(event); err != nil || handled {
|
||||
return err
|
||||
}
|
||||
iconEvent, handled, err := application.ParseIconEvent(event)
|
||||
if err != nil || !handled {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package gio
|
||||
|
||||
import "softbox.local/core/application"
|
||||
|
||||
func (shell *AppShell) applyAuthorizationEvent(event application.Event) (bool, error) {
|
||||
payload, handled, err := application.ParseAuthorizationEvent(event)
|
||||
if err != nil || !handled {
|
||||
return handled, err
|
||||
}
|
||||
shell.authorization = payload.Snapshot
|
||||
shell.authorizationLoaded = true
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package gio
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"softbox.local/core/application"
|
||||
)
|
||||
|
||||
func TestAuthorizationViewRendersSanitizedStateAndQueuesImport(t *testing.T) {
|
||||
shell := NewAppShell(adapterContractEdition, adapterContractItems()...)
|
||||
if err := shell.ApplyEvent(application.NewAuthorizationEvent(application.AuthorizationSnapshot{
|
||||
State: application.AuthorizationStateGrace,
|
||||
MachineHash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
Products: []application.AuthorizedProduct{{
|
||||
ProductID: "product-test", Kind: application.LicenseKindNonPerpetual, RebindPolicy: "support-only",
|
||||
}},
|
||||
})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shell.viewLicense.Click()
|
||||
nodes := adapterContractLayout(shell, adapterContractViewport)
|
||||
if !shell.showLicense || !adapterContractHasSemantic(nodes, "授权") ||
|
||||
!adapterContractHasSemantic(nodes, "导入许可证") ||
|
||||
!adapterContractHasSemantic(nodes, "product-test · 非永久许可证(License v1 未提供试用到期信息)") {
|
||||
t.Fatal("authorization state did not render its accessible content")
|
||||
}
|
||||
shell.importLicense.Click()
|
||||
adapterContractLayout(shell, adapterContractViewport)
|
||||
if !shell.TakeLicenseImportRequest() || shell.TakeLicenseImportRequest() {
|
||||
t.Fatal("import click did not produce exactly one UI-only request")
|
||||
}
|
||||
|
||||
if err := shell.ApplyEvent(application.NewAuthorizationEvent(application.AuthorizationSnapshot{
|
||||
State: application.AuthorizationStateUnconfigured, Products: []application.AuthorizedProduct{},
|
||||
})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shell.importLicense.Click()
|
||||
adapterContractLayout(shell, adapterContractViewport)
|
||||
if shell.TakeLicenseImportRequest() {
|
||||
t.Fatal("unconfigured authorization accepted an import request")
|
||||
}
|
||||
}
|
||||
+39
-10
@@ -24,19 +24,25 @@ type AppShell struct {
|
||||
viewAll widget.Clickable
|
||||
viewInstalled widget.Clickable
|
||||
viewUpdates widget.Clickable
|
||||
viewLicense widget.Clickable
|
||||
resetFilters widget.Clickable
|
||||
closeDetail widget.Clickable
|
||||
importLicense widget.Clickable
|
||||
|
||||
categoryControls map[string]*widget.Clickable
|
||||
rows map[string]*rowControls
|
||||
icons map[string]paint.ImageOp
|
||||
iconReferences map[string]string
|
||||
iconRequests map[string]application.IconEventIdentity
|
||||
iconApplied map[string]application.IconEventIdentity
|
||||
iconFailures map[string]iconFailureState
|
||||
catalogState catalogPresentationState
|
||||
lastRendered int
|
||||
detailRendered bool
|
||||
categoryControls map[string]*widget.Clickable
|
||||
rows map[string]*rowControls
|
||||
icons map[string]paint.ImageOp
|
||||
iconReferences map[string]string
|
||||
iconRequests map[string]application.IconEventIdentity
|
||||
iconApplied map[string]application.IconEventIdentity
|
||||
iconFailures map[string]iconFailureState
|
||||
catalogState catalogPresentationState
|
||||
authorization application.AuthorizationSnapshot
|
||||
authorizationLoaded bool
|
||||
showLicense bool
|
||||
licenseImportRequested bool
|
||||
lastRendered int
|
||||
detailRendered bool
|
||||
}
|
||||
|
||||
// NewAppShell creates the catalog shell with an optional in-memory snapshot.
|
||||
@@ -56,6 +62,7 @@ func NewAppShell(
|
||||
iconRequests: make(map[string]application.IconEventIdentity),
|
||||
iconApplied: make(map[string]application.IconEventIdentity),
|
||||
iconFailures: make(map[string]iconFailureState),
|
||||
authorization: application.AuthorizationSnapshot{Products: []application.AuthorizedProduct{}},
|
||||
}
|
||||
shell.search.SingleLine = true
|
||||
shell.SetItems(items)
|
||||
@@ -164,14 +171,26 @@ func (shell *AppShell) drainInput(gtx layout.Context) {
|
||||
shell.model.SetQuery(shell.search.Text())
|
||||
|
||||
for shell.viewAll.Clicked(gtx) {
|
||||
shell.showLicense = false
|
||||
shell.model.SetView(application.CatalogViewAll)
|
||||
}
|
||||
for shell.viewInstalled.Clicked(gtx) {
|
||||
shell.showLicense = false
|
||||
shell.model.SetView(application.CatalogViewInstalled)
|
||||
}
|
||||
for shell.viewUpdates.Clicked(gtx) {
|
||||
shell.showLicense = false
|
||||
shell.model.SetView(application.CatalogViewUpdates)
|
||||
}
|
||||
for shell.viewLicense.Clicked(gtx) {
|
||||
shell.showLicense = true
|
||||
shell.model.Select("")
|
||||
}
|
||||
for shell.importLicense.Clicked(gtx) {
|
||||
if shell.canImportLicense() {
|
||||
shell.licenseImportRequested = true
|
||||
}
|
||||
}
|
||||
for category, control := range shell.categoryControls {
|
||||
for control.Clicked(gtx) {
|
||||
shell.model.SetCategory(category)
|
||||
@@ -190,3 +209,13 @@ func (shell *AppShell) drainInput(gtx layout.Context) {
|
||||
shell.model.Select("")
|
||||
}
|
||||
}
|
||||
|
||||
// TakeLicenseImportRequest consumes one UI-only import request. Composition
|
||||
// must perform any picker, file I/O and verification in a background task.
|
||||
func (shell *AppShell) TakeLicenseImportRequest() bool {
|
||||
if shell == nil || !shell.licenseImportRequested {
|
||||
return false
|
||||
}
|
||||
shell.licenseImportRequested = false
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -72,12 +72,34 @@ func (shell *AppShell) layoutContent(
|
||||
application.CatalogViewUpdates,
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
||||
return shell.layoutFilterButton(
|
||||
gtx,
|
||||
theme,
|
||||
&shell.viewLicense,
|
||||
"授权",
|
||||
shell.showLicense,
|
||||
)
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(16)}.Layout),
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
if shell.showLicense {
|
||||
return panel(
|
||||
gtx,
|
||||
shellColors.surface,
|
||||
unit.Dp(10),
|
||||
layout.UniformInset(unit.Dp(16)),
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutLicensePanel(gtx, theme)
|
||||
},
|
||||
)
|
||||
}
|
||||
selected, hasSelection := shell.model.SelectedItem()
|
||||
return layout.Flex{}.Layout(
|
||||
gtx,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package gio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gioui.org/io/semantic"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget/material"
|
||||
|
||||
"softbox.local/core/application"
|
||||
)
|
||||
|
||||
func (shell *AppShell) layoutLicensePanel(gtx layout.Context, theme *material.Theme) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(material.H5(theme, "授权").Layout),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return licenseText(gtx, theme, shell.licenseStatusText())
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if shell.authorization.MachineHash == "" {
|
||||
return licenseText(gtx, theme, "机器信息:授权配置完成后显示 machine_hash")
|
||||
}
|
||||
return licenseText(gtx, theme, "机器信息(machine_hash):"+shell.authorization.MachineHash)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !shell.canImportLicense() {
|
||||
return licenseText(gtx, theme, "许可证导入:当前构建未配置可信授权来源")
|
||||
}
|
||||
return shell.layoutLicenseImportButton(gtx, theme)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout),
|
||||
layout.Rigid(material.H6(theme, "已授权产品").Layout),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
if len(shell.authorization.Products) == 0 {
|
||||
return licenseText(gtx, theme, "暂无可用授权。试用功能需要服务端签发可验证的许可证,本机不会创建试用授权。")
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, licenseProductWidgets(shell.authorization.Products, theme)...)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutLicenseImportButton(gtx layout.Context, theme *material.Theme) layout.Dimensions {
|
||||
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(44))
|
||||
button := material.Button(theme, &shell.importLicense, "导入许可证")
|
||||
button.Background = shellColors.primary
|
||||
button.Color = shellColors.onPrimary
|
||||
button.Inset = layout.Inset{Top: unit.Dp(10), Bottom: unit.Dp(10), Left: unit.Dp(14), Right: unit.Dp(14)}
|
||||
semantic.Button.Add(gtx.Ops)
|
||||
semantic.DescriptionOp("导入许可证文件,验证在后台完成").Add(gtx.Ops)
|
||||
return button.Layout(gtx)
|
||||
}
|
||||
|
||||
func licenseProductWidgets(products []application.AuthorizedProduct, theme *material.Theme) []layout.FlexChild {
|
||||
widgets := make([]layout.FlexChild, 0, len(products)*3)
|
||||
for _, product := range products {
|
||||
product := product
|
||||
widgets = append(widgets,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return licenseText(gtx, theme, fmt.Sprintf("%s · %s", product.ProductID, licenseKindText(product.Kind)))
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return licenseText(gtx, theme, "换绑/申诉:"+product.RebindPolicy+";不会在本机修改绑定")
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
||||
)
|
||||
}
|
||||
return widgets
|
||||
}
|
||||
|
||||
func (shell *AppShell) canImportLicense() bool {
|
||||
return shell.authorizationLoaded && shell.authorization.State != application.AuthorizationStateUnconfigured
|
||||
}
|
||||
|
||||
func (shell *AppShell) licenseStatusText() string {
|
||||
if !shell.authorizationLoaded {
|
||||
return "授权状态:正在加载"
|
||||
}
|
||||
switch shell.authorization.State {
|
||||
case application.AuthorizationStateReady:
|
||||
return "授权状态:撤销名单有效,当前授权可用"
|
||||
case application.AuthorizationStateGrace:
|
||||
return "授权状态:撤销名单已过期,处于 7 天离线宽限期"
|
||||
case application.AuthorizationStateNoLicense:
|
||||
return "授权状态:未找到当前机器的有效许可证"
|
||||
case application.AuthorizationStateRevoked:
|
||||
return "授权状态:许可证已撤销,不能启动受保护软件"
|
||||
case application.AuthorizationStateUnavailable:
|
||||
return "授权状态:撤销验证不可用,不能启动受保护软件"
|
||||
case application.AuthorizationStateImportFailed:
|
||||
return "授权状态:导入失败;原许可证与授权状态未被信任地替换"
|
||||
case application.AuthorizationStateUnconfigured:
|
||||
return "授权状态:可信授权来源尚未配置"
|
||||
default:
|
||||
return "授权状态:不可用"
|
||||
}
|
||||
}
|
||||
|
||||
func licenseKindText(kind application.LicenseKind) string {
|
||||
if kind == application.LicenseKindPerpetual {
|
||||
return "正式许可证"
|
||||
}
|
||||
return "非永久许可证(License v1 未提供试用到期信息)"
|
||||
}
|
||||
|
||||
func licenseText(gtx layout.Context, theme *material.Theme, value string) layout.Dimensions {
|
||||
label := material.Body2(theme, value)
|
||||
label.Color = shellColors.secondary
|
||||
return label.Layout(gtx)
|
||||
}
|
||||
@@ -35,10 +35,28 @@ func main() {
|
||||
}
|
||||
|
||||
func run() error {
|
||||
return runWithCatalogLoader(application.UnconfiguredCatalogLoader{})
|
||||
return runWithLoaders(
|
||||
application.UnconfiguredCatalogLoader{},
|
||||
application.UnconfiguredAuthorizationLoader{},
|
||||
)
|
||||
}
|
||||
|
||||
func runWithCatalogLoader(loader application.CatalogSnapshotLoader) error {
|
||||
return runWithLoaders(loader, application.UnconfiguredAuthorizationLoader{})
|
||||
}
|
||||
|
||||
func runWithLoaders(
|
||||
loader application.CatalogSnapshotLoader,
|
||||
authorizationLoader application.AuthorizationSnapshotLoader,
|
||||
) error {
|
||||
return runWithCompositions(loader, authorizationLoader, nil)
|
||||
}
|
||||
|
||||
func runWithCompositions(
|
||||
loader application.CatalogSnapshotLoader,
|
||||
authorizationLoader application.AuthorizationSnapshotLoader,
|
||||
licenseImport *application.LicenseImport,
|
||||
) error {
|
||||
platform := windows.New()
|
||||
window := new(app.Window)
|
||||
window.Option(
|
||||
@@ -64,6 +82,7 @@ func runWithCatalogLoader(loader application.CatalogSnapshotLoader) error {
|
||||
)
|
||||
}()
|
||||
catalogDone := startCatalogBootstrap(eventContext, runtime, loader)
|
||||
authorizationDone := startAuthorizationBootstrap(eventContext, runtime, authorizationLoader)
|
||||
defer func() {
|
||||
cancelEvents()
|
||||
runtime.Close()
|
||||
@@ -75,6 +94,13 @@ func runWithCatalogLoader(loader application.CatalogSnapshotLoader) error {
|
||||
!errors.Is(bootstrapErr, application.ErrEventRelayClosed) {
|
||||
log.Printf("%s catalog bootstrap failed", core.ProductName)
|
||||
}
|
||||
if authorizationErr := <-authorizationDone; authorizationErr != nil &&
|
||||
!errors.Is(authorizationErr, context.Canceled) &&
|
||||
!errors.Is(authorizationErr, application.ErrAuthorizationSourceUnconfigured) &&
|
||||
!errors.Is(authorizationErr, application.ErrRuntimeClosed) &&
|
||||
!errors.Is(authorizationErr, application.ErrEventRelayClosed) {
|
||||
log.Printf("%s authorization bootstrap failed", core.ProductName)
|
||||
}
|
||||
if pumpErr := <-pumpDone; pumpErr != nil &&
|
||||
!errors.Is(pumpErr, context.Canceled) &&
|
||||
!errors.Is(pumpErr, application.ErrEventRelayClosed) {
|
||||
@@ -82,6 +108,7 @@ func runWithCatalogLoader(loader application.CatalogSnapshotLoader) error {
|
||||
}
|
||||
}()
|
||||
var operations op.Ops
|
||||
importing := make(chan struct{}, 1)
|
||||
|
||||
for {
|
||||
switch event := window.Event().(type) {
|
||||
@@ -91,9 +118,24 @@ func runWithCatalogLoader(loader application.CatalogSnapshotLoader) error {
|
||||
if err := relay.Drain(shell.ApplyEvent); err != nil {
|
||||
log.Printf("apply application event: %v", err)
|
||||
}
|
||||
context := app.NewContext(&operations, event)
|
||||
shell.Layout(context, theme)
|
||||
event.Frame(context.Ops)
|
||||
gtx := app.NewContext(&operations, event)
|
||||
shell.Layout(gtx, theme)
|
||||
if shell.TakeLicenseImportRequest() && licenseImport != nil {
|
||||
select {
|
||||
case importing <- struct{}{}:
|
||||
go func() {
|
||||
defer func() { <-importing }()
|
||||
if importErr := licenseImport.Run(eventContext); importErr != nil &&
|
||||
!errors.Is(importErr, context.Canceled) &&
|
||||
!errors.Is(importErr, application.ErrRuntimeClosed) &&
|
||||
!errors.Is(importErr, application.ErrEventRelayClosed) {
|
||||
log.Printf("%s license import failed", core.ProductName)
|
||||
}
|
||||
}()
|
||||
default:
|
||||
}
|
||||
}
|
||||
event.Frame(gtx.Ops)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,3 +151,15 @@ func startCatalogBootstrap(
|
||||
}()
|
||||
return done
|
||||
}
|
||||
|
||||
func startAuthorizationBootstrap(
|
||||
ctx context.Context,
|
||||
runtime *application.Runtime,
|
||||
loader application.AuthorizationSnapshotLoader,
|
||||
) <-chan error {
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- application.NewAuthorizationBootstrap(loader, runtime).Run(ctx)
|
||||
}()
|
||||
return done
|
||||
}
|
||||
|
||||
@@ -59,6 +59,9 @@ func (shell *AppShell) ApplyEvent(event application.Event) error {
|
||||
if handled, err := shell.applyCatalogEvent(event); err != nil || handled {
|
||||
return err
|
||||
}
|
||||
if handled, err := shell.applyAuthorizationEvent(event); err != nil || handled {
|
||||
return err
|
||||
}
|
||||
iconEvent, handled, err := application.ParseIconEvent(event)
|
||||
if err != nil || !handled {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package gio
|
||||
|
||||
import "softbox.local/core/application"
|
||||
|
||||
func (shell *AppShell) applyAuthorizationEvent(event application.Event) (bool, error) {
|
||||
payload, handled, err := application.ParseAuthorizationEvent(event)
|
||||
if err != nil || !handled {
|
||||
return handled, err
|
||||
}
|
||||
shell.authorization = payload.Snapshot
|
||||
shell.authorizationLoaded = true
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package gio
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"softbox.local/core/application"
|
||||
)
|
||||
|
||||
func TestAuthorizationViewRendersSanitizedStateAndQueuesImport(t *testing.T) {
|
||||
shell := NewAppShell(adapterContractEdition, adapterContractItems()...)
|
||||
if err := shell.ApplyEvent(application.NewAuthorizationEvent(application.AuthorizationSnapshot{
|
||||
State: application.AuthorizationStateGrace,
|
||||
MachineHash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
Products: []application.AuthorizedProduct{{
|
||||
ProductID: "product-test", Kind: application.LicenseKindNonPerpetual, RebindPolicy: "support-only",
|
||||
}},
|
||||
})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shell.viewLicense.Click()
|
||||
nodes := adapterContractLayout(shell, adapterContractViewport)
|
||||
if !shell.showLicense || !adapterContractHasSemantic(nodes, "授权") ||
|
||||
!adapterContractHasSemantic(nodes, "导入许可证") ||
|
||||
!adapterContractHasSemantic(nodes, "product-test · 非永久许可证(License v1 未提供试用到期信息)") {
|
||||
t.Fatal("authorization state did not render its accessible content")
|
||||
}
|
||||
shell.importLicense.Click()
|
||||
adapterContractLayout(shell, adapterContractViewport)
|
||||
if !shell.TakeLicenseImportRequest() || shell.TakeLicenseImportRequest() {
|
||||
t.Fatal("import click did not produce exactly one UI-only request")
|
||||
}
|
||||
|
||||
if err := shell.ApplyEvent(application.NewAuthorizationEvent(application.AuthorizationSnapshot{
|
||||
State: application.AuthorizationStateUnconfigured, Products: []application.AuthorizedProduct{},
|
||||
})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shell.importLicense.Click()
|
||||
adapterContractLayout(shell, adapterContractViewport)
|
||||
if shell.TakeLicenseImportRequest() {
|
||||
t.Fatal("unconfigured authorization accepted an import request")
|
||||
}
|
||||
}
|
||||
+39
-10
@@ -24,19 +24,25 @@ type AppShell struct {
|
||||
viewAll widget.Clickable
|
||||
viewInstalled widget.Clickable
|
||||
viewUpdates widget.Clickable
|
||||
viewLicense widget.Clickable
|
||||
resetFilters widget.Clickable
|
||||
closeDetail widget.Clickable
|
||||
importLicense widget.Clickable
|
||||
|
||||
categoryControls map[string]*widget.Clickable
|
||||
rows map[string]*rowControls
|
||||
icons map[string]paint.ImageOp
|
||||
iconReferences map[string]string
|
||||
iconRequests map[string]application.IconEventIdentity
|
||||
iconApplied map[string]application.IconEventIdentity
|
||||
iconFailures map[string]iconFailureState
|
||||
catalogState catalogPresentationState
|
||||
lastRendered int
|
||||
detailRendered bool
|
||||
categoryControls map[string]*widget.Clickable
|
||||
rows map[string]*rowControls
|
||||
icons map[string]paint.ImageOp
|
||||
iconReferences map[string]string
|
||||
iconRequests map[string]application.IconEventIdentity
|
||||
iconApplied map[string]application.IconEventIdentity
|
||||
iconFailures map[string]iconFailureState
|
||||
catalogState catalogPresentationState
|
||||
authorization application.AuthorizationSnapshot
|
||||
authorizationLoaded bool
|
||||
showLicense bool
|
||||
licenseImportRequested bool
|
||||
lastRendered int
|
||||
detailRendered bool
|
||||
}
|
||||
|
||||
// NewAppShell creates the catalog shell with an optional in-memory snapshot.
|
||||
@@ -56,6 +62,7 @@ func NewAppShell(
|
||||
iconRequests: make(map[string]application.IconEventIdentity),
|
||||
iconApplied: make(map[string]application.IconEventIdentity),
|
||||
iconFailures: make(map[string]iconFailureState),
|
||||
authorization: application.AuthorizationSnapshot{Products: []application.AuthorizedProduct{}},
|
||||
}
|
||||
shell.search.SingleLine = true
|
||||
shell.SetItems(items)
|
||||
@@ -168,14 +175,26 @@ func (shell *AppShell) drainInput(gtx layout.Context) {
|
||||
}
|
||||
shell.model.SetQuery(shell.search.Text())
|
||||
for shell.viewAll.Clicked(gtx) {
|
||||
shell.showLicense = false
|
||||
shell.model.SetView(application.CatalogViewAll)
|
||||
}
|
||||
for shell.viewInstalled.Clicked(gtx) {
|
||||
shell.showLicense = false
|
||||
shell.model.SetView(application.CatalogViewInstalled)
|
||||
}
|
||||
for shell.viewUpdates.Clicked(gtx) {
|
||||
shell.showLicense = false
|
||||
shell.model.SetView(application.CatalogViewUpdates)
|
||||
}
|
||||
for shell.viewLicense.Clicked(gtx) {
|
||||
shell.showLicense = true
|
||||
shell.model.Select("")
|
||||
}
|
||||
for shell.importLicense.Clicked(gtx) {
|
||||
if shell.canImportLicense() {
|
||||
shell.licenseImportRequested = true
|
||||
}
|
||||
}
|
||||
for category, control := range shell.categoryControls {
|
||||
for control.Clicked(gtx) {
|
||||
shell.model.SetCategory(category)
|
||||
@@ -194,3 +213,13 @@ func (shell *AppShell) drainInput(gtx layout.Context) {
|
||||
shell.model.Select("")
|
||||
}
|
||||
}
|
||||
|
||||
// TakeLicenseImportRequest consumes one UI-only import request. Composition
|
||||
// must perform any picker, file I/O and verification in a background task.
|
||||
func (shell *AppShell) TakeLicenseImportRequest() bool {
|
||||
if shell == nil || !shell.licenseImportRequested {
|
||||
return false
|
||||
}
|
||||
shell.licenseImportRequested = false
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -65,12 +65,34 @@ func (shell *AppShell) layoutContent(
|
||||
application.CatalogViewUpdates,
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
||||
return shell.layoutFilterButton(
|
||||
gtx,
|
||||
theme,
|
||||
&shell.viewLicense,
|
||||
"授权",
|
||||
shell.showLicense,
|
||||
)
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
if shell.showLicense {
|
||||
return panel(
|
||||
gtx,
|
||||
shellColors.surface,
|
||||
unit.Dp(10),
|
||||
layout.UniformInset(unit.Dp(16)),
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutLicensePanel(gtx, theme)
|
||||
},
|
||||
)
|
||||
}
|
||||
selected, hasSelection := shell.model.SelectedItem()
|
||||
return layout.Flex{}.Layout(
|
||||
gtx,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package gio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gioui.org/io/semantic"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget/material"
|
||||
|
||||
"softbox.local/core/application"
|
||||
)
|
||||
|
||||
func (shell *AppShell) layoutLicensePanel(gtx layout.Context, theme *material.Theme) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(material.H5(theme, "授权").Layout),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return licenseText(gtx, theme, shell.licenseStatusText())
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if shell.authorization.MachineHash == "" {
|
||||
return licenseText(gtx, theme, "机器信息:授权配置完成后显示 machine_hash")
|
||||
}
|
||||
return licenseText(gtx, theme, "机器信息(machine_hash):"+shell.authorization.MachineHash)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !shell.canImportLicense() {
|
||||
return licenseText(gtx, theme, "许可证导入:当前构建未配置可信授权来源")
|
||||
}
|
||||
return shell.layoutLicenseImportButton(gtx, theme)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout),
|
||||
layout.Rigid(material.H6(theme, "已授权产品").Layout),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
if len(shell.authorization.Products) == 0 {
|
||||
return licenseText(gtx, theme, "暂无可用授权。试用功能需要服务端签发可验证的许可证,本机不会创建试用授权。")
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, licenseProductWidgets(shell.authorization.Products, theme)...)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutLicenseImportButton(gtx layout.Context, theme *material.Theme) layout.Dimensions {
|
||||
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(44))
|
||||
button := material.Button(theme, &shell.importLicense, "导入许可证")
|
||||
button.Background = shellColors.primary
|
||||
button.Color = shellColors.onPrimary
|
||||
button.Inset = layout.Inset{Top: unit.Dp(10), Bottom: unit.Dp(10), Left: unit.Dp(14), Right: unit.Dp(14)}
|
||||
semantic.Button.Add(gtx.Ops)
|
||||
semantic.DescriptionOp("导入许可证文件,验证在后台完成").Add(gtx.Ops)
|
||||
return button.Layout(gtx)
|
||||
}
|
||||
|
||||
func licenseProductWidgets(products []application.AuthorizedProduct, theme *material.Theme) []layout.FlexChild {
|
||||
widgets := make([]layout.FlexChild, 0, len(products)*3)
|
||||
for _, product := range products {
|
||||
product := product
|
||||
widgets = append(widgets,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return licenseText(gtx, theme, fmt.Sprintf("%s · %s", product.ProductID, licenseKindText(product.Kind)))
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return licenseText(gtx, theme, "换绑/申诉:"+product.RebindPolicy+";不会在本机修改绑定")
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
||||
)
|
||||
}
|
||||
return widgets
|
||||
}
|
||||
|
||||
func (shell *AppShell) canImportLicense() bool {
|
||||
return shell.authorizationLoaded && shell.authorization.State != application.AuthorizationStateUnconfigured
|
||||
}
|
||||
|
||||
func (shell *AppShell) licenseStatusText() string {
|
||||
if !shell.authorizationLoaded {
|
||||
return "授权状态:正在加载"
|
||||
}
|
||||
switch shell.authorization.State {
|
||||
case application.AuthorizationStateReady:
|
||||
return "授权状态:撤销名单有效,当前授权可用"
|
||||
case application.AuthorizationStateGrace:
|
||||
return "授权状态:撤销名单已过期,处于 7 天离线宽限期"
|
||||
case application.AuthorizationStateNoLicense:
|
||||
return "授权状态:未找到当前机器的有效许可证"
|
||||
case application.AuthorizationStateRevoked:
|
||||
return "授权状态:许可证已撤销,不能启动受保护软件"
|
||||
case application.AuthorizationStateUnavailable:
|
||||
return "授权状态:撤销验证不可用,不能启动受保护软件"
|
||||
case application.AuthorizationStateImportFailed:
|
||||
return "授权状态:导入失败;原许可证与授权状态未被信任地替换"
|
||||
case application.AuthorizationStateUnconfigured:
|
||||
return "授权状态:可信授权来源尚未配置"
|
||||
default:
|
||||
return "授权状态:不可用"
|
||||
}
|
||||
}
|
||||
|
||||
func licenseKindText(kind application.LicenseKind) string {
|
||||
if kind == application.LicenseKindPerpetual {
|
||||
return "正式许可证"
|
||||
}
|
||||
return "非永久许可证(License v1 未提供试用到期信息)"
|
||||
}
|
||||
|
||||
func licenseText(gtx layout.Context, theme *material.Theme, value string) layout.Dimensions {
|
||||
label := material.Body2(theme, value)
|
||||
label.Color = shellColors.secondary
|
||||
return label.Layout(gtx)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -127,7 +127,7 @@ soft_quay/
|
||||
├─ app/ # 盒子自身程序
|
||||
├─ apps/<id>/ # current/ staging/ backup/ installed-app.json
|
||||
├─ data/<id>/ # 子软件用户数据(更新永不覆盖)
|
||||
├─ licenses/ # 许可证(更新永不覆盖)
|
||||
├─ licenses/ # 许可证(更新永不覆盖):v1/<sha256>.license 与 revocations-v1.json
|
||||
├─ cache/ # 清单缓存
|
||||
│ └─ icons/ # 正式装配的目标图标缓存根;当前以 NewIconCache(root, ...) 实参为事实
|
||||
├─ downloads/ # 下载临时文件 + 任务元数据
|
||||
@@ -145,7 +145,7 @@ soft_quay/
|
||||
|
||||
T-202 将本地识别拆为两层:
|
||||
|
||||
- `core/storage`:严格读写 `installed-app.json` v1,读取主文件或中断遗留 backup,并只读检测安装 transaction/journal 是否存在;`files[].path` 与 ZIP/Catalog 共用 Windows 安全相对路径规则。T-401 新记录同时保存已验证的 `entrypoint`、`working_directory`、`min_os` 与 `requires_admin`;旧 v1 记录仍可读取,但缺少完整元数据不能启动。
|
||||
- `core/storage`:严格读写 `installed-app.json` v1,读取主文件或中断遗留 backup,并只读检测安装 transaction/journal 是否存在;`files[].path` 与 ZIP/Catalog 共用 Windows 安全相对路径规则。T-401/T-503 新记录同时保存已验证的 `entrypoint`、`working_directory`、`min_os`、`product_id`、`supports_trial` 与 `requires_admin`;旧 v1 记录仍可读取,但缺少启动或 product 授权元数据不能启动。`core/storage.LicenseStore` 只在验签/机器绑定后原子保存 1 MiB 以下的 license document,并在每次读取重新验证;它同样原子缓存已验签的撤销列表,未知/链接/损坏布局 fail closed。
|
||||
- `core/domain`:纯 SemVer 2.0.0 比较与 `ResolveAppStatus`,按“恢复事务 → 活跃操作 → 运行 → 不兼容 → 是否安装 → 是否有更新”推导单一状态。
|
||||
|
||||
磁盘扫描结果必须在进入 Gio Layout 前准备好;UI 不直接读取 installed-app.json。完整字段见 [api.md](api.md),Schema 为 `schemas/installed-app.schema.json`。
|
||||
@@ -190,7 +190,7 @@ T-613 已把该状态机的代码层耐久顺序收敛为:payload 的 CRC/长度
|
||||
|
||||
T-403 的盒子自更新由独立、无 Gio 的 `SoftBoxUpdater.exe` 完成。它仅接受正 PID、绝对 `<root>/app` 和绝对 `<root>/staging/<request-id>`;request ID 不走 CLI,而是从已准备 staging 的规范目录名派生并复验。旧 PID 自然退出后才检查/恢复上次 journal,随后以 `prepared → target_backed_up → staging_activated → launched → committed` 把 `app` 与同 root staging 受限 rename。T-617 固定 `prepared` 的恢复依据为经 `Lstat` 验证的实际 target/backup 拓扑:仅 target 存在且 backup 不存在才删除 journal;target 缺失且 managed backup 存在必须先 restore;其余组合保留 journal/材料并 fail closed。因此首次 `app → backup` rename 已完成但目录 sync 失败时,当前调用会立即尝试该受限收敛,下一次 Recover 也不会再盲删 journal。`core/updater` 只依赖 PID waiter、固定 launcher、health waiter 和目录同步接口;Windows 的 `OpenProcess(SYNCHRONIZE)`/短等待、无 shell 固定 health 启动和 `FlushFileBuffers` 均保留在两端 `platform/windows`,非 Windows stub fail closed。新版仅能从自身 `<root>/app/SoftBox.exe` 在匹配的已激活 transaction 下写最小 `<root>/self-update-health.json` 确认;确认前旧 backup 不删除,失败优先 restore,文件锁导致 restore 失败则保留 journal/backup 而不强杀。它不提供下载、签名校验、版本选择或 UI 触发,可信 package→staging 链继续后置。
|
||||
|
||||
授权:平台层短暂读取严格规范化的 `MachineGuid` 与 Windows 目录所在卷序列号两个必需来源 → 用固定域分隔 SHA-256 生成 `machine_hash` v1(不保存原始 GUID、序列号或 MAC;任一来源失败即拒绝)→ 服务端 Ed25519 私钥对删除顶层 `signature` 后的受限 canonical License v1 JSON 签名 → 客户端 core 以调用方注入的 32 字节公钥离线验签、严格验证九字段并精确比对 machine_hash/product ID;许可证与程序文件、用户配置分开保存;子软件必须独立再次验证,不能只信盒子。受限 canonical JSON 为 Catalog/License 共用的 `core/internal` 实现;core 不读许可证文件也不含生产 key,Windows 采集留在双端 `platform/windows`,非 Windows stub fail closed。导入/存储/试用/撤销/rebind/UI composition 仍属于 T-503。
|
||||
授权:平台层短暂读取严格规范化的 `MachineGuid` 与 Windows 目录所在卷序列号两个必需来源 → 用固定域分隔 SHA-256 生成 `machine_hash` v1(不保存原始 GUID、序列号或 MAC;任一来源失败即拒绝)→ 服务端 Ed25519 私钥对删除顶层 `signature` 后的受限 canonical License v1 JSON 与 Revocation List v1 JSON 签名 → 客户端 core 以调用方显式注入的 32 字节授权公钥离线验签、严格比对 machine_hash/product ID,并在 `licenses/v1/` 重新验证缓存;撤销为 current、最多 7 天 grace 或 unavailable/revoked,unavailable/revoked 一律不可授权。安装记录从已验证 `app.json` 携带 product/trial 元数据,启动按 product ID 检查而非 app ID。`core/application.AuthorizationService` 只向 Gio relay 发布净化状态快照,Gio 点击仅入队导入请求,后台 source/read/verify/store 后才发布结果。受限 canonical JSON 为 Catalog/License/Revocation 共用的 `core/internal` 实现;core 不含生产 key,Windows 采集留在双端 `platform/windows`,非 Windows stub fail closed。当前 cmd 明确注入未配置授权 loader;真实 trust root、撤销获取和文件选择 composition 留给发布配置。试用/到期/自助换绑/申诉仍不在客户端实现,子软件必须独立再次验证,不能只信盒子。
|
||||
|
||||
## 六、关键技术难点
|
||||
|
||||
|
||||
+1
-1
@@ -119,7 +119,7 @@ T-617 已关闭 T-403 自更新的 `prepared` rename 后目录 sync 失败会留
|
||||
| --- | --- | --- | --- |
|
||||
| T-501 | 机器指纹(machine_hash) | T-001 | 固定 MachineGuid + Windows 系统卷序列号的 v1 域分隔 SHA-256;任一来源失败拒绝、不落原始标识,非 Windows stub 可测 |
|
||||
| T-502 | Ed25519 许可证验证 | T-501 | 固定 License v1 九字段、共享受限 canonical JSON、canonical Base64 Ed25519 签名与严格 machine_hash 比对;静态语料无私钥,复制到不匹配机器拒绝 |
|
||||
| T-503 | 授权界面与试用/导入/换绑 | T-502, T-204 | 导入、授权列表、试用状态展示;撤销名单验签与宽限期 |
|
||||
| T-503 | 授权界面与试用/导入/换绑 | T-502, T-204 | 已完成:受限离线导入/授权列表、非永久许可证说明、Revocation List v1 验签与 7 天宽限;trial/rebind 仅展示签名事实,不创建本地授权 |
|
||||
|
||||
### Phase 6 · Win7 加固与发布
|
||||
|
||||
|
||||
+26
-6
@@ -176,6 +176,8 @@ T-102 Phase 1 原型进一步固定:
|
||||
"entrypoint": "JsonParser.exe",
|
||||
"working_directory": ".",
|
||||
"min_os": "windows-7-sp1",
|
||||
"product_id": "product-json-parser",
|
||||
"supports_trial": true,
|
||||
"requires_admin": false,
|
||||
"files": [
|
||||
{
|
||||
@@ -191,7 +193,7 @@ T-102 Phase 1 原型进一步固定:
|
||||
|
||||
- Schema 位于 `schemas/installed-app.schema.json`;未知字段、非法 SemVer、ID/目录不匹配、非 `386|amd64` 架构、非 stable channel、共享 Windows 安全相对路径以外的文件名、大小写折叠重复路径和非法 SHA-256 均拒绝。
|
||||
- `files` 必须是数组;v1 可以为空。T-302 从实际已验证、CRC/长度检查后写入 staging 的每个 payload 文件计算路径、size 和 SHA-256 并写入完整清单;可选 `files.json` 不是新的信任根,仍保留给后续修复功能。
|
||||
- T-401 新安装必须写入 `entrypoint`、`working_directory`、`min_os`、`requires_admin`,其值来自已与可信 Catalog 精确比对的 `app.json`。前 3 个字段沿用共享安全相对路径/已知系统版本规则(working directory 额外允许 `.`);entrypoint 必须也是 `files` 内的普通文件。为保持 v1 可读,历史记录可缺少这些可选字段,但启动用例不得猜测默认 EXE、工作目录或最低系统,必须 fail closed。
|
||||
- T-401/T-503 新安装必须写入 `entrypoint`、`working_directory`、`min_os`、`product_id`、`supports_trial`、`requires_admin`;它们均来自已经验证的 `app.json`,其中 product/trial 元数据由已验证 ZIP 携带,不能由 UI 或 Catalog 显示名称推断。前 3 个字段沿用共享安全相对路径/已知系统版本规则(working directory 额外允许 `.`);entrypoint 必须也是 `files` 内的普通文件。为保持 v1 可读,历史记录可缺少这些可选字段,但启动用例不得猜测默认 EXE、工作目录、最低系统或 product ID,必须 fail closed;`supports_trial` 仅是声明能力,绝不授予本地试用。
|
||||
- 写入使用 app 目录内临时文件 + `installed-app.json.backup` 原子替换;主文件缺失时可读取中断遗留 backup,但所有读取都重新严格校验。
|
||||
- SemVer 比较遵循 2.0.0:major/minor/patch 与 prerelease 参与 precedence,build metadata 不影响更新判断。
|
||||
|
||||
@@ -252,7 +254,7 @@ T-302 在 Switcher 的 health 阶段先运行必需的注入 health check,再
|
||||
|
||||
### 2.6.1 受控启动与失败码
|
||||
|
||||
`core/application/launch` 的请求仅接受 app ID。它从本地记录取得启动元数据,确认 `current` 与工作目录是真实受控目录、entrypoint 是 `current` 内列入 `files` 的普通非 symlink 文件,再依次检查最低系统、授权和该精确 entrypoint 的运行状态,最后调用平台启动器。请求或 UI 不得提供 EXE、路径、参数、URL 或 shell command;授权 checker 是必需注入边界,许可证策略仍由 T-501~T-503 实现。
|
||||
`core/application/launch` 的请求仅接受 app ID。它从本地记录取得启动元数据,确认 `current` 与工作目录是真实受控目录、entrypoint 是 `current` 内列入 `files` 的普通非 symlink 文件,再依次检查最低系统、已验证 `product_id` 的授权和该精确 entrypoint 的运行状态,最后调用平台启动器。请求或 UI 不得提供 EXE、路径、参数、URL 或 shell command;缺少 product ID 的历史记录返回 `authorization_unavailable`,不得以 app ID、名称或 `supports_trial` 代替。
|
||||
|
||||
Windows 平台用 Toolhelp32 快照枚举,并以 `QueryFullProcessImageName` 的规范绝对路径作最终身份匹配;同名 EXE 只可作为查询优化,不能成为运行结论。快照/枚举/候选路径查询失败必须返回错误,不能当作“不在运行”。启动器只接收已验证的绝对 entrypoint 和工作目录:普通启动不经命令 shell 或 PATH 搜索;`requires_admin` 使用固定 `runas` Windows 动词且不传参数。非 Windows stub 对兼容、进程检测和启动均返回明确不支持错误。
|
||||
|
||||
@@ -327,7 +329,25 @@ License v1 只允许示例中的九个顶层字段,全部必需,禁止未知
|
||||
|
||||
验证器先拒绝不安全 JSON/签名,再严格验证字段与签名,最后将许可证中的 hash 与调用方从 T-501 得到的 64 位小写 expected hash 精确比较。未配置或非法 public key、任何格式/验签失败、hash 不匹配、未知产品均 fail closed;错误、事件和 UI 不得包含 license JSON、signature、license ID 或任何原始机器来源。`machine_hash` v1 固定由短暂读取的规范化 `MachineGuid` 与 Windows 目录所在卷序列号生成:`SHA-256("softbox.machine-hash.v1\\x00" || guid || "\\x00" || volume_serial)`;许可证、存储、事件、UI 和日志均**不保存**原始 GUID、卷序列号或 MAC。
|
||||
|
||||
T-502 的 core verifier 接收调用方注入的 public key 和 expected hash;当前仓库没有生产 trust root,不能以测试 key、环境变量或 allow-all 代替。`perpetual`、update/rebind policy 在本任务只作为已签名元数据,不实施过期、trial、撤销名单、宽限期或 rebind 决策。许可证文件保存于 `licenses/` 且更新不得覆盖,但文件导入/存储、授权页面和启动用例接线留给 T-503;届时盒子和子软件都必须独立再验证签名、machine_hash 与 product ID。
|
||||
T-503 将通过验证的原始 document 限制在 `licenses/v1/<sha256>.license`:输入和每次读取均限制 1 MiB,根/子目录/文件必须是真实对象,文件名只能为 digest 形式;写入以 0600 临时文件 `Sync`/`Close` 后 rename,重复 document 幂等。未知、链接、超限或损坏缓存均 fail closed。更新、安装和自更新不得覆盖 `licenses/`。盒子和子软件都必须独立重新验证签名、machine_hash、product ID 和下面的撤销状态。
|
||||
|
||||
### 3.2 Revocation List v1 与离线宽限
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"generated_at": "2026-07-20T00:00:00Z",
|
||||
"expires_at": "2026-07-27T00:00:00Z",
|
||||
"revoked_license_ids": ["lic-example"],
|
||||
"signature": "..."
|
||||
}
|
||||
```
|
||||
|
||||
Revocation List v1 的 Schema 为 `schemas/revocation-list.schema.json`。只允许这五个必需字段;使用与 License v1 相同的受限 canonical JSON、32 字节调用方注入授权公钥和严格标准 padded Base64 Ed25519 签名。`generated_at`/`expires_at` 是精确 UTC 秒级文本,后者必须晚于前者且间隔不超过 31 天;撤销 ID 可以为空但不得重复,格式与 License ID 相同。列表缓存为 `licenses/revocations-v1.json`,仅在验签成功后原子替换。
|
||||
|
||||
在调用方注入的时钟下,已列出的 License ID 立即为 `revoked`;未列出且列表未过期为 `current`;过期后(含恰好边界)最多 7 天为 `grace`;没有缓存、验签/布局失败、列表来自未来或超过宽限为 `unavailable`。`unavailable` 不能启动受保护软件,绝不以无撤销列表或网络失败作 allow-all 回退。事件、日志和 UI 只携带授权状态、machine_hash、product ID、正式/非永久与 rebind policy,不携带 license ID、JSON、签名、路径或原始机器来源。
|
||||
|
||||
License v1 没有到期字段:`perpetual:false` 只显示为“非永久许可证”,不是本地试用时钟。`supports_trial:true` 只表示包支持服务端试用;本仓库不创建未签名 trial、到期、换绑或申诉请求。授权页可以显示签名的 rebind policy 和说明,但不修改 machine binding。
|
||||
|
||||
## 4. application 事件合约(core → UI)
|
||||
|
||||
@@ -346,13 +366,13 @@ T-502 的 core verifier 接收调用方注入的 public key 和 expected hash;
|
||||
| InstallCompleted | 原子切换成功 + 健康检查通过 | app_id, version | 状态 → installed |
|
||||
| InstallRolledBack | 切换失败恢复 backup | app_id, error_code | 状态 → rollback 完成提示 |
|
||||
| AppStarted / AppExited | 进程启动/退出检测 | app_id, pid | 状态 → running / installed |
|
||||
| LicenseChanged | 许可证导入/撤销 | products | 授权视图刷新 |
|
||||
| LicenseChanged | 授权启动加载、导入或撤销状态更新 | `state`, machine_hash, product ID、正式/非永久、rebind policy | 授权视图刷新;不得带 license ID、JSON、签名、路径或原始机器来源 |
|
||||
| IconReady | 图标已完成可信加载与后台解码 | request_id, app_id, icon_ref, DPI, image.Image | 有界 relay 唤醒窗口;UI Frame 验证仍为最新请求后创建 ImageOp |
|
||||
| IconFailed | 图标加载、校验或解码失败(取消不发布) | request_id, app_id, icon_ref, DPI, error_code | UI 记录诊断;仅保留同 icon_ref/DPI 的既有可信图标,否则继续占位 |
|
||||
|
||||
错误码为稳定英文枚举(如 `hash_mismatch`, `zip_path_escape`, `disk_full`, `app_running`, `signature_invalid`),UI 负责本地化文案。图标事件只使用 `unavailable`、`invalid_content`、`unsafe_cache`,原始网络错误只返回后台调用方/日志,不得进入 UI payload。`unsafe_cache` 详情只使用 event 中已经验证的 app ID、icon_ref 与 DPI 生成 `<digest>-<dpi>.icon` locator;不得把 cache root、原始错误或 link target 补进 event/UI。
|
||||
|
||||
T-616 固定 Catalog 启动事件的强类型 payload:`CatalogRefreshed` 只携带已经验签、协议校验、目标过滤并转为内存 `CatalogListItem` 的深拷贝快照,`source` 只允许 `remote` 或 `cache`;`CatalogRejected` 不携带列表、URL、路径、密钥或原始错误,只允许 `catalog_source_unconfigured`、`catalog_load_failed`。UI 必须拒绝类型、source、item 身份或 code 不匹配的 payload,且失败不得清除已成功显示的快照。当前生产 cmd 没有真实 URL/公钥/发布配置,故启动时只发布 `catalog_source_unconfigured`;不得以 testdata、环境变量、未签名本地文件或 allow-all verifier 填充目录。
|
||||
T-616/T-503 固定 Catalog/Authorization 启动事件的强类型 payload:`CatalogRefreshed` 只携带已经验签、协议校验、目标过滤并转为内存 `CatalogListItem` 的深拷贝快照,`source` 只允许 `remote` 或 `cache`;`CatalogRejected` 不携带列表、URL、路径、密钥或原始错误,只允许 `catalog_source_unconfigured`、`catalog_load_failed`。`LicenseChanged` 只接受状态/产品的深拷贝净化快照;UI 必须拒绝类型、身份或 code 不匹配的 payload。当前生产 cmd 没有真实 URL/公钥/发布配置,故分别发布未配置状态;不得以 testdata、环境变量、未签名本地文件或 allow-all verifier 填充目录或授权。
|
||||
|
||||
## 5. CLI 参数合约
|
||||
|
||||
@@ -418,5 +438,5 @@ V1.1:命名管道 `\\.\pipe\softbox.<app-id>`,盒子发送 `{"command": "prepare
|
||||
- 清单密钥 ID/轮换字段定稿后同步 `schemas/`。
|
||||
- 错误码完整枚举表。
|
||||
- 图标资源从内容哈希到下载位置/分辨率变体的发布端映射格式。
|
||||
- 撤销名单的结构与宽限期时长。
|
||||
- 撤销列表获取/缓存刷新协议、授权 key 轮换与发布端签发流程(Revocation List v1 结构与 7 天宽限已冻结)。
|
||||
- 硬件或系统卷变化后的服务端 rebind 资格、频率和人工申诉流程(客户端 machine_hash v1 不做部分匹配或加权)。
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
## 当前快照
|
||||
|
||||
- 日期:2026-07-20
|
||||
- 阶段:Phase 2 已完成(T-201~T-204)并由 T-616 补齐启动 Catalog 快照投递/空态诊断;Phase 3 的 T-301 可恢复下载队列、T-302 安装流程整合、T-303 失败处理/磁盘预检查与 T-615 staging 输出 I/O/磁盘满诊断整改已完成;审核整改 T-604~T-617 与 Phase 4 的 T-401 进程检测、受控启动和切换临界区复查、T-402 子软件更新编排、T-403 受限盒子自更新事务及 T-617 恢复状态机/测试整改已完成;Phase 5 的 T-501 严格双来源 machine_hash v1 与 T-502 License v1 离线验签已完成
|
||||
- 阶段:Phase 2 已完成(T-201~T-204)并由 T-616 补齐启动 Catalog 快照投递/空态诊断;Phase 3 的 T-301 可恢复下载队列、T-302 安装流程整合、T-303 失败处理/磁盘预检查与 T-615 staging 输出 I/O/磁盘满诊断整改已完成;审核整改 T-604~T-617 与 Phase 4 的 T-401 进程检测、受控启动和切换临界区复查、T-402 子软件更新编排、T-403 受限盒子自更新事务及 T-617 恢复状态机/测试整改已完成;Phase 5 的 T-501 严格双来源 machine_hash v1、T-502 License v1 离线验签与 T-503 授权导入/撤销状态/UI 接线已完成
|
||||
- 技术栈:根 Go 1.25 workspace 只纳入 core/app-modern,`app-win7/go.work` 独立纳入 core/app-win7;版本闸门证明 modern Gio v0.10.1 与 win7 Gio v0.6.0 不交叉解析
|
||||
- 生产代码:core 已有 Catalog/本地状态/存储、共享 Windows 安全相对路径策略与静态跨实现 canonicalization/Ed25519 vector corpus(拒绝非法 surrogate、`-0` 和非唯一 Base64 signature,大整数保持 token)、安全 ZIP 解压/回滚原型及 T-302/T-303/T-615 安装 use case(`core/application/install.InstallService` 只取已过滤 Catalog entry + architecture,强制注入 disk/storage-failure/target-state checker;`Extractor.ExtractVerifiedFileWithCheck` 在同一普通文件句柄按 size→SHA-256→EOCD/ZIP64→严格 app.json→已规划 payload 的 staging 前预检→安全 staging 的顺序处理,空间要求为 payload+64 MiB,ZIP 输入错误与 staging 创建/write/sync/close 错误分界并保留原始 I/O 链;平台可识别的后者磁盘满返回 `disk_full`,其余输出 I/O 返回稳定 code 且不触发 switch;清理失败可观察,Recover 仅删除已验证 layout 内的残留 staging;每个实际 payload 文件 hash 与受验证 entrypoint/working directory/min_os/requires_admin 写入 installed-app;health 或记录写失败经 Switcher 回滚;更新 current→backup 紧邻前复查精确 entrypoint,明确运行/检测故障保持旧版本并清理 staging),transaction/switch/rollback/recovery 的 journal、rename、清理经统一 fail-closed 耐久栅栏,Windows 使用目录句柄 FlushFileBuffers)、纯 core `application/launch`(只接收 app ID、受控 current/普通 entrypoint/兼容/授权/运行状态/启动器接口全部 fail closed)、双端 Toolhelp 完整映像路径检测/Win7 可用系统版本判断/无参数受控启动与非 Windows fail-closed stub、发布稳定只读 generation 的无 IO 软件列表模型、按 key in-flight + 流式有界读取 + 32 MiB/256-key LRU 的可信图标缓存、图标 Load/Decode 事件发布用例、有界 application event relay,以及默认并发 2 的持久可恢复下载队列;modern/win7 主循环已接 relay/Invalidate,AppShell 已实现搜索/分类/视图、惰性列表、详情右栏、完整图标失败 identity 生命周期与仅 `unsafe_cache` 可见的安全 locator/人工恢复提示,并按 root/header/catalog/detail/style 同 package 镜像职责拆文件
|
||||
- T-402 更新用例:`core/application/update` 只接收外层可信的 install selection,验证已装版本/旧 entrypoint后,运行中才请求关闭确认并以 1 秒~10 分钟上限等待自然退出,随后委托已有 `InstallService` 的双重运行复查与 rollback;取消、超时、Toolhelp 检测错误和安装错误均保留稳定 code/错误链,不强杀且不改 `data/`、`licenses/`。两端 platform 对齐 `WaitForExit` 契约,Windows 固定短轮询完整路径,非 Windows 返回明确不支持。
|
||||
@@ -21,33 +21,33 @@
|
||||
- T-616 启动 Catalog:`core/application.CatalogBootstrap` 只消费 composition 注入的已验证、目标过滤内存快照,以深拷贝 `CatalogRefreshed`/稳定 `CatalogRejected` payload 经现有 runtime/relay 交给 UI Frame;双端 shell 在 UI goroutine 更新 `SetItems` 并明确显示 loading、未配置、加载失败、已加载空目录与筛选空结果。当前 cmd 有意注入 `UnconfiguredCatalogLoader`,故 `dist/SoftBox.exe` 不会显示 fake 软件,而会显示“Catalog 来源尚未配置”;可信 URL/公钥/缓存 composition 仍待发布配置。
|
||||
- T-617 自更新整改:`prepared` journal 不再单独决定是否移动;Recover 仅在 target 存在且 managed backup 缺失时删 journal,target 缺失且 backup 存在时先 restore,其余拓扑 fail closed 并保留材料。首次 backup rename 后目录 sync 错误立即走相同受限恢复路径,恢复包装保留 `ErrRecoveryRequired` 和底层 durability 根因。覆盖 prepared/target_backed_up/staging_activated/launched/committed、transaction/rename/sync/cleanup 和 health timeout 阻塞后 Recover;双端 health flag 的缺 ID/多余参数均被拒绝。Windows 真机锁、杀毒和断电仍待 T-601。
|
||||
- T-501 机器指纹:`core/licensing.DeriveMachineHash` 是 Go 1.20 兼容的纯算法,严格规范化 `MachineGuid` 并按固定域分隔与 Windows 目录所在卷序列号生成 64 字符小写 SHA-256;两端 `platform/windows.MachineHash` 仅在调用期间读取这两个来源,采集/规范化失败只返回无原始值的 sentinel,非 Windows 返回 `ErrUnsupported`。原始 GUID、卷序列号和 MAC 均不进入持久化、事件、UI 或日志。
|
||||
- T-502 许可证验证:`core/internal/canonicaljson` 是 Catalog/License 共用的受限 canonical JSON 实现;`core/licensing.Verifier` 只接受复制后的 32 字节 public key、License v1 document 与 T-501 expected hash,严格验签、九字段、机器绑定和 product 查询,失败只返回无内容 sentinel。`schemas/license.schema.json` 与 `testdata/license` 固定协议/跨实现签名向量且没有私钥;生产 trust root、许可证文件导入/存储、授权 UI、trial/revocation/rebind 和启动接线仍待 T-503。
|
||||
- T-503 授权:`core/licensing.RevocationVerifier` 以相同受限 canonical JSON 验签五字段 Revocation List v1;最多 31 天签名有效期,过期后仅 7 天 grace,未来/缺失/损坏/超期或已撤销一律不可授权。`core/storage.LicenseStore` 只在 License v1 签名+machine hash 验证后原子写入受控 `licenses/v1/<sha256>.license`,读取时重新验证,撤销缓存为 `licenses/revocations-v1.json`;链接、未知/损坏缓存和超过 1 MiB 均 fail closed。`AuthorizationService`/`LicenseChanged` 只投递状态、machine_hash、product ID、许可证类型和 rebind policy,绝不投递 license ID/文档/签名/路径;安装记录从已验证 app manifest 带 `product_id`/`supports_trial`,启动按 product ID 检查,旧记录缺它时返回授权不可用。双端 Gio 有相同授权视图与后台导入请求入口,默认 cmd 只发布 authorization unconfigured,不提供生产 key、撤销下载或文件选择 composition;License v1 不提供本地 trial/expiry/rebind 服务。
|
||||
- 测试:core 覆盖 Catalog 静态 canonicalization/Ed25519 vectors、非法 surrogate/`-0`/Base64 fail-closed、列表快照 generation/零复制、SemVer/12 状态、本地安装记录、Windows dot-space/设备名/Unicode 折叠路径攻击、ZIP destination 包含性与 EOCD/ZIP64 原始包/中央目录/条目数预扫描、T-302/T-303/T-615 同句柄 package size/SHA、严格/有界 app.json、verified payload 预检 hook、容量精确阈值/故障、程序运行/状态故障、稳定安装失败码、staging write/sync/close ENOSPC 与普通输出 I/O 原因保留、CRC 输入分界、清理失败/受控恢复、payload hash 与启动元数据记录、Catalog 选择拒绝、transaction recovery、health/记录写失败回滚、switch 临界区复查、受控启动的旧 metadata/unsafe layout/缺文件/兼容/授权/运行/启动失败、payload/staging tree/journal/rename/rollback/recovery/cleanup 耐久顺序及错误注入、Windows 原生目录 `FlushFileBuffers`、图标并发/取消/读取边界/LRU、真实目录/symlink fail-closed 与 cache→`unsafe_cache` event、relay 背压与关闭、下载并发/暂停/取消/重试/Range/断连/恢复/事件失败与文件身份替换;两个 app 覆盖 Toolhelp snapshot full-path collision/error seam、OS version 判断和非 Windows fail-closed stub,以及 Editor/视图/分类/行/恢复/关闭接线、500 项 viewport、AppID 控件与分类控件生命周期、详情上下文、空状态语义、UI drain 前后、图标失败身份生命周期与 `unsafe_cache` 详情语义;安装恢复矩阵保持通过
|
||||
- 数据:`schemas/` 已有 manifest/app/installed-app/download-task/license v1 Schema并注明 Windows 路径运行时权威规则;`testdata/catalog/` 有公开虚构清单样例和 v1 静态 canonicalization/Ed25519 corpus;`testdata/license/` 有不含私钥的 License v1 静态签名语料;`testdata/zip/` 与 `testdata/download/` 记录运行时生成的攻击/传输矩阵
|
||||
- 数据:`schemas/` 已有 manifest/app/installed-app/download-task/license/revocation-list v1 Schema并注明 Windows 路径运行时权威规则;`testdata/catalog/` 有公开虚构清单样例和 v1 静态 canonicalization/Ed25519 corpus;`testdata/license/` 有不含私钥的 License v1 静态签名语料;`testdata/zip/` 与 `testdata/download/` 记录运行时生成的攻击/传输矩阵
|
||||
- 标准启动路径:`./init.sh` / `./init.ps1`(同步依赖、执行完整 Phase 0 闸门、打印双目标构建命令)
|
||||
- 标准验证路径:`bash scripts/verify_phase0.sh` / `./scripts/verify_phase0.ps1`
|
||||
- 版本管理:git 已初始化,main 分支,远端 origin 为 Gitea `opc/soft_quay`;harness 文档已提交
|
||||
- 当前 blocker:真实 Catalog URL/公钥/发布配置仍未入库,所以默认 `dist/SoftBox.exe` 明确显示 `catalog_source_unconfigured`,不得用 testdata、未签名本地文件或 allow-all verifier 伪装可用列表。可信自更新包下载/签名、版本选择、许可证策略与完整端到端 cmd/UI 编排也仍未装配。T-614 的外部 `softbox-catalog` 消费 corpus CI 证据仍需跨仓库协调;物理断电、文件锁/杀毒软件干扰和真实图形环境仍需 T-601 的目标 Windows VM/真机故障注入
|
||||
- 当前 blocker:真实 Catalog URL/公钥/发布配置仍未入库,所以默认 `dist/SoftBox.exe` 明确显示 `catalog_source_unconfigured`,不得用 testdata、未签名本地文件或 allow-all verifier 伪装可用列表。授权代码同样只显示 `authorization_unconfigured`,直到 T-603 注入生产 trust root、撤销获取与文件选择 composition;可信自更新包下载/签名和版本选择也仍未装配。T-614 的外部 `softbox-catalog` 消费 corpus CI 证据仍需跨仓库协调;物理断电、文件锁/杀毒软件干扰和真实图形环境仍需 T-601 的目标 Windows VM/真机故障注入
|
||||
|
||||
## 当前目录要点
|
||||
|
||||
| 路径 | 状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `docs/` | 已有 | harness coding 文档集(本次初始化完成) |
|
||||
| `docs/tasks/` | 已有 | Phase 0~2、T-301~T-303、T-604~T-617、T-401~T-403、T-501 与 T-502 已完成 |
|
||||
| `docs/tasks/` | 已有 | Phase 0~2、T-301~T-303、T-604~T-617、T-401~T-403、T-501~T-503 已完成 |
|
||||
| `scripts/` | 已有 | harness 治理、core 边界、Go 版本检查与 Phase 0 双平台验证入口 |
|
||||
| `core/` | 已建 | Go 1.20 兼容;已有正式 Catalog、本地状态/存储、共享 Windows safepath、列表模型、有界并发图标缓存、图标事件/relay、可恢复下载队列与 Phase 1 安装安全原型 |
|
||||
| `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;Modern AppShell 已接入虚拟列表、详情、图标事件 drain/过期拒绝和内存 ImageOp,并拆为五类 shell 职责文件 |
|
||||
| `app-win7/` | 已建 | Go 1.20 + Gio v0.6.0;Legacy AppShell 已接入低成本列表、详情、图标事件 drain/过期拒绝和内存 ImageOp,并保持同名职责文件与版本差异 |
|
||||
| `schemas/` | 已建 | `manifest.schema.json`、`app.schema.json`、`installed-app.schema.json`、`download-task.schema.json` 与 `license.schema.json` |
|
||||
| `schemas/` | 已建 | `manifest.schema.json`、`app.schema.json`、`installed-app.schema.json`、`download-task.schema.json`、`license.schema.json` 与 `revocation-list.schema.json` |
|
||||
| `testdata/` | 已建 | 包含 Catalog 假数据、ZIP 恶意矩阵与下载协议测试说明;后续任务继续扩展 |
|
||||
|
||||
## 任务状态
|
||||
|
||||
任务状态以 `docs/tasks/` 各任务文件 frontmatter 的 `status` 为准。本节只写项目级摘要:
|
||||
|
||||
- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`~`T-204` 与 `T-616`;Phase 3 的 `T-301`~`T-303` 与 `T-615`;审核整改 `T-604`~`T-617`;Phase 4 的 `T-401`~`T-403`;Phase 5 的 `T-501`、`T-502`。
|
||||
- 正在进行:无;下一步可按路线图正式落成 T-503(许可证文件导入、授权 UI、trial/revocation/rebind 与启动授权接线,依赖 T-502、T-204)。T-601 的物理断电与干扰故障注入仍保留为发布前环境验证。
|
||||
- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`~`T-204` 与 `T-616`;Phase 3 的 `T-301`~`T-303` 与 `T-615`;审核整改 `T-604`~`T-617`;Phase 4 的 `T-401`~`T-403`;Phase 5 的 `T-501`~`T-503`。
|
||||
- 正在进行:无;下一步为 T-601 的 Windows 7 真机/VM API 与故障注入验证。真实生产授权 trust root、撤销获取/文件选择 composition 与发布流水线仍需 T-603 配置,不能由 testdata 或 allow-all 替代。
|
||||
|
||||
## 当前可运行内容
|
||||
|
||||
|
||||
@@ -52,6 +52,8 @@ T-610 固定 modern 与 Win7 两个 `ui/gio` 适配器的同 package 文件职
|
||||
| `shell_catalog.go` | content/catalog、惰性列表、app row/icon 与空状态 |
|
||||
| `shell_detail.go` | 详情布局、详情字段、`unsafe_cache` 安全诊断与动作/回退文案 helper |
|
||||
| `shell_style.go` | palette/theme、panel 绘制、view/status 文案与颜色 helper |
|
||||
| `shell_license.go` | 授权状态、machine_hash、产品/试用说明、导入与换绑说明布局;只消费内存快照 |
|
||||
| `license_events.go` | `LicenseChanged` 净化快照的 UI goroutine 接入 |
|
||||
|
||||
这些文件仍共同实现一个 `gio` package,不是新增页面 API 或状态层。modern-only 的 `layoutCatalog`/`layoutFooter`/`actionLabel` 等保留在对应职责文件,Win7 不增加空壳;后续视图应进入相应职责文件,不能重新把布局链堆回根 `shell.go`。
|
||||
|
||||
@@ -79,6 +81,8 @@ T-609 固定 `VisibleItems()` 的只读 snapshot generation:筛选或 Catalog
|
||||
|
||||
T-616 固定启动快照投递:`cmd` 只在后台运行 `CatalogBootstrap`,它将已准备内存快照或稳定失败 code 发布到 runtime;relay 后的 UI Frame 通过 `ApplyEvent` 更新 shell。初始 loading、`catalog_source_unconfigured`、`catalog_load_failed`、已加载空 Catalog 和筛选无结果必须有不同的可见文本,错误不清除已加载快照。当前没有可信发布配置时,只能显示未配置诊断,不能从 testdata、环境变量、任意 URL、未签名本地文件或 allow-all verifier 生成列表;任何这些 I/O/校验仍不得进入 Layout。
|
||||
|
||||
T-503 的授权视图也只消费 `LicenseChanged` 净化快照。它显示 machine_hash、授权产品、正式/非永久说明、撤销 current/grace/unavailable/revoked 状态与已签名 rebind policy;不显示 license ID、文档、签名、路径或原始机器标识。导入按钮只产生一个 UI 请求,后台 source/read/verify/store 后再经 relay 更新;未配置构建明确禁用导入。`supports_trial` 不是本地试用授权,页面必须说明没有可验证期限时不会创建试用或修改绑定。
|
||||
|
||||
T-204 已落地的详情/图标约束:
|
||||
|
||||
- 点击软件行用 selected app ID 打开右侧详情,关闭后回到同一列表/筛选/滚动上下文。
|
||||
|
||||
+6
-3
@@ -3,12 +3,12 @@ id: T-503
|
||||
title: 授权界面、离线导入与撤销状态
|
||||
phase: 5
|
||||
deps: [T-502, T-204]
|
||||
status: TODO
|
||||
status: DONE
|
||||
created: 2026-07-20
|
||||
issue: null
|
||||
context_ref: null
|
||||
context_ref: b44531337d2fdca7c7c52fd42f66ad588ebcf9a2
|
||||
claim_branch: null
|
||||
work_branch: null
|
||||
work_branch: agent/codex/T-503
|
||||
write_paths:
|
||||
- docs/tasks/T-503.md
|
||||
- core/licensing/
|
||||
@@ -70,3 +70,6 @@ T-501/T-502 已经能在调用方给出机器摘要和公钥时,严格离线
|
||||
## 执行记录
|
||||
|
||||
- 2026-07-20:正式落成。冻结 Revocation List v1、7 天离线宽限和受限 `licenses/` 缓存;明确产品授权必须使用受验证 manifest 的 product ID,且 License v1 不足以实现本地 trial/rebind。默认发布构建仍以未配置状态 fail closed,真实 trust root/发布配置交由 T-603。
|
||||
- 2026-07-20:领取任务,基于 `b44531337d2fdca7c7c52fd42f66ad588ebcf9a2` 在 `agent/codex/T-503` 执行;先重跑基线,再实现授权存储、撤销状态、产品映射与双端 Gio 授权视图。
|
||||
- 2026-07-20:完成。新增严格 Revocation List v1(共享 canonical JSON、Ed25519、31 天最大有效期、7 天离线宽限)和 `LicenseStore`(验签后 1 MiB 受限缓存、digest 文件名复核、0600 原子写入、每次读取重新验证);`AuthorizationService` 仅发布不含 license ID/JSON/签名/路径的授权快照,并在无/坏/过期撤销缓存时拒绝授权。已验证 package manifest 的 `product_id`/`supports_trial` 现在写入安装记录,启动改按 product ID 检查,历史记录缺字段返回 `authorization_unavailable`。现代/Win7 Gio shell 均增加授权页和后台导入请求接线;默认 cmd 只发布 unconfigured 状态,不使用测试 key 或 allow-all。试用/换绑只展示已签名事实,不创建本地授权或变更绑定。
|
||||
- 2026-07-20:验证通过:`go -C core vet ./...`;`go -C core test -count=1 ./...`;`go -C app-modern test -count=1 ./...`;`go -C app-win7 test -count=1 ./...`;modern/Win7 Windows amd64 `go build ./cmd/softbox`(Win7 强制 Go 1.20.14);`./scripts/verify_phase0.ps1`;`python scripts/validate_agent_context.py`;`python scripts/validate_harness_governance.py`。提交前复核覆盖撤销边界、缓存 digest/布局、事件净化、旧安装记录 fail-closed 与 Gio Layout 无 I/O。
|
||||
|
||||
@@ -45,6 +45,15 @@
|
||||
"min_os": {
|
||||
"enum": ["windows-7-sp1", "windows-10", "windows-11"]
|
||||
},
|
||||
"product_id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9-]+$",
|
||||
"$comment": "Optional for pre-T-503 records; new installations persist the verified app.json product_id for launch authorization."
|
||||
},
|
||||
"supports_trial": {
|
||||
"type": "boolean",
|
||||
"$comment": "Optional for pre-T-503 records; absence means false and never grants a local trial."
|
||||
},
|
||||
"requires_admin": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://softbox.local/schemas/revocation-list-v1.json",
|
||||
"title": "SoftBox Revocation List v1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema_version", "generated_at", "expires_at", "revoked_license_ids", "signature"],
|
||||
"properties": {
|
||||
"schema_version": { "const": 1 },
|
||||
"generated_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$" },
|
||||
"expires_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$" },
|
||||
"revoked_license_ids": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": { "type": "string", "pattern": "^lic-[a-z0-9][a-z0-9-]{0,59}$" }
|
||||
},
|
||||
"signature": { "type": "string", "pattern": "^[A-Za-z0-9+/]{86}==$" }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user