Skip to content

Commit e54bd45

Browse files
feat(license): gate commercial trust on online revalidation
Require LastLicenseVerifiedAt within 30d when not AirGapped so revoked tokens stop granting entitlements after sync ages out. Co-authored-by: Cursor
1 parent 228c43e commit e54bd45

8 files changed

Lines changed: 328 additions & 1 deletion

File tree

‎licensing/advanced_proxy_test.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ func TestAllowsAdvancedProxyAllowsGraceThenRejects(t *testing.T) {
6666

6767
func withAdvancedProxyToken(t *testing.T, raw string, fn func()) {
6868
t.Helper()
69+
withCommercialTrustOK(t)
6970
orig := accessTokenForAdvancedProxy
7071
accessTokenForAdvancedProxy = func() string { return raw }
7172
t.Cleanup(func() { accessTokenForAdvancedProxy = orig })

‎licensing/claims.go‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ const FeatureGracePeriod = 7 * 24 * time.Hour
1111

1212
// commercialClaims returns parsed commercial claims when the token is a
1313
// non-temporary access token with at least one commercial entitlement and is
14-
// within exp + grace. Signature is not re-checked here.
14+
// within exp + grace. Signature is not re-checked here. Online hosts also
15+
// require CommercialTrustOK (last successful verify within 30 days).
1516
func commercialClaims(raw string) (*token.TokenClaims, bool) {
1617
claims, ok := parseCommercialClaims(raw)
1718
if !ok {
@@ -20,9 +21,23 @@ func commercialClaims(raw string) (*token.TokenClaims, bool) {
2021
if !withinFeatureWindow(claims, time.Now()) {
2122
return nil, false
2223
}
24+
if !CommercialTrustOK(time.Now()) {
25+
return nil, false
26+
}
2327
return claims, true
2428
}
2529

30+
// IsCommercialAccessToken reports whether raw looks like a non-temporary
31+
// commercial AccessToken still within exp+grace. Ignores online revalidation trust
32+
// so sync can still attempt verify when the success stamp is stale.
33+
func IsCommercialAccessToken(raw string) bool {
34+
claims, ok := parseCommercialClaims(raw)
35+
if !ok {
36+
return false
37+
}
38+
return withinFeatureWindow(claims, time.Now())
39+
}
40+
2641
// commercialLicenseType returns the primary entitlement label (governance/audit/build/…).
2742
func commercialLicenseType(raw string) (string, bool) {
2843
claims, ok := commercialClaims(raw)

‎licensing/edition_test.go‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,27 @@ func TestEditionFallsBackToCommunity(t *testing.T) {
4949
}
5050
}
5151

52+
func TestEditionDropsWhenLicenseVerifyStale(t *testing.T) {
53+
raw := mustMintAccessToken(t, "governance", false)
54+
withEditionToken(t, raw)
55+
if got := Edition(); got != "Governance" {
56+
t.Fatalf("Edition() = %q before stale", got)
57+
}
58+
59+
origVerified := verifiedAtFn
60+
verifiedAtFn = func() string {
61+
return time.Now().Add(-(LicenseVerifyMaxAge + time.Hour)).UTC().Format(time.RFC3339)
62+
}
63+
t.Cleanup(func() { verifiedAtFn = origVerified })
64+
65+
if got := Edition(); got != "Community" {
66+
t.Fatalf("Edition() = %q, want Community when verify stale", got)
67+
}
68+
}
69+
5270
func withEditionToken(t *testing.T, raw string) {
5371
t.Helper()
72+
withCommercialTrustOK(t)
5473
orig := accessTokenForEdition
5574
accessTokenForEdition = func() string { return raw }
5675
t.Cleanup(func() { accessTokenForEdition = orig })

‎licensing/notice_test.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ func TestExpiryNoticeForTokenCertifiedPlans(t *testing.T) {
126126
}
127127

128128
func TestWithinFeatureWindowGrace(t *testing.T) {
129+
withCommercialTrustOK(t)
129130
now := time.Now()
130131
raw := mustMintAccessTokenExpiringAt(t, "governance", now.Add(-time.Hour))
131132
if _, ok := commercialLicenseType(raw); !ok {

‎licensing/structured_test.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ func TestUnmarshalLicArrayAndString(t *testing.T) {
140140

141141
func withStructuredToken(t *testing.T, raw string, fn func()) {
142142
t.Helper()
143+
withCommercialTrustOK(t)
143144
orig := accessTokenForStructuredLogging
144145
accessTokenForStructuredLogging = func() string { return raw }
145146
t.Cleanup(func() { accessTokenForStructuredLogging = orig })

‎licensing/verify_state.go‎

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package license
2+
3+
import (
4+
"common/settings"
5+
"common/token"
6+
"strings"
7+
"time"
8+
)
9+
10+
const (
11+
// LicenseVerifyMaxAge is how long commercial entitlements stay trusted after
12+
// the last successful online AccessToken verification.
13+
LicenseVerifyMaxAge = 30 * 24 * time.Hour
14+
// LicenseVerifyWarnAge is when sync starts warning that revalidation is overdue.
15+
LicenseVerifyWarnAge = 7 * 24 * time.Hour
16+
// LicenseVerifyAttemptInterval throttles online verify attempts (~2x/week).
17+
LicenseVerifyAttemptInterval = 84 * time.Hour // 3.5 days
18+
)
19+
20+
var (
21+
airGappedFn = func() bool { return settings.Global().AirGapped }
22+
verifiedAtFn = func() string { return strings.TrimSpace(settings.Global().LastLicenseVerifiedAt) }
23+
verifyAttemptAtFn = func() string {
24+
return strings.TrimSpace(settings.Global().LastLicenseVerifyAttemptAt)
25+
}
26+
putSettingFn = settings.Put
27+
nowFn = time.Now
28+
setAccessToken = token.Set
29+
)
30+
31+
// CommercialTrustOK reports whether commercial entitlements may be granted based
32+
// on periodic online revalidation. AirGapped hosts skip this check (offline JWKS
33+
// + JWT exp/grace only). Missing LastLicenseVerifiedAt while online is not trusted.
34+
func CommercialTrustOK(now time.Time) bool {
35+
if airGappedFn() {
36+
return true
37+
}
38+
stamp, ok := parseRFC3339UTC(verifiedAtFn())
39+
if !ok {
40+
return false
41+
}
42+
return !now.After(stamp.Add(LicenseVerifyMaxAge))
43+
}
44+
45+
// LicenseVerifyAge returns how long since the last successful online verify.
46+
// ok is false when AirGapped or no stamp exists.
47+
func LicenseVerifyAge(now time.Time) (age time.Duration, ok bool) {
48+
if airGappedFn() {
49+
return 0, false
50+
}
51+
stamp, ok := parseRFC3339UTC(verifiedAtFn())
52+
if !ok {
53+
return 0, false
54+
}
55+
if now.Before(stamp) {
56+
return 0, true
57+
}
58+
return now.Sub(stamp), true
59+
}
60+
61+
// LicenseVerifyAttemptDue reports whether an online verify attempt should run.
62+
// Missing attempt stamp or missing success stamp → due immediately.
63+
func LicenseVerifyAttemptDue(now time.Time) bool {
64+
if airGappedFn() {
65+
return false
66+
}
67+
if strings.TrimSpace(verifiedAtFn()) == "" {
68+
return true
69+
}
70+
attempt, ok := parseRFC3339UTC(verifyAttemptAtFn())
71+
if !ok {
72+
return true
73+
}
74+
return !now.Before(attempt.Add(LicenseVerifyAttemptInterval))
75+
}
76+
77+
// StampLicenseVerified records a successful online AccessToken verification.
78+
func StampLicenseVerified(now time.Time) error {
79+
return putSettingFn("last_license_verified_at", now.UTC().Format(time.RFC3339))
80+
}
81+
82+
// StampLicenseVerifyAttempt records that an online verify was tried.
83+
func StampLicenseVerifyAttempt(now time.Time) error {
84+
return putSettingFn("last_license_verify_attempt_at", now.UTC().Format(time.RFC3339))
85+
}
86+
87+
// VerifyAndStampAccessToken runs token.Set and stamps success on OK.
88+
// Does not clear AccessToken on failure.
89+
func VerifyAndStampAccessToken(raw string, now time.Time) error {
90+
raw = strings.TrimSpace(raw)
91+
if raw == "" {
92+
return nil
93+
}
94+
if err := StampLicenseVerifyAttempt(now); err != nil {
95+
return err
96+
}
97+
if err := setAccessToken(raw); err != nil {
98+
return err
99+
}
100+
return StampLicenseVerified(now)
101+
}
102+
103+
func parseRFC3339UTC(raw string) (time.Time, bool) {
104+
raw = strings.TrimSpace(raw)
105+
if raw == "" {
106+
return time.Time{}, false
107+
}
108+
t, err := time.Parse(time.RFC3339, raw)
109+
if err != nil {
110+
return time.Time{}, false
111+
}
112+
return t.UTC(), true
113+
}

‎licensing/verify_state_test.go‎

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
package license
2+
3+
import (
4+
"errors"
5+
"testing"
6+
"time"
7+
)
8+
9+
func TestCommercialTrustOK(t *testing.T) {
10+
now := time.Date(2026, 9, 11, 12, 0, 0, 0, time.UTC)
11+
origAir, origVerified := airGappedFn, verifiedAtFn
12+
t.Cleanup(func() {
13+
airGappedFn = origAir
14+
verifiedAtFn = origVerified
15+
})
16+
17+
t.Run("airgapped always ok", func(t *testing.T) {
18+
airGappedFn = func() bool { return true }
19+
verifiedAtFn = func() string { return "" }
20+
if !CommercialTrustOK(now) {
21+
t.Fatal("expected AirGapped trust OK")
22+
}
23+
})
24+
25+
t.Run("missing stamp not trusted", func(t *testing.T) {
26+
airGappedFn = func() bool { return false }
27+
verifiedAtFn = func() string { return "" }
28+
if CommercialTrustOK(now) {
29+
t.Fatal("missing stamp must not trust")
30+
}
31+
})
32+
33+
t.Run("fresh stamp trusted", func(t *testing.T) {
34+
airGappedFn = func() bool { return false }
35+
verifiedAtFn = func() string { return now.Add(-24 * time.Hour).Format(time.RFC3339) }
36+
if !CommercialTrustOK(now) {
37+
t.Fatal("expected fresh stamp trusted")
38+
}
39+
})
40+
41+
t.Run("stale stamp not trusted", func(t *testing.T) {
42+
airGappedFn = func() bool { return false }
43+
verifiedAtFn = func() string {
44+
return now.Add(-(LicenseVerifyMaxAge + time.Hour)).Format(time.RFC3339)
45+
}
46+
if CommercialTrustOK(now) {
47+
t.Fatal("stale stamp must not trust")
48+
}
49+
})
50+
51+
t.Run("exactly 30d still trusted", func(t *testing.T) {
52+
airGappedFn = func() bool { return false }
53+
verifiedAtFn = func() string { return now.Add(-LicenseVerifyMaxAge).Format(time.RFC3339) }
54+
if !CommercialTrustOK(now) {
55+
t.Fatal("age == 30d should still trust")
56+
}
57+
})
58+
}
59+
60+
func TestLicenseVerifyAttemptDue(t *testing.T) {
61+
now := time.Date(2026, 9, 11, 12, 0, 0, 0, time.UTC)
62+
origAir, origVerified, origAttempt := airGappedFn, verifiedAtFn, verifyAttemptAtFn
63+
t.Cleanup(func() {
64+
airGappedFn = origAir
65+
verifiedAtFn = origVerified
66+
verifyAttemptAtFn = origAttempt
67+
})
68+
69+
airGappedFn = func() bool { return false }
70+
verifiedAtFn = func() string { return now.Add(-time.Hour).Format(time.RFC3339) }
71+
72+
t.Run("missing attempt due", func(t *testing.T) {
73+
verifyAttemptAtFn = func() string { return "" }
74+
if !LicenseVerifyAttemptDue(now) {
75+
t.Fatal("expected due")
76+
}
77+
})
78+
79+
t.Run("recent attempt not due", func(t *testing.T) {
80+
verifyAttemptAtFn = func() string { return now.Add(-time.Hour).Format(time.RFC3339) }
81+
if LicenseVerifyAttemptDue(now) {
82+
t.Fatal("expected not due")
83+
}
84+
})
85+
86+
t.Run("interval elapsed due", func(t *testing.T) {
87+
verifyAttemptAtFn = func() string {
88+
return now.Add(-(LicenseVerifyAttemptInterval + time.Minute)).Format(time.RFC3339)
89+
}
90+
if !LicenseVerifyAttemptDue(now) {
91+
t.Fatal("expected due after interval")
92+
}
93+
})
94+
95+
t.Run("missing success stamp due immediately", func(t *testing.T) {
96+
verifiedAtFn = func() string { return "" }
97+
verifyAttemptAtFn = func() string { return now.Format(time.RFC3339) }
98+
if !LicenseVerifyAttemptDue(now) {
99+
t.Fatal("missing success stamp must force attempt")
100+
}
101+
})
102+
103+
t.Run("airgapped never due", func(t *testing.T) {
104+
airGappedFn = func() bool { return true }
105+
verifiedAtFn = func() string { return "" }
106+
verifyAttemptAtFn = func() string { return "" }
107+
if LicenseVerifyAttemptDue(now) {
108+
t.Fatal("AirGapped must skip attempts")
109+
}
110+
})
111+
}
112+
113+
func TestVerifyAndStampAccessTokenLeavesTokenOnFailure(t *testing.T) {
114+
now := time.Date(2026, 9, 11, 12, 0, 0, 0, time.UTC)
115+
puts := map[string]string{}
116+
origPut, origSet := putSettingFn, setAccessToken
117+
t.Cleanup(func() {
118+
putSettingFn = origPut
119+
setAccessToken = origSet
120+
})
121+
122+
putSettingFn = func(name string, value interface{}) error {
123+
puts[name] = value.(string)
124+
return nil
125+
}
126+
setAccessToken = func(string) error {
127+
return errors.New("revoked")
128+
}
129+
130+
err := VerifyAndStampAccessToken("tok", now)
131+
if err == nil {
132+
t.Fatal("expected verify error")
133+
}
134+
if _, ok := puts["last_license_verify_attempt_at"]; !ok {
135+
t.Fatal("expected attempt stamp")
136+
}
137+
if _, ok := puts["last_license_verified_at"]; ok {
138+
t.Fatal("must not stamp success on failure")
139+
}
140+
}
141+
142+
func TestVerifyAndStampAccessTokenSuccess(t *testing.T) {
143+
now := time.Date(2026, 9, 11, 12, 0, 0, 0, time.UTC)
144+
puts := map[string]string{}
145+
origPut, origSet := putSettingFn, setAccessToken
146+
t.Cleanup(func() {
147+
putSettingFn = origPut
148+
setAccessToken = origSet
149+
})
150+
151+
putSettingFn = func(name string, value interface{}) error {
152+
puts[name] = value.(string)
153+
return nil
154+
}
155+
setAccessToken = func(string) error { return nil }
156+
157+
if err := VerifyAndStampAccessToken("tok", now); err != nil {
158+
t.Fatal(err)
159+
}
160+
if puts["last_license_verified_at"] != now.UTC().Format(time.RFC3339) {
161+
t.Fatalf("verified stamp = %q", puts["last_license_verified_at"])
162+
}
163+
}
164+
165+
func withCommercialTrustOK(t *testing.T) {
166+
t.Helper()
167+
origAir, origVerified := airGappedFn, verifiedAtFn
168+
airGappedFn = func() bool { return false }
169+
verifiedAtFn = func() string { return time.Now().UTC().Format(time.RFC3339) }
170+
t.Cleanup(func() {
171+
airGappedFn = origAir
172+
verifiedAtFn = origVerified
173+
})
174+
}

‎settings/options.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ type Settings struct {
3030
LastNewsCheck string `cfg:"last_news_check" reg:"LastNewsCheck" help:"The last time news was checked." hidden:"true"`
3131
LastSyncCheck string `cfg:"last_sync_check" reg:"LastSyncCheck" help:"The last time sync app was updated." hidden:"true"`
3232
LastLicenseNotice string `cfg:"last_license_notice" reg:"LastLicenseNotice" help:"Dedupe key for the last license expiry desktop notice." hidden:"true"`
33+
LastLicenseVerifiedAt string `cfg:"last_license_verified_at" reg:"LastLicenseVerifiedAt" help:"RFC3339 UTC of last successful online AccessToken verification." hidden:"true"`
34+
LastLicenseVerifyAttemptAt string `cfg:"last_license_verify_attempt_at" reg:"LastLicenseVerifyAttemptAt" help:"RFC3339 UTC of last online AccessToken verification attempt." hidden:"true"`
35+
LastLicenseVerifyNotice string `cfg:"last_license_verify_notice" reg:"LastLicenseVerifyNotice" help:"Dedupe key for license revalidation warning toasts." hidden:"true"`
3336
Aliases []string `cfg:"aliases" reg:"Aliases" default:"" help:"Comma-delimited list of version aliases in the format alias=version." hidden:"true"`
3437
AllowedSigners []string `cfg:"allowed_signers" reg:"AllowedSigners" help:"Comma-delimited signer organization names (O=) allowed after Authenticode chain verification. Use to restrict vendors (for example OpenJS Foundation vs NodeSource)." hidden:"true"`
3538
AllowedThumbprints []string `cfg:"allowed_thumbprints" reg:"AllowedThumbprints" help:"Optional comma-delimited SHA-1 Authenticode leaf thumbprints (hex). When set, node.exe must match one pin after org allowlist. Empty disables pinning." hidden:"true"`

0 commit comments

Comments
 (0)