Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/devextreme/js/__internal/core/license/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,7 @@ export const KEY_SPLITTER = '.';
export const BUY_NOW_LINK = 'https://go.devexpress.com/Licensing_Installer_Watermark_DevExtremeJQuery.aspx';
export const LICENSING_DOC_LINK = 'https://go.devexpress.com/Licensing_Documentation_DevExtremeJQuery.aspx';

export const LICENSE_KEY_PLACEHOLDER = '/* ___$$$$$___devextreme___lcp___placeholder____$$$$$ */';

export const NBSP = '\u00A0';
export const SUBSCRIPTION_NAMES = `Universal, DXperience, ASP.NET${NBSP}and${NBSP}Blazor, DevExtreme${NBSP}Complete`;
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { describe, expect, it } from '@jest/globals';
/* eslint-disable */

import {
describe,
expect,
it,
jest,
} from '@jest/globals';
import { version as currentVersion } from '@js/core/version';

import { parseVersion } from '../../../utils/version';
Expand All @@ -7,6 +14,38 @@ import { parseDevExpressProductKey } from './lcp_key_validator';
import { findLatestDevExtremeVersion, isLicenseValid } from './license_info';
import { createProductInfo } from './product_info';

const DOT_NET_TICKS_EPOCH_OFFSET = 621355968000000000n;
const DOT_NET_TICKS_PER_MS = 10000n;
const DEVEXTREME_HTML_JS_BIT = 1n << 54n;

function msToDotNetTicks(ms: number): string {
return (BigInt(ms) * DOT_NET_TICKS_PER_MS + DOT_NET_TICKS_EPOCH_OFFSET).toString();
}

function createLcpSource(payload: string): string {
const signature = 'A'.repeat(136);
return `LCPv1${btoa(`${signature}${payload}`)}`;
}

function loadParserWithBypassedSignatureCheck() {
jest.resetModules();
jest.doMock('./utils', () => {
const actual = jest.requireActual('./utils') as Record<string, unknown>;
return {
...actual,
encodeString: (text: string) => text,
shiftDecodeText: (text: string) => text,
verifyHash: () => true,
};
});

// eslint-disable-next-line
const { parseDevExpressProductKey } = require('./lcp_key_validator');
// eslint-disable-next-line
const { TokenKind } = require('../types');
return { parseDevExpressProductKey, TokenKind };
}

function getTrialLicense() {
const { major, minor } = parseVersion(currentVersion);
const products = [
Expand Down Expand Up @@ -34,4 +73,27 @@ describe('LCP key validation', () => {

expect(version).toBe(undefined);
});

it('does not classify a valid DevExtreme product key as trial-expired when expiration metadata is in the past', () => {
const { parseDevExpressProductKey, TokenKind } = loadParserWithBypassedSignatureCheck();
const expiredAt = msToDotNetTicks(Date.UTC(2020, 0, 1));

const payload = `meta;251,${DEVEXTREME_HTML_JS_BIT},0,${expiredAt};`;
const token = parseDevExpressProductKey(createLcpSource(payload));

expect(token.kind).toBe(TokenKind.verified);
});

it('returns trial-expired for expired trial keys without DevExtreme product access', () => {
const { parseDevExpressProductKey, TokenKind } = loadParserWithBypassedSignatureCheck();
const expiredAt = msToDotNetTicks(Date.UTC(2020, 0, 1));

const payload = `meta;251,0,0,${expiredAt};`;
const token = parseDevExpressProductKey(createLcpSource(payload));

expect(token.kind).toBe(TokenKind.corrupted);
if (token.kind === TokenKind.corrupted) {
expect(token.error).toBe('trial-expired');
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,22 @@ import {
PRODUCT_KIND_ERROR,
type Token,
TokenKind,
TRIAL_EXPIRED_ERROR,
VERIFICATION_ERROR,
} from '../types';
import {
LCP_SIGNATURE,
RSA_PUBLIC_KEY_XML,
SIGN_LENGTH,
} from './const';
import { findLatestDevExtremeVersion } from './license_info';
import { findLatestDevExtremeVersion, getMaxExpiration } from './license_info';
import { createProductInfo, type ProductInfo } from './product_info';
import { encodeString, shiftDecodeText, verifyHash } from './utils';
import {
dotNetTicksToMs,
encodeString,
shiftDecodeText,
verifyHash,
} from './utils';

interface ParsedProducts {
products: ProductInfo[];
Expand All @@ -41,9 +47,11 @@ function productsFromString(encodedString: string): ParsedProducts {
const parts = tuple.split(',');
const version = Number.parseInt(parts[0], 10);
const productsValue = BigInt(parts[1]);
const expiration = parts.length > 3 ? dotNetTicksToMs(parts[3]) : Infinity;
return createProductInfo(
version,
productsValue,
expiration,
);
});

Expand Down Expand Up @@ -88,6 +96,13 @@ export function parseDevExpressProductKey(productsLicenseSource: string): Token

const maxVersionAllowed = findLatestDevExtremeVersion({ products });

if (!maxVersionAllowed) {
const maxExpiration = getMaxExpiration({ products });
if (maxExpiration !== Infinity && maxExpiration < Date.now()) {
return TRIAL_EXPIRED_ERROR;
}
}

if (!maxVersionAllowed) {
return PRODUCT_KIND_ERROR;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ export function isLicenseValid(info: LicenseInfo): boolean {
return Array.isArray(info.products) && info.products.length > 0;
}

export function getMaxExpiration(info: LicenseInfo): number {
const expirationDates = info.products
.map((p) => p.expiration)
.filter((e) => e > 0 && e !== Infinity);
if (expirationDates.length === 0) return Infinity;
return Math.max(...expirationDates);
}

export function findLatestDevExtremeVersion(info: LicenseInfo): number | undefined {
if (!isLicenseValid(info)) {
return undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@
export interface ProductInfo {
readonly version: number;
readonly products: bigint;
readonly expiration: number;
}

export function createProductInfo(version: number, products: bigint): ProductInfo {
return { version, products: BigInt(products) };
export function createProductInfo(
version: number,
products: bigint,
expiration = Infinity,
): ProductInfo {
return { version, products: BigInt(products), expiration };
}

export function isProduct(info: ProductInfo, ...productIds: bigint[]): boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ export const shiftText = (text: string, map: string): string => {

export const shiftDecodeText = (text: string): string => shiftText(text, DECODE_MAP);

const DOT_NET_TICKS_EPOCH_OFFSET = 621355968000000000n;
const DOT_NET_TICKS_PER_MS = 10000n;
const DOT_NET_MAX_VALUE_TICKS = 3155378975999999999n;

export function dotNetTicksToMs(ticksStr: string): number {
const ticks = BigInt(ticksStr);
if (ticks >= DOT_NET_MAX_VALUE_TICKS) return Infinity;
return Number((ticks - DOT_NET_TICKS_EPOCH_OFFSET) / DOT_NET_TICKS_PER_MS);
}

export const verifyHash = (xmlKey: string, data: string, signature: string): boolean => {
const { modulus, exponent } = parseRsaXml(xmlKey);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
assertedVersionsCompatible,
clearAssertedVersions,
} from '../../utils/version';
import { LICENSE_KEY_PLACEHOLDER } from './const';
import {
parseLicenseKey,
setLicenseCheckSkipCondition,
Expand Down Expand Up @@ -294,9 +295,11 @@ describe('license check', () => {
const TOKEN_UNSUPPORTED_VERSION = 'ewogICJmb3JtYXQiOiAyLAogICJjdXN0b21lcklkIjogImIxMTQwYjQ2LWZkZTEtNDFiZC1hMjgwLTRkYjlmOGU3ZDliZCIsCiAgIm1heFZlcnNpb25BbGxvd2VkIjogMjMxCn0=.tTBymZMROsYyMiP6ldXFqGurbzqjhSQIu/pjyEUJA3v/57VgToomYl7FVzBj1asgHpadvysyTUiX3nFvPxbp166L3+LB3Jybw9ueMnwePu5vQOO0krqKLBqRq+TqHKn7k76uYRbkCIo5UajNfzetHhlkin3dJf3x2K/fcwbPW5A=';

let trialPanelSpy = jest.spyOn(trialPanel, 'renderTrialPanel');
let consoleWarnSpy = jest.spyOn(console, 'warn');

beforeEach(() => {
jest.spyOn(errors, 'log').mockImplementation(() => {});
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
trialPanelSpy = jest.spyOn(trialPanel, 'renderTrialPanel');
setLicenseCheckSkipCondition(false);
});
Expand All @@ -309,10 +312,11 @@ describe('license check', () => {
{ token: '', version: '1.0.3' },
{ token: null, version: '1.0.4' },
{ token: undefined, version: '1.0.50' },
])('W0019 error should be logged if license is empty', ({ token, version }) => {
{ token: LICENSE_KEY_PLACEHOLDER, version: '1.0.3' },
])('Warning should be logged with no-key message if license is empty', ({ token, version }) => {
validateLicense(token as string, version);
expect(errors.log).toHaveBeenCalledTimes(1);
expect(errors.log).toHaveBeenCalledWith('W0019');
expect(consoleWarnSpy).toHaveBeenCalledTimes(2);
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('devextreme-license generated key has not been specified'));
});

test.each([
Expand All @@ -322,52 +326,29 @@ describe('license check', () => {
{ token: '', version: '1.0.0' },
{ token: null, version: '1.2.4-preview' },
{ token: undefined, version: '1.2' },
{ token: LICENSE_KEY_PLACEHOLDER, version: '1.0.3' },
{ token: LICENSE_KEY_PLACEHOLDER, version: '1.0.0' },
])('trial panel should be displayed if license is empty, preview or not', ({ token, version }) => {
validateLicense(token as string, version);
expect(trialPanelSpy).toHaveBeenCalledTimes(1);
});

test.each([
{ token: '', version: '1.0' },
{ token: null, version: '1.0.' },
{ token: undefined, version: '1.0.0' },
{ token: TOKEN_23_1, version: '23.1.0' },
{ token: TOKEN_23_1, version: '12.3.1' },
{ token: TOKEN_23_2, version: '23.1.2' },
{ token: TOKEN_23_2, version: '23.2.3-preview' },
{ token: TOKEN_23_1, version: '23.2.0' },
{ token: TOKEN_23_2, version: '42.4.3-alfa' },
{ token: TOKEN_UNVERIFIED, version: '1.2.0' },
{ token: TOKEN_INVALID_JSON, version: '1.2.1' },
{ token: TOKEN_INVALID_BASE64, version: '1.2.2' },
{ token: TOKEN_MISSING_FIELD_1, version: '1.2' },
{ token: TOKEN_MISSING_FIELD_2, version: '1.2.4-preview' },
{ token: TOKEN_MISSING_FIELD_3, version: '1.2.' },
{ token: TOKEN_UNSUPPORTED_VERSION, version: '1.2.abc' },
{ token: 'Another', version: '1.2.0' },
{ token: '3.2.1', version: '1.2.1' },
{ token: TOKEN_23_1, version: '123' },
])('W0022 error should be logged if version is preview [%#]', ({ token, version }) => {
validateLicense(token as string, version);
expect(errors.log).toHaveBeenCalledWith('W0022');
});

test.each([
{ token: TOKEN_23_1, version: '23.1.3' },
{ token: TOKEN_23_1, version: '12.3.4' },
{ token: TOKEN_23_2, version: '23.1.5' },
{ token: TOKEN_23_2, version: '23.2.6' },
])('No messages should be logged if license is valid', ({ token, version }) => {
])('Old format license within version range should not trigger warnings', ({ token, version }) => {
validateLicense(token, version);
expect(errors.log).not.toHaveBeenCalled();
expect(consoleWarnSpy).not.toHaveBeenCalled();
});

test.each([
{ token: TOKEN_23_1, version: '23.1.3' },
{ token: TOKEN_23_1, version: '12.3.4' },
{ token: TOKEN_23_2, version: '23.1.5' },
{ token: TOKEN_23_2, version: '23.2.6' },
])('Trial panel should not be displayed if license is valid', ({ token, version }) => {
])('Trial panel should not be displayed for valid old format license keys', ({ token, version }) => {
validateLicense(token, version);
expect(trialPanelSpy).not.toHaveBeenCalled();
});
Expand All @@ -388,7 +369,7 @@ describe('license check', () => {
validateLicense('', '1.0');
validateLicense('', '1.0');

expect(errors.log).toHaveBeenCalledTimes(1);
expect(consoleWarnSpy).toHaveBeenCalledTimes(2);
});

test('Base z-index should match the corresponding setting in DevExtreme', () => {
Expand All @@ -399,15 +380,16 @@ describe('license check', () => {
setLicenseCheckSkipCondition();
validateLicense('', '1.0');
expect(errors.log).not.toHaveBeenCalled();
expect(consoleWarnSpy).not.toHaveBeenCalled();
});

test.each([
{ token: TOKEN_23_1, version: '23.2.3' },
{ token: TOKEN_23_2, version: '42.4.5' },
])('W0020 error should be logged if license is outdated', ({ token, version }) => {
])('Old format license should trigger version-mismatch warning when outdated', ({ token, version }) => {
validateLicense(token, version);
expect(errors.log).toHaveBeenCalledTimes(1);
expect(errors.log).toHaveBeenCalledWith('W0020');
expect(consoleWarnSpy).toHaveBeenCalledTimes(2);
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Incompatible DevExpress license key version'));
});

test.each([
Expand All @@ -425,7 +407,7 @@ describe('license check', () => {
{ token: TOKEN_23_1, version: '23.2.3-alpha' },
{ token: TOKEN_23_2, version: '24.1.0' },
{ token: TOKEN_23_2, version: '24.1.abc' },
])('Trial panel should not be displayed in previews if the license is for the previous RTM', ({ token, version }) => {
])('Trial panel should not be displayed in previews for valid old format license keys', ({ token, version }) => {
validateLicense(token, version);
expect(trialPanelSpy).not.toHaveBeenCalled();
});
Expand All @@ -440,9 +422,10 @@ describe('license check', () => {
{ token: TOKEN_UNSUPPORTED_VERSION, version: '1.2.3' },
{ token: 'str@nge in.put', version: '1.2.3' },
{ token: '3.2.1', version: '1.2.3' },
])('W0021 error should be logged if license is corrupted/invalid [%#]', ({ token, version }) => {
])('License verification warning should be logged if license is corrupted/invalid [%#]', ({ token, version }) => {
validateLicense(token, version);
expect(errors.log).toHaveBeenCalledWith('W0021');
expect(consoleWarnSpy).toHaveBeenCalledTimes(2);
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('License key verification has failed'));
});

test.each([
Expand Down Expand Up @@ -503,20 +486,15 @@ describe('internal license check', () => {
expect(errors.log).toHaveBeenCalledWith('W0020');
expect(trialPanelSpy).not.toHaveBeenCalled();
});

test('internal usage token (incorrect, pre-release)', () => {
const token = 'ewogICJpbnRlcm5hbFVzYWdlSWQiOiAiUDdmNU5icU9WMDZYRFVpa3Q1bkRyQSIsCiAgImZvcm1hdCI6IDEKfQ==.ox52WAqudazQ0ZKdnJqvh/RmNNNX+IB9cmun97irvSeZK2JMf9sbBXC1YCrSZNIPBjQapyIV8Ctv9z2wzb3BkWy+R9CEh+ev7purq7Lk0ugpwDye6GaCzqlDg+58EHwPCNaasIuBiQC3ztvOItrGwWSu0aEFooiajk9uAWwzWeM=';
validateLicense(token, '1.2.1');
expect(errors.log).toHaveBeenCalledWith('W0022');
expect(trialPanelSpy).not.toHaveBeenCalled();
});
});

describe('DevExpress license check', () => {
let trialPanelSpy = jest.spyOn(trialPanel, 'renderTrialPanel');
let consoleWarnSpy = jest.spyOn(console, 'warn');

beforeEach(() => {
jest.spyOn(errors, 'log').mockImplementation(() => {});
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
trialPanelSpy = jest.spyOn(trialPanel, 'renderTrialPanel');
setLicenseCheckSkipCondition(false);
});
Expand All @@ -528,14 +506,16 @@ describe('DevExpress license check', () => {
test('DevExpress License Key copied from Download Manager (incorrect)', () => {
const token = 'LCXv1therestofthekey';
validateLicense(token, '25.1.3');
expect(errors.log).toHaveBeenCalled();
expect(consoleWarnSpy).toHaveBeenCalledTimes(2);
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('DevExpress license key has been specified instead of a key generated using devextreme-license'));
expect(trialPanelSpy).toHaveBeenCalled();
});

test('DevExpress License Key generated from LCX key (incorrect)', () => {
const token = 'LCPtherestofthekey';
validateLicense(token, '25.1.3');
expect(errors.log).toHaveBeenCalled();
expect(consoleWarnSpy).toHaveBeenCalledTimes(2);
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('License key verification has failed'));
expect(trialPanelSpy).toHaveBeenCalled();
});
});
Expand Down
Loading
Loading