@@ -27,6 +27,14 @@ const ACTIVE_ONLINE_LICENSE_STATUSES: LicenseStatus[] = [
2727 'past_due' ,
2828] ;
2929
30+ const ONLINE_LICENSE_ASSERTION_AUDIENCE = 'sourcebot-online-license' ;
31+ const ONLINE_LICENSE_ASSERTION_CLOCK_SKEW_MS = 5 * 60 * 1000 ;
32+
33+ // Compatibility switch for the first release that understands signed online
34+ // licenses. Set this to false in the enforcement release, after Lighthouse has
35+ // been returning assertions for at least one full online-license TTL.
36+ const ALLOW_LEGACY_UNSIGNED_ONLINE_LICENSES = true ;
37+
3038// @WARNING : when adding a new entitlement to this list, make sure
3139// lighthouse/lambda/entitlements.ts is also updated && deployed
3240// prior to rolling a new Sourcebot version.
@@ -47,6 +55,74 @@ const ALL_ENTITLEMENTS = [
4755] as const ;
4856export type Entitlement = ( typeof ALL_ENTITLEMENTS ) [ number ] ;
4957
58+ const onlineLicenseAssertionPayloadSchema = z . object ( {
59+ version : z . literal ( 1 ) ,
60+ audience : z . literal ( ONLINE_LICENSE_ASSERTION_AUDIENCE ) ,
61+ licenseId : z . string ( ) . min ( 1 ) ,
62+ installId : z . string ( ) . min ( 1 ) ,
63+ status : z . enum ( [
64+ 'active' ,
65+ 'trialing' ,
66+ 'past_due' ,
67+ 'unpaid' ,
68+ 'canceled' ,
69+ 'incomplete' ,
70+ 'incomplete_expired' ,
71+ 'paused' ,
72+ ] ) ,
73+ entitlements : z . array ( z . enum ( ALL_ENTITLEMENTS ) ) ,
74+ seats : z . number ( ) . int ( ) . nonnegative ( ) ,
75+ issuedAt : z . string ( ) . datetime ( ) ,
76+ expiresAt : z . string ( ) . datetime ( ) ,
77+ } ) . strict ( ) ;
78+
79+ export type OnlineLicenseAssertionPayload = z . infer < typeof onlineLicenseAssertionPayloadSchema > ;
80+
81+ /**
82+ * Verifies and decodes an online-license assertion. The signature covers the
83+ * encoded payload itself, avoiding cross-language JSON canonicalization.
84+ */
85+ export const verifyOnlineLicenseAssertion = ( assertion : string ) : OnlineLicenseAssertionPayload | null => {
86+ try {
87+ const parts = assertion . split ( '.' ) ;
88+ if ( parts . length !== 2 ) {
89+ return null ;
90+ }
91+
92+ const [ encodedPayload , signature ] = parts ;
93+ if ( ! encodedPayload || ! signature ) {
94+ return null ;
95+ }
96+
97+ if ( ! verifySignature ( encodedPayload , signature , env . SOURCEBOT_PUBLIC_KEY_PATH ) ) {
98+ logger . error ( 'Online license assertion signature verification failed' ) ;
99+ return null ;
100+ }
101+
102+ const decodedPayload = Buffer . from ( encodedPayload , 'base64url' ) . toString ( 'utf8' ) ;
103+ const payload = onlineLicenseAssertionPayloadSchema . parse ( JSON . parse ( decodedPayload ) ) ;
104+ const issuedAt = new Date ( payload . issuedAt ) . getTime ( ) ;
105+ const expiresAt = new Date ( payload . expiresAt ) . getTime ( ) ;
106+ const now = Date . now ( ) ;
107+
108+ if (
109+ payload . installId !== env . SOURCEBOT_INSTALL_ID ||
110+ issuedAt > now + ONLINE_LICENSE_ASSERTION_CLOCK_SKEW_MS ||
111+ expiresAt <= now ||
112+ expiresAt <= issuedAt ||
113+ ( expiresAt - issuedAt ) > STALE_ONLINE_LICENSE_THRESHOLD_MS
114+ ) {
115+ logger . error ( 'Online license assertion claims are invalid' ) ;
116+ return null ;
117+ }
118+
119+ return payload ;
120+ } catch ( error ) {
121+ logger . error ( `Failed to verify online license assertion: ${ error } ` ) ;
122+ return null ;
123+ }
124+ } ;
125+
50126const decodeOfflineLicenseKeyPayload = ( payload : string ) : getValidOfflineLicense | null => {
51127 try {
52128 const decodedPayload = base64Decode ( payload ) ;
@@ -114,7 +190,9 @@ export const STALE_ONLINE_LICENSE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;
114190// so the warning has a chance to fire before entitlements are stripped.
115191export const STALE_ONLINE_LICENSE_WARNING_THRESHOLD_MS = 48 * 60 * 60 * 1000 ;
116192
117- const getValidOnlineLicense = ( _license : License | null ) : License | null => {
193+ type ValidOnlineLicense = Pick < OnlineLicenseAssertionPayload , 'entitlements' | 'status' > ;
194+
195+ const getValidLegacyOnlineLicense = ( _license : License | null ) : ValidOnlineLicense | null => {
118196 if (
119197 _license &&
120198 _license . status &&
@@ -123,7 +201,32 @@ const getValidOnlineLicense = (_license: License | null): License | null => {
123201 ( Date . now ( ) - _license . lastSyncAt . getTime ( ) ) <= STALE_ONLINE_LICENSE_THRESHOLD_MS &&
124202 _license . lastSyncErrorCode !== 'ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE'
125203 ) {
126- return _license ;
204+ return {
205+ entitlements : _license . entitlements as Entitlement [ ] ,
206+ status : _license . status as LicenseStatus ,
207+ } ;
208+ }
209+
210+ return null ;
211+ }
212+
213+ const getValidOnlineLicense = ( _license : License | null ) : ValidOnlineLicense | null => {
214+ // A present but invalid assertion must never fall back to unsigned columns.
215+ if ( _license ?. licenseAssertion !== null && _license ?. licenseAssertion !== undefined ) {
216+ if ( _license . lastSyncErrorCode === 'ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE' ) {
217+ return null ;
218+ }
219+
220+ const assertion = verifyOnlineLicenseAssertion ( _license . licenseAssertion ) ;
221+ if ( assertion && ACTIVE_ONLINE_LICENSE_STATUSES . includes ( assertion . status ) ) {
222+ return assertion ;
223+ }
224+
225+ return null ;
226+ }
227+
228+ if ( ALLOW_LEGACY_UNSIGNED_ONLINE_LICENSES ) {
229+ return getValidLegacyOnlineLicense ( _license ) ;
127230 }
128231
129232 return null ;
@@ -208,4 +311,4 @@ export const getSeatCap = (): number | undefined => {
208311 }
209312
210313 return undefined ;
211- }
314+ }
0 commit comments