Skip to content

Commit 18850da

Browse files
authored
License: Add new licensing mechanism (#33206)
1 parent ace5db9 commit 18850da

28 files changed

Lines changed: 1899 additions & 249 deletions

packages/devextreme/build/gulp/npm.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,11 @@ const sources = (src, dist, distGlob) => (() => merge(
112112
.pipe(eol('\n'))
113113
.pipe(gulp.dest(`${dist}/bin`)),
114114

115+
gulp
116+
.src(['license/**'])
117+
.pipe(eol('\n'))
118+
.pipe(gulp.dest(`${dist}/license`)),
119+
115120
gulp
116121
.src('webpack.config.js')
117122
.pipe(gulp.dest(`${dist}/bin`)),
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
#!/usr/bin/env node
2+
'use strict';
3+
4+
require('../license/devextreme-license');

packages/devextreme/eslint.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export default [
3434
'js/viz/docs/*',
3535
'node_modules/*',
3636
'build/*',
37+
'license/*',
3738
'**/*.j.tsx',
3839
'playground/*',
3940
'themebuilder/data/metadata/*',

packages/devextreme/js/__internal/core/license/byte_utils.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,16 @@ export function leftRotate(x: number, n: number): number {
4848
return ((x << n) | (x >>> (32 - n))) >>> 0;
4949
}
5050

51+
export function bigIntFromBytes(bytes: Uint8Array): bigint {
52+
const eight = BigInt(8);
53+
const zero = BigInt(0);
54+
55+
return bytes.reduce(
56+
(acc, cur) => (acc << eight) + BigInt(cur),
57+
zero,
58+
);
59+
}
60+
5161
export function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
5262
const result = new Uint8Array(a.length + b.length);
5363
result.set(a, 0);
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export const FORMAT = 1;
2+
export const RTM_MIN_PATCH_VERSION = 3;
3+
export const KEY_SPLITTER = '.';
4+
5+
export const BUY_NOW_LINK = 'https://go.devexpress.com/Licensing_Installer_Watermark_DevExtremeJQuery.aspx';
6+
export const LICENSING_DOC_LINK = 'https://go.devexpress.com/Licensing_Documentation_DevExtremeJQuery.aspx';
7+
8+
export const LICENSE_KEY_PLACEHOLDER = '/* ___$$$$$___devextreme___lcp___placeholder____$$$$$ */';
9+
10+
export const NBSP = '\u00A0';
11+
export const SUBSCRIPTION_NAMES = `Universal, DXperience, ASP.NET${NBSP}and${NBSP}Blazor, DevExtreme${NBSP}Complete`;

packages/devextreme/js/__internal/core/license/key.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,3 @@ export const PUBLIC_KEY: PublicKey = {
1313
230, 44, 247, 200, 253, 170, 192, 246, 30, 12, 96, 205, 100, 249, 181, 93, 0, 231,
1414
]),
1515
};
16-
17-
export const INTERNAL_USAGE_ID = 'V2QpQmJVXWy6Nexkq9Xk9o';
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export const LCP_SIGNATURE = 'LCPv1';
2+
export const SIGN_LENGTH = 68 * 2; // 136 characters
3+
export const DECODE_MAP = '\u0000\u0001\u0002\u0003\u0004\u0005\u0006\u0007\u0008\u0009\u000a\u000b\u000c\u000d\u000e\u000f\u0010\u0011\u0012\u0013\u0014\u0015\u0016\u0017\u0018\u0019\u001a\u001b\u001c\u001d\u001e\u001f\u0020R\u0022f6U`\'aA7Fdp,?#yeYx[KWwQMqk^T+5&r/8ItLDb2C0;H._ElZ@*N>ojOv\u005c$]m)JncBVsi<XGP=93zS%g:h(u-!14{|}~';
4+
export const RSA_PUBLIC_KEY_XML = '<RSAKeyValue><Modulus>94ACmndawR6kB4PEJnXBBrz5Dn8ekEf5IvL7ro5ZvOyLVDiRwZXYR2uF8tFUSYjS5v7kOg74lfpZqfPXof7kcZwV3ENuy3tB7rqPBZaAqTMp5nBsZOc2H7MgDBXzrSdd4hzASQ==</Modulus><Exponent>AQAB</Exponent></RSAKeyValue>';
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/* eslint-disable */
2+
3+
import {
4+
describe,
5+
expect,
6+
it,
7+
jest,
8+
} from '@jest/globals';
9+
import { version as currentVersion } from '@js/core/version';
10+
11+
import { parseVersion } from '../../../utils/version';
12+
import { TokenKind } from '../types';
13+
import { parseDevExpressProductKey } from './lcp_key_validator';
14+
import { findLatestDevExtremeVersion, isLicenseValid } from './license_info';
15+
import { createProductInfo } from './product_info';
16+
17+
const DOT_NET_TICKS_EPOCH_OFFSET = 621355968000000000n;
18+
const DOT_NET_TICKS_PER_MS = 10000n;
19+
const DEVEXTREME_HTML_JS_BIT = 1n << 54n;
20+
21+
function msToDotNetTicks(ms: number): string {
22+
return (BigInt(ms) * DOT_NET_TICKS_PER_MS + DOT_NET_TICKS_EPOCH_OFFSET).toString();
23+
}
24+
25+
function createLcpSource(payload: string): string {
26+
const signature = 'A'.repeat(136);
27+
return `LCPv1${btoa(`${signature}${payload}`)}`;
28+
}
29+
30+
function loadParserWithBypassedSignatureCheck() {
31+
jest.resetModules();
32+
jest.doMock('./utils', () => {
33+
const actual = jest.requireActual('./utils') as Record<string, unknown>;
34+
return {
35+
...actual,
36+
encodeString: (text: string) => text,
37+
shiftDecodeText: (text: string) => text,
38+
verifyHash: () => true,
39+
};
40+
});
41+
42+
// eslint-disable-next-line
43+
const { parseDevExpressProductKey } = require('./lcp_key_validator');
44+
// eslint-disable-next-line
45+
const { TokenKind } = require('../types');
46+
return { parseDevExpressProductKey, TokenKind };
47+
}
48+
49+
function getTrialLicense() {
50+
const { major, minor } = parseVersion(currentVersion);
51+
const products = [
52+
createProductInfo(parseInt(`${major}${minor}`, 10), 0n),
53+
];
54+
return { products };
55+
}
56+
57+
describe('LCP key validation', () => {
58+
it('serializer returns an invalid license for malformed input', () => {
59+
const token = parseDevExpressProductKey('not-a-real-license');
60+
expect(token.kind).toBe(TokenKind.corrupted);
61+
});
62+
63+
(process.env.DX_PRODUCT_KEY ? it : it.skip)('developer product license fixtures parse into valid LicenseInfo instances', () => {
64+
const token = parseDevExpressProductKey(process.env.DX_PRODUCT_KEY as string);
65+
expect(token.kind).toBe(TokenKind.verified);
66+
});
67+
68+
it('trial fallback does not grant product access', () => {
69+
const trialLicense = getTrialLicense();
70+
expect(isLicenseValid(trialLicense)).toBe(true);
71+
72+
const version = findLatestDevExtremeVersion(trialLicense);
73+
74+
expect(version).toBe(undefined);
75+
});
76+
77+
it('does not classify a valid DevExtreme product key as trial-expired when expiration metadata is in the past', () => {
78+
const { parseDevExpressProductKey, TokenKind } = loadParserWithBypassedSignatureCheck();
79+
const expiredAt = msToDotNetTicks(Date.UTC(2020, 0, 1));
80+
81+
const payload = `meta;251,${DEVEXTREME_HTML_JS_BIT},0,${expiredAt};`;
82+
const token = parseDevExpressProductKey(createLcpSource(payload));
83+
84+
expect(token.kind).toBe(TokenKind.verified);
85+
});
86+
87+
it('returns trial-expired for expired trial keys without DevExtreme product access', () => {
88+
const { parseDevExpressProductKey, TokenKind } = loadParserWithBypassedSignatureCheck();
89+
const expiredAt = msToDotNetTicks(Date.UTC(2020, 0, 1));
90+
91+
const payload = `meta;251,0,0,${expiredAt};`;
92+
const token = parseDevExpressProductKey(createLcpSource(payload));
93+
94+
expect(token.kind).toBe(TokenKind.corrupted);
95+
if (token.kind === TokenKind.corrupted) {
96+
expect(token.error).toBe('trial-expired');
97+
}
98+
});
99+
});
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { FORMAT } from '../const';
2+
import {
3+
DESERIALIZATION_ERROR,
4+
type ErrorToken,
5+
GENERAL_ERROR,
6+
PRODUCT_KIND_ERROR,
7+
type Token,
8+
TokenKind,
9+
TRIAL_EXPIRED_ERROR,
10+
VERIFICATION_ERROR,
11+
} from '../types';
12+
import {
13+
LCP_SIGNATURE,
14+
RSA_PUBLIC_KEY_XML,
15+
SIGN_LENGTH,
16+
} from './const';
17+
import { findLatestDevExtremeVersion, getMaxExpiration } from './license_info';
18+
import { createProductInfo, type ProductInfo } from './product_info';
19+
import {
20+
dotNetTicksToMs,
21+
encodeString,
22+
shiftDecodeText,
23+
verifyHash,
24+
} from './utils';
25+
26+
interface ParsedProducts {
27+
products: ProductInfo[];
28+
errorToken?: ErrorToken;
29+
}
30+
31+
export function isProductOnlyLicense(license: string): boolean {
32+
return typeof license === 'string' && license.startsWith(LCP_SIGNATURE);
33+
}
34+
35+
function productsFromString(encodedString: string): ParsedProducts {
36+
if (!encodedString) {
37+
return {
38+
products: [],
39+
errorToken: GENERAL_ERROR,
40+
};
41+
}
42+
43+
try {
44+
const splitInfo = encodedString.split(';');
45+
const productTuples = splitInfo.slice(1).filter((entry) => entry.length > 0);
46+
const products = productTuples.map((tuple) => {
47+
const parts = tuple.split(',');
48+
const version = Number.parseInt(parts[0], 10);
49+
const productsValue = BigInt(parts[1]);
50+
const expiration = parts.length > 3 ? dotNetTicksToMs(parts[3]) : Infinity;
51+
return createProductInfo(
52+
version,
53+
productsValue,
54+
expiration,
55+
);
56+
});
57+
58+
return {
59+
products,
60+
};
61+
} catch (error) {
62+
return {
63+
products: [],
64+
errorToken: DESERIALIZATION_ERROR,
65+
};
66+
}
67+
}
68+
69+
export function parseDevExpressProductKey(productsLicenseSource: string): Token {
70+
if (!isProductOnlyLicense(productsLicenseSource)) {
71+
return GENERAL_ERROR;
72+
}
73+
74+
try {
75+
const productsLicense = atob(
76+
shiftDecodeText(productsLicenseSource.substring(LCP_SIGNATURE.length)),
77+
);
78+
79+
const signature = productsLicense.substring(0, SIGN_LENGTH);
80+
const productsPayload = productsLicense.substring(SIGN_LENGTH);
81+
82+
if (!verifyHash(RSA_PUBLIC_KEY_XML, productsPayload, signature)) {
83+
return VERIFICATION_ERROR;
84+
}
85+
86+
const {
87+
products,
88+
errorToken,
89+
} = productsFromString(
90+
encodeString(productsPayload, shiftDecodeText),
91+
);
92+
93+
if (errorToken) {
94+
return errorToken;
95+
}
96+
97+
const maxVersionAllowed = findLatestDevExtremeVersion({ products });
98+
99+
if (!maxVersionAllowed) {
100+
const maxExpiration = getMaxExpiration({ products });
101+
if (maxExpiration !== Infinity && maxExpiration < Date.now()) {
102+
return TRIAL_EXPIRED_ERROR;
103+
}
104+
}
105+
106+
if (!maxVersionAllowed) {
107+
return PRODUCT_KIND_ERROR;
108+
}
109+
110+
return {
111+
kind: TokenKind.verified,
112+
payload: {
113+
customerId: '',
114+
maxVersionAllowed,
115+
format: FORMAT,
116+
},
117+
};
118+
} catch (error) {
119+
return GENERAL_ERROR;
120+
}
121+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { isProduct, type ProductInfo } from './product_info';
2+
import { ProductKind } from './types';
3+
4+
export interface LicenseInfo {
5+
readonly products: ProductInfo[];
6+
}
7+
8+
export function isLicenseValid(info: LicenseInfo): boolean {
9+
return Array.isArray(info.products) && info.products.length > 0;
10+
}
11+
12+
export function getMaxExpiration(info: LicenseInfo): number {
13+
const expirationDates = info.products
14+
.map((p) => p.expiration)
15+
.filter((e) => e > 0 && e !== Infinity);
16+
if (expirationDates.length === 0) return Infinity;
17+
return Math.max(...expirationDates);
18+
}
19+
20+
export function findLatestDevExtremeVersion(info: LicenseInfo): number | undefined {
21+
if (!isLicenseValid(info)) {
22+
return undefined;
23+
}
24+
25+
const sorted = [...info.products].sort((a, b) => b.version - a.version);
26+
27+
return sorted.find((p) => isProduct(p, ProductKind.DevExtremeHtmlJs))?.version;
28+
}

0 commit comments

Comments
 (0)