diff --git a/packages/devextreme/build/gulp/npm.js b/packages/devextreme/build/gulp/npm.js index f256edc83ed5..63d0c7b4131f 100644 --- a/packages/devextreme/build/gulp/npm.js +++ b/packages/devextreme/build/gulp/npm.js @@ -112,6 +112,11 @@ const sources = (src, dist, distGlob) => (() => merge( .pipe(eol('\n')) .pipe(gulp.dest(`${dist}/bin`)), + gulp + .src(['license/**']) + .pipe(eol('\n')) + .pipe(gulp.dest(`${dist}/license`)), + gulp .src('webpack.config.js') .pipe(gulp.dest(`${dist}/bin`)), diff --git a/packages/devextreme/build/npm-bin/devextreme-license.js b/packages/devextreme/build/npm-bin/devextreme-license.js new file mode 100644 index 000000000000..146a83b3c95c --- /dev/null +++ b/packages/devextreme/build/npm-bin/devextreme-license.js @@ -0,0 +1,4 @@ +#!/usr/bin/env node +'use strict'; + +require('../license/devextreme-license'); diff --git a/packages/devextreme/eslint.config.mjs b/packages/devextreme/eslint.config.mjs index 7922693e11fd..01944618ad23 100644 --- a/packages/devextreme/eslint.config.mjs +++ b/packages/devextreme/eslint.config.mjs @@ -34,6 +34,7 @@ export default [ 'js/viz/docs/*', 'node_modules/*', 'build/*', + 'license/*', '**/*.j.tsx', 'playground/*', 'themebuilder/data/metadata/*', diff --git a/packages/devextreme/js/__internal/core/license/byte_utils.ts b/packages/devextreme/js/__internal/core/license/byte_utils.ts index d35693361dbf..8c34b0316984 100644 --- a/packages/devextreme/js/__internal/core/license/byte_utils.ts +++ b/packages/devextreme/js/__internal/core/license/byte_utils.ts @@ -48,6 +48,16 @@ export function leftRotate(x: number, n: number): number { return ((x << n) | (x >>> (32 - n))) >>> 0; } +export function bigIntFromBytes(bytes: Uint8Array): bigint { + const eight = BigInt(8); + const zero = BigInt(0); + + return bytes.reduce( + (acc, cur) => (acc << eight) + BigInt(cur), + zero, + ); +} + export function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array { const result = new Uint8Array(a.length + b.length); result.set(a, 0); diff --git a/packages/devextreme/js/__internal/core/license/const.ts b/packages/devextreme/js/__internal/core/license/const.ts new file mode 100644 index 000000000000..18a3cd0247eb --- /dev/null +++ b/packages/devextreme/js/__internal/core/license/const.ts @@ -0,0 +1,11 @@ +export const FORMAT = 1; +export const RTM_MIN_PATCH_VERSION = 3; +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`; diff --git a/packages/devextreme/js/__internal/core/license/key.ts b/packages/devextreme/js/__internal/core/license/key.ts index 8d801fb02cdf..e93a65ecdca7 100644 --- a/packages/devextreme/js/__internal/core/license/key.ts +++ b/packages/devextreme/js/__internal/core/license/key.ts @@ -13,5 +13,3 @@ export const PUBLIC_KEY: PublicKey = { 230, 44, 247, 200, 253, 170, 192, 246, 30, 12, 96, 205, 100, 249, 181, 93, 0, 231, ]), }; - -export const INTERNAL_USAGE_ID = 'V2QpQmJVXWy6Nexkq9Xk9o'; diff --git a/packages/devextreme/js/__internal/core/license/lcp_key_validation/const.ts b/packages/devextreme/js/__internal/core/license/lcp_key_validation/const.ts new file mode 100644 index 000000000000..4b0700026051 --- /dev/null +++ b/packages/devextreme/js/__internal/core/license/lcp_key_validation/const.ts @@ -0,0 +1,4 @@ +export const LCP_SIGNATURE = 'LCPv1'; +export const SIGN_LENGTH = 68 * 2; // 136 characters +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 { + const actual = jest.requireActual('./utils') as Record; + 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 = [ + createProductInfo(parseInt(`${major}${minor}`, 10), 0n), + ]; + return { products }; +} + +describe('LCP key validation', () => { + it('serializer returns an invalid license for malformed input', () => { + const token = parseDevExpressProductKey('not-a-real-license'); + expect(token.kind).toBe(TokenKind.corrupted); + }); + + (process.env.DX_PRODUCT_KEY ? it : it.skip)('developer product license fixtures parse into valid LicenseInfo instances', () => { + const token = parseDevExpressProductKey(process.env.DX_PRODUCT_KEY as string); + expect(token.kind).toBe(TokenKind.verified); + }); + + it('trial fallback does not grant product access', () => { + const trialLicense = getTrialLicense(); + expect(isLicenseValid(trialLicense)).toBe(true); + + const version = findLatestDevExtremeVersion(trialLicense); + + 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'); + } + }); +}); diff --git a/packages/devextreme/js/__internal/core/license/lcp_key_validation/lcp_key_validator.ts b/packages/devextreme/js/__internal/core/license/lcp_key_validation/lcp_key_validator.ts new file mode 100644 index 000000000000..4c936be7d9d7 --- /dev/null +++ b/packages/devextreme/js/__internal/core/license/lcp_key_validation/lcp_key_validator.ts @@ -0,0 +1,121 @@ +import { FORMAT } from '../const'; +import { + DESERIALIZATION_ERROR, + type ErrorToken, + GENERAL_ERROR, + 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, getMaxExpiration } from './license_info'; +import { createProductInfo, type ProductInfo } from './product_info'; +import { + dotNetTicksToMs, + encodeString, + shiftDecodeText, + verifyHash, +} from './utils'; + +interface ParsedProducts { + products: ProductInfo[]; + errorToken?: ErrorToken; +} + +export function isProductOnlyLicense(license: string): boolean { + return typeof license === 'string' && license.startsWith(LCP_SIGNATURE); +} + +function productsFromString(encodedString: string): ParsedProducts { + if (!encodedString) { + return { + products: [], + errorToken: GENERAL_ERROR, + }; + } + + try { + const splitInfo = encodedString.split(';'); + const productTuples = splitInfo.slice(1).filter((entry) => entry.length > 0); + const products = productTuples.map((tuple) => { + 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, + ); + }); + + return { + products, + }; + } catch (error) { + return { + products: [], + errorToken: DESERIALIZATION_ERROR, + }; + } +} + +export function parseDevExpressProductKey(productsLicenseSource: string): Token { + if (!isProductOnlyLicense(productsLicenseSource)) { + return GENERAL_ERROR; + } + + try { + const productsLicense = atob( + shiftDecodeText(productsLicenseSource.substring(LCP_SIGNATURE.length)), + ); + + const signature = productsLicense.substring(0, SIGN_LENGTH); + const productsPayload = productsLicense.substring(SIGN_LENGTH); + + if (!verifyHash(RSA_PUBLIC_KEY_XML, productsPayload, signature)) { + return VERIFICATION_ERROR; + } + + const { + products, + errorToken, + } = productsFromString( + encodeString(productsPayload, shiftDecodeText), + ); + + if (errorToken) { + return errorToken; + } + + 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; + } + + return { + kind: TokenKind.verified, + payload: { + customerId: '', + maxVersionAllowed, + format: FORMAT, + }, + }; + } catch (error) { + return GENERAL_ERROR; + } +} diff --git a/packages/devextreme/js/__internal/core/license/lcp_key_validation/license_info.ts b/packages/devextreme/js/__internal/core/license/lcp_key_validation/license_info.ts new file mode 100644 index 000000000000..a526a3c61983 --- /dev/null +++ b/packages/devextreme/js/__internal/core/license/lcp_key_validation/license_info.ts @@ -0,0 +1,28 @@ +import { isProduct, type ProductInfo } from './product_info'; +import { ProductKind } from './types'; + +export interface LicenseInfo { + readonly products: ProductInfo[]; +} + +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; + } + + const sorted = [...info.products].sort((a, b) => b.version - a.version); + + return sorted.find((p) => isProduct(p, ProductKind.DevExtremeHtmlJs))?.version; +} diff --git a/packages/devextreme/js/__internal/core/license/lcp_key_validation/license_payload.test.ts b/packages/devextreme/js/__internal/core/license/lcp_key_validation/license_payload.test.ts new file mode 100644 index 000000000000..049004ff44db --- /dev/null +++ b/packages/devextreme/js/__internal/core/license/lcp_key_validation/license_payload.test.ts @@ -0,0 +1,298 @@ +/** + * Payload-level license tests — analogous to dxvcs LicenseTestHelperTests. + * + * These tests exercise ProductInfo / LicenseInfo directly + * (no full LCP key encoding / signature verification involved) + * so we can validate product-kind bit-flag logic in isolation. + */ +/* eslint-disable spellcheck/spell-checker, no-bitwise */ + +import { describe, expect, it } from '@jest/globals'; +import { version as currentVersion } from '@js/core/version'; + +import { parseVersion } from '../../../utils/version'; +import { findLatestDevExtremeVersion, isLicenseValid } from './license_info'; +import { createProductInfo, isProduct } from './product_info'; +import { ProductKind } from './types'; + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +/** Build a numeric version id that matches the current DevExtreme build (e.g. 251). */ +function currentVersionId(): number { + const { major, minor } = parseVersion(currentVersion); + return parseInt(`${major}${minor}`, 10); +} + +/** Shortcut: create a LicenseInfo with a single ProductInfo entry. */ +function makeLicense(products: bigint, version?: number) { + const v = version ?? currentVersionId(); + return { products: [createProductInfo(v, products)] }; +} + +// --------------------------------------------------------------------------- +// ProductInfo.isProduct +// --------------------------------------------------------------------------- + +describe('ProductInfo.isProduct – product-kind bit flags', () => { + it.each([ + { name: 'DXperienceASP', kind: ProductKind.DXperienceASP }, + { name: 'DXperienceWPF', kind: ProductKind.DXperienceWPF }, + { name: 'DXperienceWin', kind: ProductKind.DXperienceWin }, + { name: 'Blazor', kind: ProductKind.Blazor }, + { name: 'XAF', kind: ProductKind.XAF }, + { name: 'DevExtremeHtmlJs', kind: ProductKind.DevExtremeHtmlJs }, + { name: 'Dashboard', kind: ProductKind.Dashboard }, + { name: 'Docs', kind: ProductKind.Docs }, + { name: 'DocsBasic', kind: ProductKind.DocsBasic }, + { name: 'XtraReports', kind: ProductKind.XtraReports }, + ])('single flag $name is detected', ({ kind }) => { + const pi = createProductInfo(currentVersionId(), kind); + expect(isProduct(pi, kind)).toBe(true); + }); + + it('does not match a flag that was NOT set', () => { + const pi = createProductInfo(currentVersionId(), ProductKind.Blazor); + expect(isProduct(pi, ProductKind.Docs)).toBe(false); + expect(isProduct(pi, ProductKind.DXperienceWin)).toBe(false); + }); + + it('DXperienceUni includes every individual product', () => { + const pi = createProductInfo(currentVersionId(), ProductKind.DXperienceUni); + + expect(isProduct(pi, ProductKind.DXperienceWin)).toBe(true); + expect(isProduct(pi, ProductKind.DXperienceASP)).toBe(true); + expect(isProduct(pi, ProductKind.DXperienceWPF)).toBe(true); + expect(isProduct(pi, ProductKind.Blazor)).toBe(true); + expect(isProduct(pi, ProductKind.XAF)).toBe(true); + expect(isProduct(pi, ProductKind.Dashboard)).toBe(true); + expect(isProduct(pi, ProductKind.Docs)).toBe(true); + expect(isProduct(pi, ProductKind.DevExtremeHtmlJs)).toBe(true); + expect(isProduct(pi, ProductKind.XtraReports)).toBe(true); + }); + + it('DXperienceEnt includes its constituent products but not XAF/Dashboard/Docs', () => { + const pi = createProductInfo(currentVersionId(), ProductKind.DXperienceEnt); + + expect(isProduct(pi, ProductKind.DXperienceWin)).toBe(true); + expect(isProduct(pi, ProductKind.DXperienceASP)).toBe(true); + expect(isProduct(pi, ProductKind.DXperienceWPF)).toBe(true); + expect(isProduct(pi, ProductKind.Blazor)).toBe(true); + expect(isProduct(pi, ProductKind.DevExtremeHtmlJs)).toBe(true); + expect(isProduct(pi, ProductKind.XtraReports)).toBe(true); + + // Not included in DXperienceEnt + expect(isProduct(pi, ProductKind.XAF)).toBe(false); + expect(isProduct(pi, ProductKind.Dashboard)).toBe(false); + // Note: Docs IS included in DXperienceUni but NOT in DXperienceEnt + expect(isProduct(pi, ProductKind.Docs)).toBe(false); + }); + + it('isProduct returns true when ANY of multiple flags matches', () => { + const pi = createProductInfo(currentVersionId(), ProductKind.Docs); + expect(isProduct(pi, ProductKind.Docs, ProductKind.DocsBasic)).toBe(true); + expect(isProduct(pi, ProductKind.DocsBasic, ProductKind.Docs)).toBe(true); + expect(isProduct(pi, ProductKind.Blazor, ProductKind.Docs)).toBe(true); + }); + + it('isProduct returns false when NONE of multiple flags matches', () => { + const pi = createProductInfo(currentVersionId(), ProductKind.Blazor); + expect(isProduct(pi, ProductKind.Docs, ProductKind.DocsBasic)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// LicenseInfo – validity & findLatestDevExtremeVersion +// --------------------------------------------------------------------------- + +describe('LicenseInfo – payload-level behaviour (analogous to dxvcs LicenseTestHelperTests)', () => { + const versionId = currentVersionId(); + + // -- trial / no-product license ------------------------------------------ + + it('trial license (products = 0n) is valid but has no DevExtreme product', () => { + const trial = makeLicense(ProductKind.Default); + expect(isLicenseValid(trial)).toBe(true); + expect(findLatestDevExtremeVersion(trial)).toBeUndefined(); + }); + + it('empty LicenseInfo is invalid', () => { + const empty = { products: [] }; + expect(isLicenseValid(empty)).toBe(false); + expect(findLatestDevExtremeVersion(empty)).toBeUndefined(); + }); + + it('no-arg LicenseInfo is invalid', () => { + const noLicense = { products: [] }; + expect(isLicenseValid(noLicense)).toBe(false); + expect(findLatestDevExtremeVersion(noLicense)).toBeUndefined(); + }); + + // -- licensed with DevExtremeHtmlJs -------------------------------------- + + it.each([ + { name: 'DevExtremeHtmlJs', kind: ProductKind.DevExtremeHtmlJs }, + { name: 'DXperienceEnt', kind: ProductKind.DXperienceEnt }, + { name: 'DXperienceUni', kind: ProductKind.DXperienceUni }, + ])('license with $name grants DevExtreme access at current version', ({ kind }) => { + const lic = makeLicense(kind); + expect(isLicenseValid(lic)).toBe(true); + expect(findLatestDevExtremeVersion(lic)).toBe(versionId); + }); + + // -- licensed WITHOUT DevExtremeHtmlJs flag ------------------------------ + + it.each([ + { name: 'DXperienceWin', kind: ProductKind.DXperienceWin }, + { name: 'Blazor', kind: ProductKind.Blazor }, + { name: 'XAF', kind: ProductKind.XAF }, + { name: 'Docs', kind: ProductKind.Docs }, + { name: 'Dashboard', kind: ProductKind.Dashboard }, + { name: 'XtraReports', kind: ProductKind.XtraReports }, + ])('license with only $name does NOT grant DevExtreme access', ({ kind }) => { + const lic = makeLicense(kind); + expect(isLicenseValid(lic)).toBe(true); + expect(findLatestDevExtremeVersion(lic)).toBeUndefined(); + }); + + // -- version matching ---------------------------------------------------- + + it('findLatestDevExtremeVersion returns the highest matching version', () => { + const lic = { + products: [ + createProductInfo(240, ProductKind.DevExtremeHtmlJs), + createProductInfo(250, ProductKind.DevExtremeHtmlJs), + ], + }; + expect(findLatestDevExtremeVersion(lic)).toBe(250); + }); + + it('products on older version do not appear at newer version', () => { + const oldVersion = versionId - 10; + const lic = makeLicense(ProductKind.DevExtremeHtmlJs, oldVersion); + expect(isLicenseValid(lic)).toBe(true); + expect(findLatestDevExtremeVersion(lic)).toBe(oldVersion); + }); + + // -- combining product kinds -------------------------------------------- + + it('combined flags DXperienceASP | DevExtremeHtmlJs grant DevExtreme', () => { + const combined = ProductKind.DXperienceASP | ProductKind.DevExtremeHtmlJs; + const lic = makeLicense(combined); + const pi = lic.products[0]; + + expect(isProduct(pi, ProductKind.DXperienceASP)).toBe(true); + expect(isProduct(pi, ProductKind.DevExtremeHtmlJs)).toBe(true); + expect(findLatestDevExtremeVersion(lic)).toBe(versionId); + }); + + it('individual non-DevExtreme kind does NOT grant DevExtreme even when combined with other non-DevExtreme', () => { + const combined = ProductKind.DXperienceWin | ProductKind.Docs; + const lic = makeLicense(combined); + + expect(isProduct(lic.products[0], ProductKind.DXperienceWin)).toBe(true); + expect(isProduct(lic.products[0], ProductKind.Docs)).toBe(true); + expect(findLatestDevExtremeVersion(lic)).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Smoke tests – mirrors dxvcs LicenseTestHelperTests.Smoke per product kind +// --------------------------------------------------------------------------- + +describe('Smoke tests per ProductKind (dxvcs-style)', () => { + it.each([ + { name: 'DXperienceASP', kind: ProductKind.DXperienceASP }, + { name: 'DXperienceWPF', kind: ProductKind.DXperienceWPF }, + { name: 'DXperienceWin', kind: ProductKind.DXperienceWin }, + { name: 'Blazor', kind: ProductKind.Blazor }, + { name: 'XAF', kind: ProductKind.XAF }, + ])('$name – trial / licensed / universal / no-license states', ({ kind }) => { + // 1. Trial (products = Default = 0n --> no product flags) + const trial = makeLicense(ProductKind.Default); + expect(isLicenseValid(trial)).toBe(true); + expect(isProduct(trial.products[0], kind)).toBe(false); + + // 2. Licensed with DXperienceUni --> every kind is included + const uniLic = makeLicense(ProductKind.DXperienceUni); + expect(isProduct(uniLic.products[0], kind)).toBe(true); + // DevExtremeHtmlJs should also be present in Uni + expect(isProduct(uniLic.products[0], ProductKind.DevExtremeHtmlJs)).toBe(true); + + // 3. Licensed with specific kind --> only that kind + const specificLic = makeLicense(kind); + expect(isProduct(specificLic.products[0], kind)).toBe(true); + + // Docs should NOT be present when only 'kind' is specified + // (unless kind itself is Docs or encompasses Docs) + if ((kind & ProductKind.Docs) !== ProductKind.Docs) { + expect(isProduct(specificLic.products[0], ProductKind.Docs)).toBe(false); + } + + // 4. No license + const noLicense = { products: [] }; + expect(isLicenseValid(noLicense)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// DocsBasic vs Docs (analogous to dxvcs LicenseInfoTests.HasLicenseTests) +// --------------------------------------------------------------------------- + +describe('HasLicense-style tests (DocsBasic vs Docs)', () => { + it('DocsBasic flag set → isProduct(DocsBasic) true, isProduct(Docs) false', () => { + const pi = createProductInfo(currentVersionId(), ProductKind.DocsBasic); + expect(isProduct(pi, ProductKind.DocsBasic)).toBe(true); + expect(isProduct(pi, ProductKind.Docs)).toBe(false); + }); + + it('Docs flag set → isProduct(Docs) true, isProduct(DocsBasic) false', () => { + const pi = createProductInfo(currentVersionId(), ProductKind.Docs); + expect(isProduct(pi, ProductKind.Docs)).toBe(true); + expect(isProduct(pi, ProductKind.DocsBasic)).toBe(false); + }); + + it('isProduct with multiple alternatives works like HasLicense(version, kind1, kind2)', () => { + const pi = createProductInfo(currentVersionId(), ProductKind.DocsBasic); + expect(isProduct(pi, ProductKind.Docs, ProductKind.DocsBasic)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Multi-version license (analogous to dxvcs LicenseInfoTests.FindBestLicense) +// --------------------------------------------------------------------------- + +describe('Multi-version license scenarios', () => { + const versionId = currentVersionId(); + + it('finds the latest DevExtreme version from multiple product entries', () => { + const lic = { + products: [ + createProductInfo(versionId - 1, ProductKind.DevExtremeHtmlJs), + createProductInfo(versionId, ProductKind.DevExtremeHtmlJs), + ], + }; + expect(findLatestDevExtremeVersion(lic)).toBe(versionId); + }); + + it('returns older version when newer does not include DevExtreme', () => { + const lic = { + products: [ + createProductInfo(versionId, ProductKind.DXperienceWin), // no DevExtreme + createProductInfo(versionId - 1, ProductKind.DevExtremeHtmlJs), + ], + }; + expect(findLatestDevExtremeVersion(lic)).toBe(versionId - 1); + }); + + it('returns undefined when no entry has DevExtremeHtmlJs', () => { + const lic = { + products: [ + createProductInfo(versionId, ProductKind.DXperienceWin), + createProductInfo(versionId - 1, ProductKind.Blazor), + ], + }; + expect(findLatestDevExtremeVersion(lic)).toBeUndefined(); + }); +}); diff --git a/packages/devextreme/js/__internal/core/license/lcp_key_validation/product_info.ts b/packages/devextreme/js/__internal/core/license/lcp_key_validation/product_info.ts new file mode 100644 index 000000000000..b7b5c8764b5a --- /dev/null +++ b/packages/devextreme/js/__internal/core/license/lcp_key_validation/product_info.ts @@ -0,0 +1,23 @@ +/* eslint-disable no-bitwise */ +export interface ProductInfo { + readonly version: number; + readonly products: bigint; + readonly expiration: number; +} + +export function createProductInfo( + version: number, + products: bigint, + expiration = Infinity, +): ProductInfo { + return { version, products: BigInt(products), expiration }; +} + +export function isProduct(info: ProductInfo, ...productIds: bigint[]): boolean { + if (productIds.length === 1) { + const flag = BigInt(productIds[0]); + return (info.products & flag) === flag; + } + + return productIds.some((id) => (info.products & BigInt(id)) === BigInt(id)); +} diff --git a/packages/devextreme/js/__internal/core/license/lcp_key_validation/types.ts b/packages/devextreme/js/__internal/core/license/lcp_key_validation/types.ts new file mode 100644 index 000000000000..79918aa58294 --- /dev/null +++ b/packages/devextreme/js/__internal/core/license/lcp_key_validation/types.ts @@ -0,0 +1,47 @@ +/* eslint-disable spellcheck/spell-checker, no-bitwise */ +import { bit } from './utils'; + +const productKind = { + Default: 0n, + DXperienceWin: bit(0), + XtraReports: bit(4), + XPO: bit(15), + DevExtremeAspNet: bit(17), + DXperienceASP: bit(25), + XAF: bit(28), + Blazor: bit(31), + DXperienceWPF: bit(38), + DocsBasic: bit(39), + Dashboard: bit(47), + Snap: bit(49), + DevExtremeHtmlJs: bit(54), + Docs: bit(55), + XtraReportsWpf: bit(57), + XtraReportsWeb: bit(59), + XtraReportsWin: bit(60), + XtraReportsBlazor: bit(41), + DXperienceEnt: bit(0), + DXperienceUni: bit(0), +}; + +productKind.DXperienceEnt = productKind.Blazor + | productKind.DXperienceWin + | productKind.XtraReports + | productKind.Snap + | productKind.XtraReportsWin + | productKind.XPO + | productKind.DXperienceASP + | productKind.DXperienceWPF + | productKind.XtraReportsWeb + | productKind.XtraReportsWpf + | productKind.XtraReportsBlazor + | productKind.DevExtremeAspNet + | productKind.DevExtremeHtmlJs; + +productKind.DXperienceUni = productKind.DXperienceEnt + | productKind.XAF + | productKind.DXperienceWPF + | productKind.Dashboard + | productKind.Docs; + +export const ProductKind = Object.freeze(productKind); diff --git a/packages/devextreme/js/__internal/core/license/lcp_key_validation/utils.ts b/packages/devextreme/js/__internal/core/license/lcp_key_validation/utils.ts new file mode 100644 index 000000000000..7299aa87ba29 --- /dev/null +++ b/packages/devextreme/js/__internal/core/license/lcp_key_validation/utils.ts @@ -0,0 +1,79 @@ +/* eslint-disable spellcheck/spell-checker, no-bitwise */ +import { base64ToBytes, bigIntFromBytes } from '../byte_utils'; +import type { PublicKey } from '../key'; +import { pad } from '../pkcs1'; +import { compareSignatures } from '../rsa_bigint'; +import { sha1 } from '../sha1'; +import { DECODE_MAP } from './const'; + +export const bit = (shift: number): bigint => 1n << BigInt(shift); + +export const parseRsaXml = (xml: string): { modulus: Uint8Array; exponent: number } => { + const modulusMatch = /([^<]+)<\/Modulus>/.exec(xml); + const exponentMatch = /([^<]+)<\/Exponent>/.exec(xml); + + if (!modulusMatch || !exponentMatch) { + throw new Error('Invalid RSA XML key.'); + } + + return { + modulus: base64ToBytes(modulusMatch[1]), + exponent: Number(bigIntFromBytes(base64ToBytes(exponentMatch[1]))), + }; +}; + +export const encodeString = ( + text: string, + encode: (s: string) => string, +): string => ( + typeof encode === 'function' ? encode(text) : text +); + +export const shiftText = (text: string, map: string): string => { + if (!text) { + return text || ''; + } + + let result = ''; + + for (let i = 0; i < text.length; i += 1) { + const charCode = text.charCodeAt(i); + + if (charCode < map.length) { + result += map[charCode]; + } else { + result += text[i]; + } + } + + return result; +}; + +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); + + const key: PublicKey = { + n: modulus, + e: exponent, + }; + + const sign = base64ToBytes(signature); + + return compareSignatures({ + key, + signature: sign, + actual: pad(sha1(data)), + }); +}; diff --git a/packages/devextreme/js/__internal/core/license/license_validation.test.ts b/packages/devextreme/js/__internal/core/license/license_validation.test.ts index daa32510246a..cf78595fdfb9 100644 --- a/packages/devextreme/js/__internal/core/license/license_validation.test.ts +++ b/packages/devextreme/js/__internal/core/license/license_validation.test.ts @@ -10,6 +10,7 @@ import { assertedVersionsCompatible, clearAssertedVersions, } from '../../utils/version'; +import { LICENSE_KEY_PLACEHOLDER } from './const'; import { parseLicenseKey, setLicenseCheckSkipCondition, @@ -30,49 +31,28 @@ jest.mock('./key', () => ({ 72, 62, 186, 243, 199, 73, ]), }, - INTERNAL_USAGE_ID: 'aYC7EHibp0yxtXTihJERkA', })); describe('license token', () => { it.each([ - { - token: 'ewogICJmb3JtYXQiOiAxLAogICJjdXN0b21lcklkIjogImIxMTQwYjQ2LWZkZTEtNDFiZC1hMjgwLTRkYjlmOGU3ZDliZCIsCiAgIm1heFZlcnNpb25BbGxvd2VkIjogMjMxCn0=.DiDceRbil4IzXl5av7pNkKieyqHHhRf+CM477zDu4N9fyrhkQsjRourYvgVfkbSm+EQplkXhlMBc3s8Vm9n+VtPaMbeWXis92cdW/6HiT+Dm54xw5vZ5POGunKRrNYUzd9zTbYcz0bYA/dc/mHFeUdXA0UlKcx1uMaXmtJrkK74=', - payload: { - customerId: 'b1140b46-fde1-41bd-a280-4db9f8e7d9bd', - maxVersionAllowed: 231, - }, - }, { - token: 'ewogICJmb3JtYXQiOiAxLAogICJjdXN0b21lcklkIjogIjYxMjFmMDIyLTFjMTItNDNjZC04YWE0LTkwNzJkNDU4YjYxNCIsCiAgIm1heFZlcnNpb25BbGxvd2VkIjogMjMyCn0=.RENyZ3Ga5rCB7/XNKYbk2Ffv1n9bUexYNhyOlqcAD02YVnPw6XyQcN+ZORScKDU9gOInJ4o7vPxkgh10KvMZNn+FuBK8UcUR7kchk7z0CHGuOcIn2jD5X2hG6SYJ0UCBG/JDG35AL09T7Uv/pGj4PolRsANxtuMpoqmvX2D2vkU=', - payload: { - customerId: '6121f022-1c12-43cd-8aa4-9072d458b614', - maxVersionAllowed: 232, - }, - }, { - token: 'ewogICJmb3JtYXQiOiAxLAogICJjdXN0b21lcklkIjogIjM3Yjg4ZjBmLWQ0MmMtNDJiZS05YjhkLTU1ZGMwYzUzYzAxZiIsCiAgIm1heFZlcnNpb25BbGxvd2VkIjogMjIxCn0=.NVsilC5uWlD5QGS6bocLMlsVVK0VpZXYwU2DstUiLRpEI79/onuR8dGWasCLBo4PORDHPkNA/Ej8XeCHzJ0EkXRRZ7E2LrP/xlEfHRXTruvW4IEbZt3LiwJBt6/isLz+wzXtYtjV7tpE07/Y0TFoy+mWpHoU11GVtwKh6weRxkg=', - payload: { - customerId: '37b88f0f-d42c-42be-9b8d-55dc0c53c01f', - maxVersionAllowed: 221, - }, - }, - ])('verifies and decodes payload [%#]', ({ token, payload: expected }) => { + 'ewogICJmb3JtYXQiOiAxLAogICJjdXN0b21lcklkIjogImIxMTQwYjQ2LWZkZTEtNDFiZC1hMjgwLTRkYjlmOGU3ZDliZCIsCiAgIm1heFZlcnNpb25BbGxvd2VkIjogMjMxCn0=.DiDceRbil4IzXl5av7pNkKieyqHHhRf+CM477zDu4N9fyrhkQsjRourYvgVfkbSm+EQplkXhlMBc3s8Vm9n+VtPaMbeWXis92cdW/6HiT+Dm54xw5vZ5POGunKRrNYUzd9zTbYcz0bYA/dc/mHFeUdXA0UlKcx1uMaXmtJrkK74=', + 'ewogICJmb3JtYXQiOiAxLAogICJjdXN0b21lcklkIjogIjYxMjFmMDIyLTFjMTItNDNjZC04YWE0LTkwNzJkNDU4YjYxNCIsCiAgIm1heFZlcnNpb25BbGxvd2VkIjogMjMyCn0=.RENyZ3Ga5rCB7/XNKYbk2Ffv1n9bUexYNhyOlqcAD02YVnPw6XyQcN+ZORScKDU9gOInJ4o7vPxkgh10KvMZNn+FuBK8UcUR7kchk7z0CHGuOcIn2jD5X2hG6SYJ0UCBG/JDG35AL09T7Uv/pGj4PolRsANxtuMpoqmvX2D2vkU=', + 'ewogICJmb3JtYXQiOiAxLAogICJjdXN0b21lcklkIjogIjM3Yjg4ZjBmLWQ0MmMtNDJiZS05YjhkLTU1ZGMwYzUzYzAxZiIsCiAgIm1heFZlcnNpb25BbGxvd2VkIjogMjIxCn0=.NVsilC5uWlD5QGS6bocLMlsVVK0VpZXYwU2DstUiLRpEI79/onuR8dGWasCLBo4PORDHPkNA/Ej8XeCHzJ0EkXRRZ7E2LrP/xlEfHRXTruvW4IEbZt3LiwJBt6/isLz+wzXtYtjV7tpE07/Y0TFoy+mWpHoU11GVtwKh6weRxkg=', + ])('old format token is not parsed [%#]', (token) => { const license = parseLicenseKey(token); - expect(license.kind).toBe('verified'); - if (license.kind === 'verified') { - expect(license.payload).toEqual(expected); + expect(license.kind).toBe('corrupted'); + if (license.kind === 'corrupted') { + expect(license.error).toBe('general'); } }); - it('verifies and decodes payload with extra fields', () => { + it('old format token with extra fields is not parsed', () => { const license = parseLicenseKey('ewogICJmb3JtYXQiOiAxLAogICJjdXN0b21lcklkIjogImIxMTQwYjQ2LWZkZTEtNDFiZC1hMjgwLTRkYjlmOGU3ZDliZCIsCiAgIm1heFZlcnNpb25BbGxvd2VkIjogMjMxLAogICJleHRyYUZpZWxkIjogIkE5OTk5OTkiCn0=.fqm8mVhQ9+x/R7E7MVwUP3nJaYL3KldhYffVXdDqPVyHIQi66Z2XZ2RdygH4J0jvUpjhZ6yzmGPV0J0WoPbKyhtnY4ELhove/IAwpn8WGfRw3wLSxfR+RWuaKcw2yvlUA1JqrQUrIrN23UwNQodbJ/hGm30s0h1bf8zCvQ/d31k='); - expect(license.kind).toBe('verified'); - if (license.kind === 'verified') { - expect(license.payload).toEqual({ - customerId: 'b1140b46-fde1-41bd-a280-4db9f8e7d9bd', - maxVersionAllowed: 231, - extraField: 'A999999', - }); + expect(license.kind).toBe('corrupted'); + if (license.kind === 'corrupted') { + expect(license.error).toBe('general'); } }); @@ -82,27 +62,27 @@ describe('license token', () => { expect(license.kind).toBe('corrupted'); if (license.kind === 'corrupted') { - expect(license.error).toBe('verification'); + expect(license.error).toBe('general'); } }); - it('fails if payload is invalid JSON', () => { + it('old format token with invalid JSON is not parsed', () => { const license = parseLicenseKey('YWJj.vjx6wAI9jVkHJAnKcsuYNZ5UvCq3UhypQ+0f+kZ37/Qc1uj4BM6//Kfi4SVsXGOaOTFYWgzesROnHCp3jZRqphJwal4yXHD1sGFi6FEdB4MgdgNZvsZSnxNWLs/7s07CzuHLTpJrAG7sTdHVkQWZNnSCKjzV7909c/Stl9+hkLo='); expect(license.kind).toBe('corrupted'); if (license.kind === 'corrupted') { - expect(license.error).toBe('deserialization'); + expect(license.error).toBe('general'); } }); - it('fails if payload is invalid Base64', () => { + it('old format token with invalid Base64 is not parsed', () => { const license = parseLicenseKey('ewogICJmb3JtYXQiOiAxLAogICJjdXN0b21lcklkIjogIjM3Yjg4ZjBmLWQ0MmMtNDJiZS05YjhkLTU1ZGMwYzUzYzAxZiIsCiAgIm1heFZlcnNpb25BbGxvd2VkIjogMjIxCn0-.EnP/RDKg0eSyaPU1eDUFll1lqOdYbhN3u73LhN1op8vjNwA0P1vKiT1DfQRmXudlleGWgDkLA2OmJYUER8j7I3LSFf3hLkBAoWoBErgveTb2zkbz8P1i9lE+XmzIXeYHyZBYUt0IPkNfajF9zzbSDDin1CvW7pnADi0vIeZ5ICQ='); expect(license.kind).toBe('corrupted'); if (license.kind === 'corrupted') { - expect(license.error).toBe('decoding'); + expect(license.error).toBe('general'); } }); @@ -110,23 +90,23 @@ describe('license token', () => { 'ewogICJmb3JtYXQiOiAxLAogICJtYXhWZXJzaW9uQWxsb3dlZCI6IDIzMQp9.WH30cajUFcKqw/fwt4jITM/5tzVwPpbdbezhhdBi5oeOvU06zKY0J4M8gQy8GQ++RPYVCAo2md6vI9D80FD2CC4w+hpQLJNJJgNUHYPrgG6CX1yAB3M+NKHsPP9S71bXAgwvignb5uPo0R5emQzr4RKDhWQMKtgqEcRe+yme2mU=', 'ewogICJjdXN0b21lcklkIjogImIxMTQwYjQ2LWZkZTEtNDFiZC1hMjgwLTRkYjlmOGU3ZDliZCIsCiAgIm1heFZlcnNpb25BbGxvd2VkIjogMjMxCn0=.ok32DBaAgf3ijLmNQb+A0kUV2AiSivqvZJADdF607qqlAaduAVnotJtgdwm/Ib3MErfaGrDohCYoFMnKQevkRxFkA7tK3kOBnTZPUnZY0r3wyulMQmr4Qo+Sjf/fyXs4IYpGsC7/uJjgrCos8uzBegfmgfM93XSt6pKl9+c5xvc=', 'ewogICJmb3JtYXQiOiAxLAogICJjdXN0b21lcklkIjogImIxMTQwYjQ2LWZkZTEtNDFiZC1hMjgwLTRkYjlmOGU3ZDliZCIKfQ==.resgTqmazrorRNw7mmtV31XQnmTSw0uLEArsmpzCjWMQJLocBfAjpFvKBf+SAG9q+1iOSFySj64Uv2xBVqHnyeNVBRbouOKOnAB8RpkKvN4sc5SDc8JAG5TkwPVSzK/VLBpQxpqbxlcrRfHwz9gXqQoPt4/ZVATn285iw3DW0CU=', - ])('fails if payload misses required fields [%#]', (token) => { + ])('old format token with missing fields is not parsed [%#]', (token) => { const license = parseLicenseKey(token); expect(license.kind).toBe('corrupted'); if (license.kind === 'corrupted') { - expect(license.error).toBe('payload'); + expect(license.error).toBe('general'); } }); - it('fails if payload has unsupported version', () => { + it('old format token with unsupported version is not parsed', () => { const license = parseLicenseKey('ewogICJmb3JtYXQiOiAyLAogICJjdXN0b21lcklkIjogImIxMTQwYjQ2LWZkZTEtNDFiZC1hMjgwLTRkYjlmOGU3ZDliZCIsCiAgIm1heFZlcnNpb25BbGxvd2VkIjogMjMxCn0=.tTBymZMROsYyMiP6ldXFqGurbzqjhSQIu/pjyEUJA3v/57VgToomYl7FVzBj1asgHpadvysyTUiX3nFvPxbp166L3+LB3Jybw9ueMnwePu5vQOO0krqKLBqRq+TqHKn7k76uYRbkCIo5UajNfzetHhlkin3dJf3x2K/fcwbPW5A='); expect(license.kind).toBe('corrupted'); if (license.kind === 'corrupted') { - expect(license.error).toBe('version'); + expect(license.error).toBe('general'); } }); @@ -157,13 +137,14 @@ describe('version mismatch', () => { clearAssertedVersions(); }); - test('Perform license check if versions match', () => { + test('Old format token triggers old-devextreme-key warning even when versions match', () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); const token = 'ewogICJpbnRlcm5hbFVzYWdlSWQiOiAiUDdmNU5icU9WMDZYRFVpa3Q1bkRyQSIsCiAgImZvcm1hdCI6IDEKfQ==.ox52WAqudazQ0ZKdnJqvh/RmNNNX+IB9cmun97irvSeZK2JMf9sbBXC1YCrSZNIPBjQapyIV8Ctv9z2wzb3BkWy+R9CEh+ev7purq7Lk0ugpwDye6GaCzqlDg+58EHwPCNaasIuBiQC3ztvOItrGwWSu0aEFooiajk9uAWwzWeM='; assertDevExtremeVersion('DevExpress.Product.A', CORRECT_VERSION); assertDevExtremeVersion('DevExpress.Product.A', CORRECT_VERSION); assertDevExtremeVersion('DevExpress.Product.B', CORRECT_VERSION); validateLicense(token, CORRECT_VERSION); - expect(errors.log).toHaveBeenCalledWith('W0020'); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('A DevExtreme key (v25.2 or earlier)')); }); test('Perform version comparison if the license is okay', () => { @@ -294,9 +275,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); }); @@ -309,10 +292,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([ @@ -322,44 +306,22 @@ 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 should trigger old-devextreme-key warning', ({ token, version }) => { validateLicense(token, version); - expect(errors.log).not.toHaveBeenCalled(); + expect(consoleWarnSpy).toHaveBeenCalledTimes(2); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('A DevExtreme key (v25.2 or earlier)')); }); test.each([ @@ -367,9 +329,9 @@ describe('license check', () => { { 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 be displayed for old format license keys', ({ token, version }) => { validateLicense(token, version); - expect(trialPanelSpy).not.toHaveBeenCalled(); + expect(trialPanelSpy).toHaveBeenCalledTimes(1); }); test('Trial panel "Buy Now" link must use the jQuery link if no config has been set', () => { @@ -388,7 +350,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', () => { @@ -399,15 +361,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 old-devextreme-key warning even 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('A DevExtreme key (v25.2 or earlier)')); }); test.each([ @@ -425,9 +388,9 @@ 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 be displayed for old format license keys even in previews', ({ token, version }) => { validateLicense(token, version); - expect(trialPanelSpy).not.toHaveBeenCalled(); + expect(trialPanelSpy).toHaveBeenCalledTimes(1); }); test.each([ @@ -440,9 +403,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([ @@ -475,6 +439,7 @@ describe('internal license check', () => { beforeEach(() => { jest.spyOn(errors, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'warn').mockImplementation(() => {}); trialPanelSpy = jest.spyOn(trialPanel, 'renderTrialPanel'); setLicenseCheckSkipCondition(false); }); @@ -483,40 +448,35 @@ describe('internal license check', () => { jest.restoreAllMocks(); }); - test('valid internal usage token (correct)', () => { + test('old format internal usage token triggers old-devextreme-key warning', () => { const token = 'ewogICJpbnRlcm5hbFVzYWdlSWQiOiAiYVlDN0VIaWJwMHl4dFhUaWhKRVJrQSIsCiAgImZvcm1hdCI6IDEKfQ==.emWMjFDkBI2bvqc6R/hwh//2wE9YqS7yyTPSglqLBP7oPFMthW9tHNHsh1lG8MEuSKoi8TYOY+4R9GgvFi190f62iOy4iz8FenPXZodiv9hgDaovb2eIkwK4pilthOEAS9/JYhgTAentJ1f2+PlbjkTIqvYogk01GrRrd+WOtIA='; validateLicense(token, '1.2.3'); expect(errors.log).not.toHaveBeenCalled(); - expect(trialPanelSpy).not.toHaveBeenCalled(); + expect(trialPanelSpy).toHaveBeenCalledTimes(1); }); - test('valid internal usage token (correct, pre-release)', () => { + test('old format internal usage token triggers old-devextreme-key warning (pre-release)', () => { const token = 'ewogICJpbnRlcm5hbFVzYWdlSWQiOiAiYVlDN0VIaWJwMHl4dFhUaWhKRVJrQSIsCiAgImZvcm1hdCI6IDEKfQ==.emWMjFDkBI2bvqc6R/hwh//2wE9YqS7yyTPSglqLBP7oPFMthW9tHNHsh1lG8MEuSKoi8TYOY+4R9GgvFi190f62iOy4iz8FenPXZodiv9hgDaovb2eIkwK4pilthOEAS9/JYhgTAentJ1f2+PlbjkTIqvYogk01GrRrd+WOtIA='; validateLicense(token, '1.2.1'); expect(errors.log).not.toHaveBeenCalled(); - expect(trialPanelSpy).not.toHaveBeenCalled(); + expect(trialPanelSpy).toHaveBeenCalledTimes(1); }); - test('internal usage token (incorrect)', () => { + test('old format internal usage token (incorrect) triggers old-devextreme-key warning', () => { const token = 'ewogICJpbnRlcm5hbFVzYWdlSWQiOiAiUDdmNU5icU9WMDZYRFVpa3Q1bkRyQSIsCiAgImZvcm1hdCI6IDEKfQ==.ox52WAqudazQ0ZKdnJqvh/RmNNNX+IB9cmun97irvSeZK2JMf9sbBXC1YCrSZNIPBjQapyIV8Ctv9z2wzb3BkWy+R9CEh+ev7purq7Lk0ugpwDye6GaCzqlDg+58EHwPCNaasIuBiQC3ztvOItrGwWSu0aEFooiajk9uAWwzWeM='; validateLicense(token, '1.2.3'); - 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(); + expect(errors.log).not.toHaveBeenCalled(); + expect(trialPanelSpy).toHaveBeenCalledTimes(1); }); }); 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); }); @@ -528,14 +488,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(); }); }); diff --git a/packages/devextreme/js/__internal/core/license/license_validation.ts b/packages/devextreme/js/__internal/core/license/license_validation.ts index d70b39e923be..7a8c9fab9336 100644 --- a/packages/devextreme/js/__internal/core/license/license_validation.ts +++ b/packages/devextreme/js/__internal/core/license/license_validation.ts @@ -8,119 +8,48 @@ import { getPreviousMajorVersion, parseVersion, } from '../../utils/version'; -import { base64ToBytes } from './byte_utils'; -import { INTERNAL_USAGE_ID, PUBLIC_KEY } from './key'; -import { pad } from './pkcs1'; -import { compareSignatures } from './rsa_bigint'; -import { sha1 } from './sha1'; +import { + BUY_NOW_LINK, LICENSE_KEY_PLACEHOLDER, + LICENSING_DOC_LINK, RTM_MIN_PATCH_VERSION, SUBSCRIPTION_NAMES, +} from './const'; +import { isProductOnlyLicense, parseDevExpressProductKey } from './lcp_key_validation/lcp_key_validator'; +import { logLicenseWarning } from './license_warnings'; import { showTrialPanel } from './trial_panel'; import type { - License, LicenseCheckParams, Token, } from './types'; -import { TokenKind } from './types'; - -interface Payload extends Partial { - readonly format?: number; - readonly internalUsageId?: string; -} - -const FORMAT = 1; -const RTM_MIN_PATCH_VERSION = 3; -const KEY_SPLITTER = '.'; - -const BUY_NOW_LINK = 'https://go.devexpress.com/Licensing_Installer_Watermark_DevExtremeJQuery.aspx'; -const LICENSING_DOC_LINK = 'https://go.devexpress.com/Licensing_Documentation_DevExtremeJQuery.aspx'; - -const NBSP = '\u00A0'; -const SUBSCRIPTION_NAMES = `Universal, DXperience, ASP.NET${NBSP}and${NBSP}Blazor, DevExtreme${NBSP}Complete`; - -const GENERAL_ERROR: Token = { kind: TokenKind.corrupted, error: 'general' }; -const VERIFICATION_ERROR: Token = { kind: TokenKind.corrupted, error: 'verification' }; -const DECODING_ERROR: Token = { kind: TokenKind.corrupted, error: 'decoding' }; -const DESERIALIZATION_ERROR: Token = { kind: TokenKind.corrupted, error: 'deserialization' }; -const PAYLOAD_ERROR: Token = { kind: TokenKind.corrupted, error: 'payload' }; -const VERSION_ERROR: Token = { kind: TokenKind.corrupted, error: 'version' }; +import { + GENERAL_ERROR, + TokenKind, +} from './types'; let validationPerformed = false; -// verifies RSASSA-PKCS1-v1.5 signature -function verifySignature({ text, signature: encodedSignature }: { - text: string; - signature: string; -}): boolean { - return compareSignatures({ - key: PUBLIC_KEY, - signature: base64ToBytes(encodedSignature), - actual: pad(sha1(text)), - }); -} - export function parseLicenseKey(encodedKey: string | undefined): Token { if (encodedKey === undefined) { return GENERAL_ERROR; } - const parts = encodedKey.split(KEY_SPLITTER); - - if (parts.length !== 2 || parts[0].length === 0 || parts[1].length === 0) { - return GENERAL_ERROR; - } - - if (!verifySignature({ text: parts[0], signature: parts[1] })) { - return VERIFICATION_ERROR; + if (isProductOnlyLicense(encodedKey)) { + return parseDevExpressProductKey(encodedKey); } - let decodedPayload = ''; - try { - decodedPayload = atob(parts[0]); - } catch { - return DECODING_ERROR; - } - - let payload: Payload = {}; - try { - payload = JSON.parse(decodedPayload); - } catch { - return DESERIALIZATION_ERROR; - } - - const { - customerId, maxVersionAllowed, format, internalUsageId, ...rest - } = payload; - - if (internalUsageId !== undefined) { - return { - kind: TokenKind.internal, - internalUsageId, - }; - } - - if (customerId === undefined || maxVersionAllowed === undefined || format === undefined) { - return PAYLOAD_ERROR; - } - - if (format !== FORMAT) { - return VERSION_ERROR; - } - - return { - kind: TokenKind.verified, - payload: { - customerId, - maxVersionAllowed, - ...rest, - }, - }; + return GENERAL_ERROR; } function isPreview(patch: number): boolean { return isNaN(patch) || patch < RTM_MIN_PATCH_VERSION; } -function isDevExpressLicenseKey(licenseKey: string): boolean { - return licenseKey.startsWith('LCX') || licenseKey.startsWith('LCP'); +function hasLicensePrefix(licenseKey: string, prefix: string): boolean { + return licenseKey.trim().startsWith(prefix); +} + +function displayTrialPanel(): void { + const buyNowLink = config().buyNowLink ?? BUY_NOW_LINK; + const licensingDocLink = config().licensingDocLink ?? LICENSING_DOC_LINK; + showTrialPanel(buyNowLink, licensingDocLink, fullVersion, SUBSCRIPTION_NAMES); } function getLicenseCheckParams({ @@ -137,35 +66,50 @@ function getLicenseCheckParams({ const { major, minor } = preview ? getPreviousMajorVersion(version) : version; - if (!licenseKey) { - return { preview, error: 'W0019' }; + if (!licenseKey || licenseKey === LICENSE_KEY_PLACEHOLDER) { + return { preview, error: 'W0019', warningType: 'no-key' }; + } + + if (hasLicensePrefix(licenseKey, 'LCX')) { + return { preview, error: 'W0021', warningType: 'lcx-used' }; } - if (isDevExpressLicenseKey(licenseKey)) { - return { preview, error: 'W0024' }; + if (hasLicensePrefix(licenseKey, 'ewog')) { + return { preview, error: 'W0021', warningType: 'old-devextreme-key' }; } const license = parseLicenseKey(licenseKey); if (license.kind === TokenKind.corrupted) { - return { preview, error: 'W0021' }; + if (license.error === 'product-kind') { + return { preview, error: 'W0021', warningType: 'no-devextreme-license' }; + } + if (license.error === 'trial-expired') { + return { preview, error: 'W0020', warningType: 'trial-expired' }; + } + return { preview, error: 'W0021', warningType: 'invalid-key' }; } - if (license.kind === TokenKind.internal) { - return { preview, internal: true, error: license.internalUsageId === INTERNAL_USAGE_ID ? undefined : 'W0020' }; + if (license.kind !== TokenKind.verified) { + return { preview, error: 'W0021', warningType: 'invalid-key' }; } if (!(major && minor)) { - return { preview, error: 'W0021' }; + return { preview, error: 'W0021', warningType: 'invalid-key' }; } if (major * 10 + minor > license.payload.maxVersionAllowed) { - return { preview, error: 'W0020' }; + return { + preview, + error: 'W0020', + warningType: 'version-mismatch', + maxVersionAllowed: license.payload.maxVersionAllowed, + }; } return { preview, error: undefined }; } catch { - return { preview, error: 'W0021' }; + return { preview, error: 'W0021', warningType: 'invalid-key' }; } } @@ -177,32 +121,31 @@ export function validateLicense(licenseKey: string, versionStr: string = fullVer const version = parseVersion(versionStr); - const versionsCompatible = assertedVersionsCompatible(version); + assertedVersionsCompatible(version); - const { internal, error } = getLicenseCheckParams({ + const { + error, warningType, maxVersionAllowed, + } = getLicenseCheckParams({ licenseKey, version, }); - if (!versionsCompatible && internal) { - return; - } - - if (error && !internal) { - const buyNowLink = config().buyNowLink ?? BUY_NOW_LINK; - const licensingDocLink = config().licensingDocLink ?? LICENSING_DOC_LINK; - showTrialPanel(buyNowLink, licensingDocLink, fullVersion, SUBSCRIPTION_NAMES); - } - - const preview = isPreview(version.patch); - if (error) { - errors.log(preview ? 'W0022' : error); - return; + displayTrialPanel(); } - if (preview && !internal) { - errors.log('W0022'); + if (error) { + if (warningType) { + const versionInfo = warningType === 'version-mismatch' && maxVersionAllowed !== undefined + ? { + keyVersion: `${Math.floor(maxVersionAllowed / 10)}.${maxVersionAllowed % 10}`, + requiredVersion: `${version.major}.${version.minor}`, + } + : undefined; + logLicenseWarning(warningType, versionStr, versionInfo); + } else { + errors.log(error); + } } } diff --git a/packages/devextreme/js/__internal/core/license/license_warnings.ts b/packages/devextreme/js/__internal/core/license/license_warnings.ts new file mode 100644 index 000000000000..f4748fcb52d9 --- /dev/null +++ b/packages/devextreme/js/__internal/core/license/license_warnings.ts @@ -0,0 +1,118 @@ +import type { LicenseWarningType } from './types'; + +export const TEMPLATES = Object.freeze({ + warningPrefix: (code: string | number): string => { + let warningDescription = ''; + switch (code) { + case 'W0019': + warningDescription = 'DevExtreme: You are using a trial (evaluation) version of DevExtreme.'; + break; + case 'W0020': + warningDescription = 'DevExtreme: License Key Has Expired.'; + break; + case 'W0021': + warningDescription = 'DevExtreme: License Key Verification Has Failed.'; + break; + default: + warningDescription = 'DevExtreme: For evaluation purposes only. Redistribution prohibited.'; + break; + } + return `${code} - ${warningDescription}`; + }, + + keyNotFound: 'A devextreme-license generated key has not been specified in the GlobalConfig.', + + keyWasFound: (type: string, path?: string): string => { + switch (type) { + case 'envVariable': + return 'The DevExpress license key was retrieved from the \'DevExpress_License\' environment variable.'; + case 'envPath': + return 'The DevExpress license key was retrieved from the \'DevExpress_LicensePath\' environment variable.'; + case 'file': + return `The DevExpress license key was retrieved from file: "${path}".`; + default: + return 'The DevExpress license key was retrieved.'; + } + }, + + keyVerificationFailed: (type?: string, keyVersion?: string, requiredVersion?: string): string => { + switch (type) { + case 'incompatibleVersion': + return `Incompatible DevExpress license key version (v${keyVersion}). Download and register an updated DevExpress license key (v${requiredVersion}+). Clear npm/IDE/NuGet cache and rebuild your project (https://devexpress.com/DX1002).`; + case 'trialExpired': + return 'Your DevExpress trial period has expired. Purchase a license to continue using DevExpress product libraries.'; + default: + return 'License key verification has failed.'; + } + }, + + purchaseLicense: 'Please register an existing license (https://devexpress.com/DX1000) or purchase a new license (https://devexpress.com/Buy/) to continue use of the following DevExpress product libraries: DevExtreme - Included in Subscriptions: Universal, DXperience, ASP.NET and Blazor, DevExtreme Complete.', + + installationInstructions: 'If you own a licensed/registered version or if you are using a 30-day trial version of DevExpress product libraries on a development machine, download your personal license key and verify it with the devextreme-license tool (https://devexpress.com/DX1001).', + + // eslint-disable-next-line spellcheck/spell-checker + lcxUsedInsteadOfLcp: 'A DevExpress license key has been specified instead of a key generated using devextreme-license.', + + oldDevExtremeKey: 'A DevExtreme key (v25.2 or earlier) has been detected in the GlobalConfig. Generate a key with devextreme-license instead.', + + licenseId: (id: string): string => `License ID: ${id}`, +}); + +export function logLicenseWarning( + warningType: LicenseWarningType, + version: string, + versionInfo?: { keyVersion: string; requiredVersion: string }, +): void { + const T = TEMPLATES; + + const purchaseLine = `${T.warningPrefix('W0019')} ${T.purchaseLicense}`; + const installLine = `${T.warningPrefix('W0021')} ${T.installationInstructions}`; + + const warnings: string[][] = [[purchaseLine]]; + + switch (warningType) { + case 'no-key': + warnings[warnings.length - 1].push(T.keyNotFound); + warnings.push([installLine]); + break; + + case 'invalid-key': + warnings[warnings.length - 1].push(T.keyVerificationFailed()); + warnings.push([installLine]); + break; + + case 'lcx-used': + // eslint-disable-next-line spellcheck/spell-checker + warnings[warnings.length - 1].push(T.keyVerificationFailed(), T.lcxUsedInsteadOfLcp); + warnings.push([installLine]); + break; + + case 'old-devextreme-key': + warnings[warnings.length - 1].push(T.keyVerificationFailed(), T.oldDevExtremeKey); + warnings.push([installLine]); + break; + + case 'version-mismatch': { + const incompatibleLine = `${T.warningPrefix('W0020')} ${T.keyVerificationFailed('incompatibleVersion', versionInfo?.keyVersion, versionInfo?.requiredVersion)}`; + warnings[warnings.length - 1].push(T.keyVerificationFailed()); + warnings.push([incompatibleLine]); + break; + } + + case 'trial-expired': { + const expiredLine = `${T.warningPrefix('W0020')} ${T.keyVerificationFailed('trialExpired')}`; + warnings.push([expiredLine]); + break; + } + + case 'no-devextreme-license': + // Only the purchase line, no additional details + break; + default: + break; + } + + warnings.forEach((group) => { + console.warn(group.join('\n')); + }); +} diff --git a/packages/devextreme/js/__internal/core/license/rsa_bigint.ts b/packages/devextreme/js/__internal/core/license/rsa_bigint.ts index 0b830fdf3f23..df0c01650348 100644 --- a/packages/devextreme/js/__internal/core/license/rsa_bigint.ts +++ b/packages/devextreme/js/__internal/core/license/rsa_bigint.ts @@ -1,3 +1,4 @@ +import { bigIntFromBytes } from './byte_utils'; import type { PublicKey } from './key'; interface Args { @@ -7,9 +8,7 @@ interface Args { } export function compareSignatures(args: Args): boolean { try { - const zero = BigInt(0); const one = BigInt(1); - const eight = BigInt(8); const modExp = (base: bigint, exponent: bigint, modulus: bigint): bigint => { let result = one; @@ -27,11 +26,6 @@ export function compareSignatures(args: Args): boolean { return result; }; - const bigIntFromBytes = (bytes: Uint8Array): bigint => bytes.reduce( - (acc, cur) => (acc << eight) + BigInt(cur), // eslint-disable-line no-bitwise - zero, - ); - const actual = bigIntFromBytes(args.actual); const signature = bigIntFromBytes(args.signature); diff --git a/packages/devextreme/js/__internal/core/license/types.ts b/packages/devextreme/js/__internal/core/license/types.ts index c6212ba8eebf..40d5df223cab 100644 --- a/packages/devextreme/js/__internal/core/license/types.ts +++ b/packages/devextreme/js/__internal/core/license/types.ts @@ -7,24 +7,42 @@ export interface License { export enum TokenKind { corrupted = 'corrupted', verified = 'verified', - internal = 'internal', } -export type Token = { +export interface ErrorToken { + readonly kind: TokenKind.corrupted; + readonly error: 'general' | 'verification' | 'decoding' | 'deserialization' | 'payload' | 'version' | 'product-kind' | 'trial-expired'; +} + +export interface VerifiedToken { readonly kind: TokenKind.verified; readonly payload: License; -} | { - readonly kind: TokenKind.corrupted; - readonly error: 'general' | 'verification' | 'decoding' | 'deserialization' | 'payload' | 'version'; -} | { - readonly kind: TokenKind.internal; - readonly internalUsageId: string; -}; +} + +export type Token = ErrorToken | VerifiedToken; type LicenseVerifyResult = 'W0019' | 'W0020' | 'W0021' | 'W0022' | 'W0023' | 'W0024'; +export const GENERAL_ERROR: ErrorToken = { kind: TokenKind.corrupted, error: 'general' }; +export const VERIFICATION_ERROR: ErrorToken = { kind: TokenKind.corrupted, error: 'verification' }; +export const DECODING_ERROR: ErrorToken = { kind: TokenKind.corrupted, error: 'decoding' }; +export const DESERIALIZATION_ERROR: ErrorToken = { kind: TokenKind.corrupted, error: 'deserialization' }; +export const PAYLOAD_ERROR: ErrorToken = { kind: TokenKind.corrupted, error: 'payload' }; +export const VERSION_ERROR: ErrorToken = { kind: TokenKind.corrupted, error: 'version' }; +export const PRODUCT_KIND_ERROR: ErrorToken = { kind: TokenKind.corrupted, error: 'product-kind' }; +export const TRIAL_EXPIRED_ERROR: ErrorToken = { kind: TokenKind.corrupted, error: 'trial-expired' }; + +export type LicenseWarningType = 'no-key' + | 'invalid-key' + | 'lcx-used' + | 'old-devextreme-key' + | 'version-mismatch' + | 'no-devextreme-license' + | 'trial-expired'; + export interface LicenseCheckParams { preview: boolean; - internal?: true; error: LicenseVerifyResult | undefined; + warningType?: LicenseWarningType; + maxVersionAllowed?: number; } diff --git a/packages/devextreme/js/__internal/core/m_config.ts b/packages/devextreme/js/__internal/core/m_config.ts index 9f556bad406b..74de307bb315 100644 --- a/packages/devextreme/js/__internal/core/m_config.ts +++ b/packages/devextreme/js/__internal/core/m_config.ts @@ -19,6 +19,7 @@ const config = { useLegacyVisibleIndex: false, versionAssertions: [], copyStylesToShadowDom: true, + licenseKey: '/* ___$$$$$___devextreme___lcp___placeholder____$$$$$ */', floatingActionButtonConfig: { icon: 'add', diff --git a/packages/devextreme/license/devextreme-license-plugin.d.ts b/packages/devextreme/license/devextreme-license-plugin.d.ts new file mode 100644 index 000000000000..1f18fbe0b62b --- /dev/null +++ b/packages/devextreme/license/devextreme-license-plugin.d.ts @@ -0,0 +1,4 @@ +import type { UnpluginInstance } from 'unplugin'; + +export const DevExtremeLicensePlugin: UnpluginInstance; + diff --git a/packages/devextreme/license/devextreme-license-plugin.js b/packages/devextreme/license/devextreme-license-plugin.js new file mode 100644 index 000000000000..f84ecbb7711f --- /dev/null +++ b/packages/devextreme/license/devextreme-license-plugin.js @@ -0,0 +1,96 @@ +const { createUnplugin } = require('unplugin'); +const { getDevExpressLCXKey } = require('./dx-get-lcx'); +const { tryConvertLCXtoLCP, getLCPInfo } = require('./dx-lcx-2-lcp'); +const { MESSAGES } = require('./messages'); + +const PLUGIN_NAME = 'devextreme-bundler-plugin'; +const PLUGIN_PREFIX = `[${PLUGIN_NAME}]`; +const PLACEHOLDER = '/* ___$$$$$___devextreme___lcp___placeholder____$$$$$ */'; +// Target only the specific config file to avoid scanning all files during build +const TARGET_FILE_PATTERN = /[\/\\]__internal[\/\\]core[\/\\]m_config\.(ts|js)$/; + +const DevExtremeLicensePlugin = createUnplugin(() => { + let resolvedOnce = false; + let lcpCache = null; + let warnedOnce = false; + + function warn(ctx, msg) { + try { + if(ctx && typeof ctx.warn === 'function') { + ctx.warn(msg); + } + } catch{} + } + + function warnOnce(ctx, msg) { + if(warnedOnce) return; + warnedOnce = true; + warn(ctx, msg); + } + + function warnLicenseIssue(ctx, source, licenseId, warning) { + try { + if(ctx && typeof ctx.warn === 'function') { + ctx.warn(`${PLUGIN_PREFIX} DevExpress license key (LCX) retrieved from: ${source}`); + if(licenseId) { + ctx.warn(`${PLUGIN_PREFIX} License ID: ${licenseId}`); + } + ctx.warn(`${PLUGIN_PREFIX} Warning: ${warning}`); + } + } catch{} + } + + function resolveLcpSafe(ctx) { + if(resolvedOnce) return lcpCache; + resolvedOnce = true; + + try { + const { key: lcx, source } = getDevExpressLCXKey() || {}; + + if(!lcx) { + warnOnce(ctx, `${PLUGIN_PREFIX} Warning: ${MESSAGES.keyNotFound}`); + return (lcpCache = null); + } + + const lcp = tryConvertLCXtoLCP(lcx); + if(!lcp) { + warnLicenseIssue(ctx, source, null, MESSAGES.keyNotFound); + return (lcpCache = null); + } + + const { warning, licenseId } = getLCPInfo(lcp); + if(warning) { + warnLicenseIssue(ctx, source, licenseId, warning); + } + + return (lcpCache = lcp); + } catch{ + warnOnce(ctx, `${PLUGIN_PREFIX} Warning: ${MESSAGES.resolveFailed}`); + return (lcpCache = null); + } + } + + return { + name: PLUGIN_NAME, + enforce: 'pre', + transform(code, id) { + try { + if(!TARGET_FILE_PATTERN.test(id)) return null; + if(typeof code !== 'string') return null; + if(!code.includes(PLACEHOLDER)) return null; + + const lcp = resolveLcpSafe(this); + if(!lcp) return null; + + return { code: code.split(PLACEHOLDER).join(lcp), map: null }; + } catch{ + warnOnce(this, `${PLUGIN_PREFIX} Patch error. Placeholder will remain.`); + return null; + } + }, + }; +}); + +module.exports = { + DevExtremeLicensePlugin, +}; diff --git a/packages/devextreme/license/devextreme-license.js b/packages/devextreme/license/devextreme-license.js new file mode 100644 index 000000000000..e2604c2a9b2c --- /dev/null +++ b/packages/devextreme/license/devextreme-license.js @@ -0,0 +1,262 @@ +#!/usr/bin/env node + + +const fs = require('fs'); +const path = require('path'); + +const { getDevExpressLCXKey } = require('./dx-get-lcx'); +const { tryConvertLCXtoLCP, getLCPInfo } = require('./dx-lcx-2-lcp'); +const { TEMPLATES } = require('./messages'); + +const EXPORT_NAME = 'licenseKey'; +const TRIAL_VALUE = 'TRIAL'; +const CLI_PREFIX = '[devextreme-license]'; + +function logStderr(...lines) { + process.stderr.write(lines.join('\n') + '\n\n'); +} + +function prefixed(msg) { + return `${CLI_PREFIX} ${msg}`; +} + +function fail(msg) { + process.stderr.write(msg.endsWith('\n') ? msg : msg + '\n'); + process.exit(0); +} + +function printHelp() { + process.stdout.write( + [ + 'Usage:', + ' devextreme-license --out [options]', + '', + 'Options:', + ' --out Output file path (optional)', + ' --non-modular Generate a non-modular JS file (only with .js extension)', + ' --no-gitignore Do not modify .gitignore', + ' --force Overwrite existing output file', + ' --cwd Project root (default: process.cwd())', + ' -h, --help Show help', + '', + 'Examples:', + ' "prebuild": "devextreme-license --out src/.devextreme/license-key.ts"', + ' "prebuild": "devextreme-license --non-modular --out src/.devextreme/license-key.js"', + '', + ].join('\n') + ); +} + +function parseArgs(argv) { + const args = argv.slice(2); + const out = { + outPath: null, + nonModular: false, + gitignore: true, + force: false, + cwd: process.cwd(), + help: false, + }; + + for(let i = 0; i < args.length; i++) { + const a = args[i]; + + if(a === '-h' || a === '--help') out.help = true; + else if(a === '--out') { + const next = args[i + 1]; + if(!next || next.startsWith('-')) { + logStderr(prefixed('Warning: --out requires a path argument but none was provided. Ignoring --out.')); + } else { + out.outPath = args[++i]; + } + } + else if(a.startsWith('--out=')) { + const val = a.slice('--out='.length); + if(!val) { + logStderr(prefixed('Warning: --out requires a path argument but none was provided. Ignoring --out.')); + } else { + out.outPath = val; + } + } + else if(a === '--non-modular') out.nonModular = true; + else if(a === '--no-gitignore') out.gitignore = false; + else if(a === '--force') out.force = true; + else if(a === '--cwd') { + const next = args[i + 1]; + if(!next || next.startsWith('-')) { + logStderr(prefixed('Warning: --cwd requires a path argument but none was provided. Ignoring --cwd.')); + } else { + out.cwd = args[++i]; + } + } + else if(a.startsWith('--cwd=')) { + const val = a.slice('--cwd='.length); + if(!val) { + logStderr(prefixed('Warning: --cwd requires a path argument but none was provided. Ignoring --cwd.')); + } else { + out.cwd = val; + } + } + else fail(`Unknown argument: ${a}\nRun devextreme-license --help`); + } + + return out; +} + +function ensureDirExists(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +function readTextIfExists(filePath) { + try { + if(!fs.existsSync(filePath)) return null; + return fs.readFileSync(filePath, 'utf8'); + } catch{ + return null; + } +} + +function writeFileAtomic(filePath, content) { + const dir = path.dirname(filePath); + const base = path.basename(filePath); + const tmp = path.join(dir, `.${base}.${process.pid}.${Date.now()}.tmp`); + fs.writeFileSync(tmp, content, 'utf8'); + fs.renameSync(tmp, filePath); +} + +function toPosixPath(p) { + return p.split(path.sep).join('/'); +} + +function addToGitignore(projectRoot, outAbsPath) { + const gitignorePath = path.join(projectRoot, '.gitignore'); + + let rel = path.relative(projectRoot, outAbsPath); + if(rel.startsWith('..')) return; + + rel = toPosixPath(rel).trim(); + + const existing = readTextIfExists(gitignorePath); + if(existing == null) { + writeFileAtomic(gitignorePath, rel + '\n'); + return; + } + + const lines = existing.split(/\r?\n/).map((l) => l.trim()); + if(lines.includes(rel) || lines.includes('/' + rel)) return; + + const needsNewline = existing.length > 0 && !existing.endsWith('\n'); + fs.appendFileSync(gitignorePath, (needsNewline ? '\n' : '') + rel + '\n', 'utf8'); +} + +function renderFile(lcpKey) { + return [ + '// Auto-generated by devextreme-license.', + '// Do not commit this file to source control.', + '', + `export const ${EXPORT_NAME} = ${JSON.stringify(lcpKey)};`, + '', + ].join('\n'); +} + +function renderNonModularFile(lcpKey) { + return [ + '// Auto-generated by devextreme-license.', + '// Do not commit this file to source control.', + '', + `DevExpress.config({ licenseKey: ${JSON.stringify(lcpKey)} });`, + '', + ].join('\n'); +} + +function main() { + const opts = parseArgs(process.argv); + if(opts.help) { + printHelp(); + process.exit(0); + } + + const { key: lcx, source, currentVersion } = getDevExpressLCXKey() || {}; + + let lcp = TRIAL_VALUE; + let licenseId = null; + + if(lcx && lcx.trimStart().startsWith('ewog')) { + logStderr( + prefixed(`${TEMPLATES.warningPrefix(1000)} ${TEMPLATES.purchaseLicense}`), + TEMPLATES.keyVerificationFailed(), + TEMPLATES.oldDevExtremeKey(currentVersion), + TEMPLATES.keyWasFound(source.type, source.path), + prefixed(`${TEMPLATES.warningPrefix(1001)} ${TEMPLATES.installationInstructions}`), + ); + } else if(lcx) { + lcp = tryConvertLCXtoLCP(lcx) || TRIAL_VALUE; + const { warning, licenseId: id } = getLCPInfo(lcp); + licenseId = id; + + if(warning) { + const lines = []; + + lines.push( + prefixed(`${TEMPLATES.warningPrefix(1000)} ${TEMPLATES.purchaseLicense}`), + ); + + if(licenseId) { + lines.push(TEMPLATES.licenseId(licenseId)); + } + + lines.push( + TEMPLATES.keyWasFound(source.type, source.path), + ); + + if(warning.type !== 'trial') { + const code = TEMPLATES.warningCodeByType(warning.type); + + lines.push( + TEMPLATES.keyVerificationFailed(warning.type, warning.keyVersion, warning.currentVersion), + ); + + if(warning.type === 'trialExpired') { + lines.push(prefixed(`${TEMPLATES.warningPrefix(code)} ${TEMPLATES.purchaseLicense}`)); + } else { + lines.push(prefixed(`${TEMPLATES.warningPrefix(code)} ${TEMPLATES.installationInstructions}`)); + } + } + + logStderr(...lines); + } + } else { + logStderr( + prefixed(`${TEMPLATES.warningPrefix(1000)} ${TEMPLATES.purchaseLicense}`), + TEMPLATES.keyNotFound, + prefixed(`${TEMPLATES.warningPrefix(1001)} ${TEMPLATES.installationInstructions}`), + ); + } + + if(!opts.outPath) { + process.stdout.write(lcp + '\n'); + process.exit(0); + } + + const projectRoot = path.resolve(opts.cwd); + const outAbs = path.resolve(projectRoot, opts.outPath); + + ensureDirExists(path.dirname(outAbs)); + + if(!opts.force && fs.existsSync(outAbs)) { + fail(`Output file already exists: ${opts.outPath}\nUse --force to overwrite.`); + } + + const useNonModular = opts.nonModular && outAbs.endsWith('.js'); + writeFileAtomic(outAbs, useNonModular ? renderNonModularFile(lcp) : renderFile(lcp)); + + if(opts.gitignore) { + try { + addToGitignore(projectRoot, outAbs); + } catch{} + } + + process.exit(0); +} + +main(); diff --git a/packages/devextreme/license/dx-get-lcx.js b/packages/devextreme/license/dx-get-lcx.js new file mode 100644 index 000000000000..570895982abd --- /dev/null +++ b/packages/devextreme/license/dx-get-lcx.js @@ -0,0 +1,128 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const LICENSE_ENV = 'DevExpress_License'; +const LICENSE_PATH_ENV = 'DevExpress_LicensePath'; +const LICENSE_FILE = 'DevExpress_License.txt'; + +function isNonEmptyString(v) { + return typeof v === 'string' && v.trim().length > 0; +} + +function hasEnvVar(name) { + return Object.prototype.hasOwnProperty.call(process.env, name); +} + +function readTextFileIfExists(filePath) { + try { + if(!filePath) return null; + if(!fs.existsSync(filePath)) return null; + const stat = fs.statSync(filePath); + if(!stat.isFile()) return null; + const raw = fs.readFileSync(filePath, 'utf8'); + return isNonEmptyString(raw) ? raw : null; + } catch { + return null; + } +} + +function normalizeKey(raw) { + if(!isNonEmptyString(raw)) return null; + const lines = raw + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean); + + if(lines.length === 0) return null; + const lcxLike = lines.find((l) => l.startsWith('LCX')); + return (lcxLike || lines[0]).trim(); +} + +function getDefaultLicenseFilePath() { + const home = os.homedir(); + + if(process.platform === 'win32') { + const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming'); + return path.join(appData, 'DevExpress', LICENSE_FILE); + } + + if(process.platform === 'darwin') { + return path.join( + home, + 'Library', + 'Application Support', + 'DevExpress', + LICENSE_FILE + ); + } + + return path.join(home, '.config', 'DevExpress', LICENSE_FILE); +} + +function resolveFromLicensePathEnv(licensePathValue) { + if(!isNonEmptyString(licensePathValue)) return null; + + const p = licensePathValue.trim(); + + try { + if(fs.existsSync(p)) { + const stat = fs.statSync(p); + if(stat.isFile()) return p; + if(stat.isDirectory()) return path.join(p, LICENSE_FILE); + } + } catch {} + + if(p.toLowerCase().endsWith('.txt')) return p; + return path.join(p, LICENSE_FILE); +} + +function readDevExtremeVersion() { + try { + const pkgPath = require('path').join(__dirname, '..', 'package.json'); + const pkg = JSON.parse(require('fs').readFileSync(pkgPath, 'utf8')); + const parts = String(pkg.version || '').split('.'); + const major = parseInt(parts[0], 10); + const minor = parseInt(parts[1], 10); + if(!isNaN(major) && !isNaN(minor)) { + return { major, minor, code: major * 10 + minor }; + } + } catch{} + return null; +} + +function buildVersionString(devExtremeVersion){ + const { major, minor, code: currentCode } = devExtremeVersion; + return `${major}.${minor}`; +} + +function getDevExpressLCXKey() { + const devExtremeVersion = readDevExtremeVersion(); + let currentVersion = ''; + if(devExtremeVersion) { + currentVersion = buildVersionString(devExtremeVersion); + } + if(hasEnvVar(LICENSE_ENV)) { + return { key: normalizeKey(process.env[LICENSE_ENV]), source: { type: 'envVariable' }, currentVersion }; + } + + if(hasEnvVar(LICENSE_PATH_ENV)) { + const licensePath = resolveFromLicensePathEnv(process.env[LICENSE_PATH_ENV]); + const key = normalizeKey(readTextFileIfExists(licensePath)); + return { key, source: { type: 'envPath' }, currentVersion }; + } + + const defaultPath = getDefaultLicenseFilePath(); + const fromDefault = normalizeKey(readTextFileIfExists(defaultPath)); + if(fromDefault) { + return { key: fromDefault, source: { type: 'file', path: defaultPath }, currentVersion }; + } + + return { key: null, source: null, currentVersion }; +} + +module.exports = { + getDevExpressLCXKey, + readDevExtremeVersion, + buildVersionString +}; diff --git a/packages/devextreme/license/dx-lcx-2-lcp.js b/packages/devextreme/license/dx-lcx-2-lcp.js new file mode 100644 index 000000000000..600e1ae90c2a --- /dev/null +++ b/packages/devextreme/license/dx-lcx-2-lcp.js @@ -0,0 +1,300 @@ + +const { MESSAGES } = require('./messages'); +const LCX_SIGNATURE = 'LCXv1'; +const LCP_SIGNATURE = 'LCPv1'; +const SIGN_LENGTH = 68 * 2; // 136 chars + +const ENCODE_MAP_STR = + '\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0A\x0B\x0C\x0D\x0E\x0F' + + '\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1A\x1B\x1C\x1D\x1E\x1F' + + '\x20x\x220]qA\'u`U?.wOCLyJnz@$*DmsMhlW/T)dKHQ+jNEa6G:VZk9!p>%e7i3S5\\^=P&(Ic,2#rtgYojOv\\$]m)JncBVsi state.s.length) { + throw new Error('Invalid license data'); + } + state.pos = end; + return state.s.slice(start, end); +} + +function safeBase64ToUtf8(b64) { + try { + return Buffer.from(b64, 'base64').toString('utf8'); + } catch{ + throw new Error('Invalid license data'); + } +} + +function convertLCXtoLCP(licenseString) { + assertNonEmptyString(licenseString, 'licenseString'); + const input = licenseString.trim(); + + if(!input.startsWith(LCX_SIGNATURE)) { + throw new Error('Unsupported license format'); + } + + const base64Part = input.slice(LCX_SIGNATURE.length); + const lcx = safeBase64ToUtf8(base64Part); + + if(lcx.length < SIGN_LENGTH) { + throw new Error('Invalid license data'); + } + + const lcxData = decode(lcx.slice(SIGN_LENGTH)); + const state = { s: lcxData, pos: 0 }; + const signProducts = readString(state, SIGN_LENGTH); + + void readString(state); + const productsString = readString(state); + + const payloadText = signProducts + productsString; + const payloadB64 = Buffer.from(payloadText, 'utf8').toString('base64'); + const encoded = encode(payloadB64); + + return LCP_SIGNATURE + encoded; +} + +function tryConvertLCXtoLCP(licenseString) { + try { + return convertLCXtoLCP(licenseString); + } catch{ + return null; + } +} + +const DEVEXTREME_HTMLJS_BIT = 1n << 54n; // ProductKind.DevExtremeHtmlJs from types.ts + +const DOTNET_TICKS_EPOCH_OFFSET = 621355968000000000n; +const DOTNET_TICKS_PER_MS = 10000n; +const DOTNET_MAX_VALUE_TICKS = 3155378975999999999n; + +function dotnetTicksToMs(ticksStr) { + const ticks = BigInt(ticksStr); + if(ticks >= DOTNET_MAX_VALUE_TICKS) return Infinity; + return Number((ticks - DOTNET_TICKS_EPOCH_OFFSET) / DOTNET_TICKS_PER_MS); +} + +const TokenKind = Object.freeze({ + corrupted: 'corrupted', + verified: 'verified', + internal: 'internal', +}); + +const GENERAL_ERROR = { kind: TokenKind.corrupted, error: 'general' }; +const DESERIALIZATION_ERROR = { kind: TokenKind.corrupted, error: 'deserialization' }; +const PRODUCT_KIND_ERROR = { kind: TokenKind.corrupted, error: 'product-kind' }; +const TRIAL_EXPIRED_ERROR = { kind: TokenKind.corrupted, error: 'trial-expired' }; + +function readDevExtremeVersion() { + try { + const pkgPath = require('path').join(__dirname, '..', 'package.json'); + const pkg = JSON.parse(require('fs').readFileSync(pkgPath, 'utf8')); + const parts = String(pkg.version || '').split('.'); + const major = parseInt(parts[0], 10); + const minor = parseInt(parts[1], 10); + if(!isNaN(major) && !isNaN(minor)) { + return { major, minor, code: major * 10 + minor }; + } + } catch{} + return null; +} + +function buildVersionString(devExtremeVersion){ + const { major, minor, code: currentCode } = devExtremeVersion; + return `${major}.${minor}`; +} + +function productsFromString(encodedString) { + if(!encodedString) { + return { products: [], errorToken: GENERAL_ERROR }; + } + try { + const splitInfo = encodedString.split(';'); + const licenseId = splitInfo[0]; + const productTuples = splitInfo.slice(1).filter((entry) => entry.length > 0); + const products = productTuples.map(tuple => { + const parts = tuple.split(','); + const version = Number.parseInt(parts[0], 10); + const products = BigInt(parts[1]); + const expiration = parts.length > 3 ? dotnetTicksToMs(parts[3]) : Infinity; + return { version, products, expiration }; + }); + return { products, licenseId }; + } catch{ + return { products: [], errorToken: DESERIALIZATION_ERROR }; + } +} + +function getMaxExpiration(products) { + const expirations = products + .map(p => p.expiration) + .filter(e => e > 0 && e !== Infinity); + if(expirations.length === 0) return Infinity; + return Math.max(...expirations); +} + +function findLatestDevExtremeVersion(products) { + if(!Array.isArray(products) || products.length === 0) return undefined; + const sorted = [...products].sort((a, b) => b.version - a.version); + const match = sorted.find(p => (p.products & DEVEXTREME_HTMLJS_BIT) === DEVEXTREME_HTMLJS_BIT); + return match?.version; +} + +function parseLCP(lcpString) { + if(typeof lcpString !== 'string' || !lcpString.startsWith(LCP_SIGNATURE)) { + return GENERAL_ERROR; + } + + try { + const b64 = decode(lcpString.slice(LCP_SIGNATURE.length)); + const decoded = Buffer.from(b64, 'base64').toString('binary'); + + if(decoded.length < SIGN_LENGTH) { + return GENERAL_ERROR; + } + + const productsPayload = decoded.slice(SIGN_LENGTH); + const decodedPayload = mapString(productsPayload, DECODE_MAP); + const { products, errorToken, licenseId } = productsFromString(decodedPayload); + if(errorToken) { + return { ...errorToken, licenseId }; + } + + const maxVersionAllowed = findLatestDevExtremeVersion(products); + if(!maxVersionAllowed) { + const maxExpiration = getMaxExpiration(products); + if(maxExpiration !== Infinity && maxExpiration < Date.now()) { + return { ...TRIAL_EXPIRED_ERROR, licenseId }; + } + } + + if(!maxVersionAllowed) { + return { ...PRODUCT_KIND_ERROR, licenseId }; + } + + return { + kind: TokenKind.verified, + payload: { customerId: '', maxVersionAllowed, licenseId }, + }; + } catch{ + return GENERAL_ERROR; + } +} + +function formatVersionCode(versionCode) { + return `v${Math.floor(versionCode / 10)}.${versionCode % 10}`; +} + +function getLCPInfo(lcpString) { + const token = parseLCP(lcpString); + let warning = null; + let licenseId = null; + let currentVersion = ''; + + if(token.kind === TokenKind.corrupted) { + licenseId = token.licenseId || null; + switch(token.error) { + case 'general': + warning = { type: 'general' }; + break; + case 'deserialization': + warning = { type: 'corrupted' }; + break; + case 'product-kind': + warning = { type: 'trial' }; + break; + case 'trial-expired': + warning = { type: 'trialExpired' }; + break; + } + } else { + licenseId = token.payload.licenseId || null; + const devExtremeVersion = readDevExtremeVersion(); + if(devExtremeVersion) { + currentVersion = buildVersionString(devExtremeVersion); + const { maxVersionAllowed } = token.payload; + if(maxVersionAllowed < devExtremeVersion.code) { + warning = { + type:'incompatibleVersion', + keyVersion: formatVersionCode(maxVersionAllowed), + currentVersion + }; + } + } + } + + return { warning, licenseId, currentVersion }; +} + +function getLCPWarning(lcpString) { + return getLCPInfo(lcpString).warning; +} + +module.exports = { + convertLCXtoLCP, + tryConvertLCXtoLCP, + parseLCP, + getLCPInfo, + getLCPWarning, + TokenKind, + LCX_SIGNATURE, + LCP_SIGNATURE, +}; diff --git a/packages/devextreme/license/messages.js b/packages/devextreme/license/messages.js new file mode 100644 index 000000000000..9bf082cad13d --- /dev/null +++ b/packages/devextreme/license/messages.js @@ -0,0 +1,89 @@ +'use strict'; + +const MESSAGES = Object.freeze({ + keyNotFound: [ + 'For evaluation purposes only. Redistribution prohibited.', + 'If you own a licensed/registered version or if you are using a 30-day trial version', + 'of DevExpress product libraries on a development machine,', + 'download your personal license key (devexpress.com/DX1001)', + 'and place DevExpress_License.txt in the following folder:', + '"%AppData%/DevExpress" (Windows)', + 'or "$HOME/Library/Application Support/DevExpress" (MacOS)', + 'or "$HOME/.config/DevExpress" (Linux).', + 'Alternatively, download and run the DevExpress Unified Component Installer', + 'to automatically activate your license.', + ].join(' '), + + trial: [ + 'For evaluation purposes only. Redistribution prohibited.', + 'Please purchase a license to continue use of the following', + 'DevExpress product libraries:', + 'Universal, DXperience, ASP.NET and Blazor, DevExtreme Complete.', + ].join(' '), + + resolveFailed: 'Failed to resolve license key. Placeholder will remain.', +}); + +const KEY_SOURCES = Object.freeze({ + envVariable: 'License source: Environment Variable (DevExpress_License).', + envPath: 'License source: Environment Variable (DevExpress_LicensePath).', + file: (filePath) => `License source: File "${filePath}".`, + default: 'License source: default.', +}); + +const WARNING_CODES = Object.freeze({ + general: 1001, + incompatibleVersion: 1002, + trialExpired: 1003, +}); + +const TEMPLATES = Object.freeze({ + warningPrefix: (number) => + `Warning DX${number}: For evaluation purposes only. Redistribution prohibited.`, + + keyNotFound: 'A valid DevExpress license key was not found on this machine.', + + keyWasFound: (type, filePath) => { + if(type === 'file') return KEY_SOURCES.file(filePath); + return KEY_SOURCES[type] || KEY_SOURCES.default; + }, + + keyVerificationFailed: (type, keyVersion, requiredVersion) => { + if(type === 'incompatibleVersion') { + return [ + `Incompatible DevExpress license key version (${keyVersion}).`, + `Download and register an updated DevExpress license key (${requiredVersion}+).`, + 'Clear npm/IDE/NuGet cache and rebuild your project (https://devexpress.com/DX1002).', + ].join(' '); + } + if(type === 'trialExpired') { + return [ + 'Your DevExpress trial period has expired.', + 'Purchase a license to continue using DevExpress product libraries.', + ].join(' '); + } + return 'License key verification has failed.'; + }, + + warningCodeByType: (type) => WARNING_CODES[type] || WARNING_CODES.general, + + purchaseLicense: [ + 'Please register an existing license (https://devexpress.com/DX1000) or purchase a new license', + '(https://devexpress.com/Buy/) to continue use of the following DevExpress product libraries:', + 'DevExtreme - Included in Subscriptions: Universal, DXperience, ASP.NET and Blazor, DevExtreme Complete.' + ].join(' '), + + installationInstructions: [ + 'If you own a licensed/registered version or if you are using a 30-day trial version', + 'of DevExpress product libraries on a development machine,', + 'download your personal license key and verify it with the devextreme-license tool', + '(https://devexpress.com/DX1001).', + ].join(' '), + + oldDevExtremeKey: (version) => + `A DevExtreme key (v25_2 or earlier) has been detected. Use DevExpress license key (v${version}+) instead.`, + + licenseId: (id) => `License ID: ${id}`, +}); + +module.exports = { MESSAGES, TEMPLATES }; diff --git a/packages/devextreme/package.json b/packages/devextreme/package.json index a9568663760e..cce8403fe77a 100644 --- a/packages/devextreme/package.json +++ b/packages/devextreme/package.json @@ -63,7 +63,8 @@ "inferno-create-element": "catalog:", "inferno-hydrate": "catalog:", "jszip": "^3.10.1", - "rrule": "^2.7.1" + "rrule": "^2.7.1", + "unplugin": "^3.0.0" }, "devDependencies": { "@babel/core": "7.29.0", @@ -254,7 +255,8 @@ }, "bin": { "devextreme-bundler-init": "bin/bundler-init.js", - "devextreme-bundler": "bin/bundler.js" + "devextreme-bundler": "bin/bundler.js", + "devextreme-license": "bin/devextreme-license.js" }, "browserslist": [ "last 2 Chrome versions", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e9898b9dd76d..3f247d7c0458 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1370,6 +1370,9 @@ importers: rrule: specifier: ^2.7.1 version: 2.8.1 + unplugin: + specifier: ^3.0.0 + version: 3.0.0 devDependencies: '@babel/core': specifier: 7.29.0 @@ -4872,9 +4875,6 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@jridgewell/trace-mapping@0.3.25': - resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} - '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} @@ -17670,6 +17670,10 @@ packages: resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} engines: {node: '>=18.12.0'} + unplugin@3.0.0: + resolution: {integrity: sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==} + engines: {node: ^20.19.0 || >=22.12.0} + unquote@1.1.1: resolution: {integrity: sha512-vRCqFv6UhXpWxZPyGDh/F3ZpNv8/qo7w6iufLpQg9aKnQ71qM4B5KiI7Mia9COcjEhrO9LueHpMYjYzsWH3OIg==} @@ -23573,7 +23577,7 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@jest/types': 29.6.3 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/trace-mapping': 0.3.31 babel-plugin-istanbul: 6.1.1 chalk: 4.1.2 convert-source-map: 2.0.0 @@ -23647,11 +23651,6 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} - '@jridgewell/trace-mapping@0.3.25': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -41902,6 +41901,12 @@ snapshots: picomatch: 4.0.4 webpack-virtual-modules: 0.6.2 + unplugin@3.0.0: + dependencies: + '@jridgewell/remapping': 2.3.5 + picomatch: 4.0.4 + webpack-virtual-modules: 0.6.2 + unquote@1.1.1: {} unrs-resolver@1.11.1: