diff --git a/packages/devextreme/build/gulp/npm.js b/packages/devextreme/build/gulp/npm.js index 9f7f9d5e67bd..271e204aeca9 100644 --- a/packages/devextreme/build/gulp/npm.js +++ b/packages/devextreme/build/gulp/npm.js @@ -111,6 +111,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 b971ed40dfaa..ac02f34722ab 100644 --- a/packages/devextreme/eslint.config.mjs +++ b/packages/devextreme/eslint.config.mjs @@ -33,6 +33,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..ad1ba4de7b11 --- /dev/null +++ b/packages/devextreme/js/__internal/core/license/const.ts @@ -0,0 +1,9 @@ +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 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/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 { + 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); + }); +}); 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..8d5f223f95f3 --- /dev/null +++ b/packages/devextreme/js/__internal/core/license/lcp_key_validation/lcp_key_validator.ts @@ -0,0 +1,106 @@ +import { FORMAT } from '../const'; +import { + DESERIALIZATION_ERROR, + type ErrorToken, + GENERAL_ERROR, + PRODUCT_KIND_ERROR, + type Token, + TokenKind, + VERIFICATION_ERROR, +} from '../types'; +import { + LCP_SIGNATURE, + RSA_PUBLIC_KEY_XML, + SIGN_LENGTH, +} from './const'; +import { findLatestDevExtremeVersion } from './license_info'; +import { createProductInfo, type ProductInfo } from './product_info'; +import { 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]); + return createProductInfo( + version, + productsValue, + ); + }); + + 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) { + 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..66e40a5624e1 --- /dev/null +++ b/packages/devextreme/js/__internal/core/license/lcp_key_validation/license_info.ts @@ -0,0 +1,20 @@ +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 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..0d051610cdb0 --- /dev/null +++ b/packages/devextreme/js/__internal/core/license/lcp_key_validation/product_info.ts @@ -0,0 +1,18 @@ +/* eslint-disable no-bitwise */ +export interface ProductInfo { + readonly version: number; + readonly products: bigint; +} + +export function createProductInfo(version: number, products: bigint): ProductInfo { + return { version, products: BigInt(products) }; +} + +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..a851f87fd9e2 --- /dev/null +++ b/packages/devextreme/js/__internal/core/license/lcp_key_validation/utils.ts @@ -0,0 +1,69 @@ +/* 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); + +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.ts b/packages/devextreme/js/__internal/core/license/license_validation.ts index d70b39e923be..00ec7a15071d 100644 --- a/packages/devextreme/js/__internal/core/license/license_validation.ts +++ b/packages/devextreme/js/__internal/core/license/license_validation.ts @@ -9,7 +9,11 @@ import { parseVersion, } from '../../utils/version'; import { base64ToBytes } from './byte_utils'; +import { + BUY_NOW_LINK, FORMAT, KEY_SPLITTER, LICENSING_DOC_LINK, RTM_MIN_PATCH_VERSION, SUBSCRIPTION_NAMES, +} from './const'; import { INTERNAL_USAGE_ID, PUBLIC_KEY } from './key'; +import { isProductOnlyLicense, parseDevExpressProductKey } from './lcp_key_validation/lcp_key_validator'; import { pad } from './pkcs1'; import { compareSignatures } from './rsa_bigint'; import { sha1 } from './sha1'; @@ -19,30 +23,21 @@ import type { LicenseCheckParams, Token, } from './types'; -import { TokenKind } from './types'; +import { + DECODING_ERROR, + DESERIALIZATION_ERROR, + GENERAL_ERROR, + PAYLOAD_ERROR, + TokenKind, + VERIFICATION_ERROR, + VERSION_ERROR, +} 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' }; - let validationPerformed = false; // verifies RSASSA-PKCS1-v1.5 signature @@ -62,6 +57,10 @@ export function parseLicenseKey(encodedKey: string | undefined): Token { return GENERAL_ERROR; } + if (isProductOnlyLicense(encodedKey)) { + return parseDevExpressProductKey(encodedKey); + } + const parts = encodedKey.split(KEY_SPLITTER); if (parts.length !== 2 || parts[0].length === 0 || parts[1].length === 0) { @@ -119,8 +118,31 @@ 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); +} + +export function isUnsupportedKeyFormat(licenseKey: string | undefined): boolean { + if (!licenseKey) { + return false; + } + + if (hasLicensePrefix(licenseKey, 'LCXv1')) { + errors.log('W0000', 'config', 'licenseKey', 'LCXv1 is specified in the license key'); + return true; + } + if (hasLicensePrefix(licenseKey, 'egow')) { + errors.log('W0000', 'config', 'licenseKey', 'DevExtreme key is specified in the license key'); + return true; + } + + return false; +} + +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({ @@ -141,7 +163,7 @@ function getLicenseCheckParams({ return { preview, error: 'W0019' }; } - if (isDevExpressLicenseKey(licenseKey)) { + if (hasLicensePrefix(licenseKey, 'LCX')) { return { preview, error: 'W0024' }; } @@ -175,6 +197,11 @@ export function validateLicense(licenseKey: string, versionStr: string = fullVer } validationPerformed = true; + if (isUnsupportedKeyFormat(licenseKey)) { + displayTrialPanel(); + return; + } + const version = parseVersion(versionStr); const versionsCompatible = assertedVersionsCompatible(version); @@ -189,9 +216,7 @@ export function validateLicense(licenseKey: string, versionStr: string = fullVer } if (error && !internal) { - const buyNowLink = config().buyNowLink ?? BUY_NOW_LINK; - const licensingDocLink = config().licensingDocLink ?? LICENSING_DOC_LINK; - showTrialPanel(buyNowLink, licensingDocLink, fullVersion, SUBSCRIPTION_NAMES); + displayTrialPanel(); } const preview = isPreview(version.patch); diff --git a/packages/devextreme/js/__internal/core/license/license_validation_internal.ts b/packages/devextreme/js/__internal/core/license/license_validation_internal.ts index df552c7a8878..a61f9cc003d5 100644 --- a/packages/devextreme/js/__internal/core/license/license_validation_internal.ts +++ b/packages/devextreme/js/__internal/core/license/license_validation_internal.ts @@ -4,6 +4,9 @@ import type { Token } from './types'; // @ts-expect-error - only for internal usage export function parseLicenseKey(encodedKey: string | undefined): Token {} +// @ts-expect-error - only for internal usage +export function isUnsupportedKeyFormat(licenseKey: string | undefined): boolean {} + export function validateLicense(licenseKey: string, version?: string): void {} // @ts-expect-error - only for internal usage 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..4e38ccd812ee 100644 --- a/packages/devextreme/js/__internal/core/license/types.ts +++ b/packages/devextreme/js/__internal/core/license/types.ts @@ -10,19 +10,33 @@ export enum TokenKind { internal = 'internal', } -export type Token = { +export interface ErrorToken { + readonly kind: TokenKind.corrupted; + readonly error: 'general' | 'verification' | 'decoding' | 'deserialization' | 'payload' | 'version' | 'product-kind'; +} + +export interface VerifiedToken { readonly kind: TokenKind.verified; readonly payload: License; -} | { - readonly kind: TokenKind.corrupted; - readonly error: 'general' | 'verification' | 'decoding' | 'deserialization' | 'payload' | 'version'; -} | { +} + +export interface InternalToken { readonly kind: TokenKind.internal; readonly internalUsageId: string; -}; +} + +export type Token = ErrorToken | VerifiedToken | InternalToken; 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 interface LicenseCheckParams { preview: boolean; internal?: true; 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..7777037fb422 --- /dev/null +++ b/packages/devextreme/license/devextreme-license-plugin.js @@ -0,0 +1,93 @@ +const { createUnplugin } = require('unplugin'); +const { getDevExpressLCXKey } = require('./dx-get-lcx'); +const { tryConvertLCXtoLCP, getLCPWarning } = 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, warning) { + try { + if(ctx && typeof ctx.warn === 'function') { + ctx.warn(`${PLUGIN_PREFIX} DevExpress license key (LCX) retrieved from: ${source}`); + 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, MESSAGES.keyNotFound); + return (lcpCache = null); + } + + const warning = getLCPWarning(lcp); + if(warning) { + warnLicenseIssue(ctx, source, 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..face0c2824e3 --- /dev/null +++ b/packages/devextreme/license/devextreme-license.js @@ -0,0 +1,202 @@ +#!/usr/bin/env node + + +const fs = require('fs'); +const path = require('path'); + +const { getDevExpressLCXKey } = require('./dx-get-lcx'); +const { convertLCXtoLCP, getLCPWarning } = require('./dx-lcx-2-lcp'); +const { MESSAGES } = require('./messages'); + +const EXPORT_NAME = 'licenseKey'; +const TRIAL_VALUE = 'TRIAL'; + +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('-')) { + process.stderr.write('[devextreme-license] Warning: --out requires a path argument but none was provided. Ignoring --out.\n'); + } else { + out.outPath = args[++i]; + } + } + else if(a.startsWith('--out=')) { + const val = a.slice('--out='.length); + if(!val) { + process.stderr.write('[devextreme-license] Warning: --out requires a path argument but none was provided. Ignoring --out.\n'); + } 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') out.cwd = args[++i] || process.cwd(); + else if(a.startsWith('--cwd=')) out.cwd = a.slice('--cwd='.length); + 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 } = getDevExpressLCXKey() || {}; + + let lcp = TRIAL_VALUE; + + if(lcx) { + try { + lcp = convertLCXtoLCP(lcx); + const warning = getLCPWarning(lcp); + if(warning) { + process.stderr.write(`DevExpress license key (LCX) retrieved from: ${source}\n`); + process.stderr.write(`[devextreme-license] Warning: ${warning}\n`); + } + } catch{ + process.stderr.write(`DevExpress license key (LCX) retrieved from: ${source}\n`); + process.stderr.write(`[devextreme-license] Warning: ${MESSAGES.keyNotFound}\n`); + } + } else { + process.stderr.write(`[devextreme-license] Warning: ${MESSAGES.keyNotFound}\n`); + } + + 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..f8535e084e18 --- /dev/null +++ b/packages/devextreme/license/dx-get-lcx.js @@ -0,0 +1,102 @@ +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 getDevExpressLCXKey() { + if(hasEnvVar(LICENSE_ENV)) { + return { key: normalizeKey(process.env[LICENSE_ENV]), source: `env:${LICENSE_ENV}` }; + } + + if(hasEnvVar(LICENSE_PATH_ENV)) { + const licensePath = resolveFromLicensePathEnv(process.env[LICENSE_PATH_ENV]); + const key = normalizeKey(readTextFileIfExists(licensePath)); + return { key, source: key ? `file:${licensePath}` : `env:${LICENSE_PATH_ENV}` }; + } + + const defaultPath = getDefaultLicenseFilePath(); + const fromDefault = normalizeKey(readTextFileIfExists(defaultPath)); + if(fromDefault) { + return { key: fromDefault, source: `file:${defaultPath}` }; + } + + return { key: null, source: null }; +} + +module.exports = { + getDevExpressLCXKey, +}; 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..342dfaeca67d --- /dev/null +++ b/packages/devextreme/license/dx-lcx-2-lcp.js @@ -0,0 +1,248 @@ + + +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 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' }; + +function productsFromString(encodedString) { + if(!encodedString) { + return { products: [], errorToken: GENERAL_ERROR }; + } + try { + const productTuples = encodedString.split(';').slice(1).filter(e => e.length > 0); + const products = productTuples.map(tuple => { + const parts = tuple.split(','); + return { + version: Number.parseInt(parts[0], 10), + products: BigInt(parts[1]), + }; + }); + return { products }; + } catch{ + return { products: [], errorToken: DESERIALIZATION_ERROR }; + } +} + +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 } = productsFromString(decodedPayload); + if(errorToken) { + return errorToken; + } + + const maxVersionAllowed = findLatestDevExtremeVersion(products); + if(!maxVersionAllowed) { + return PRODUCT_KIND_ERROR; + } + + return { + kind: TokenKind.verified, + payload: { customerId: '', maxVersionAllowed }, + }; + } catch{ + return GENERAL_ERROR; + } +} + +function formatVersionCode(versionCode) { + return `v${Math.floor(versionCode / 10)}.${versionCode % 10}`; +} + +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 getLCPWarning(lcpString) { + const token = parseLCP(lcpString); + + if(token.kind === TokenKind.corrupted) { + if(token.error === 'product-kind') { + return MESSAGES.trial; + } + return null; + } + + // token.kind === TokenKind.verified — check version compatibility + const devExtremeVersion = readDevExtremeVersion(); + if(devExtremeVersion) { + const { major, minor, code: currentCode } = devExtremeVersion; + const { maxVersionAllowed } = token.payload; + if(maxVersionAllowed < currentCode) { + return MESSAGES.versionIncompatible( + formatVersionCode(maxVersionAllowed), + `v${major}.${minor}`, + ); + } + } + + return null; +} + +module.exports = { + convertLCXtoLCP, + tryConvertLCXtoLCP, + parseLCP, + 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..e110da4e5d17 --- /dev/null +++ b/packages/devextreme/license/messages.js @@ -0,0 +1,26 @@ +'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.', + + 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.', + + versionIncompatible: (keyVersion, requiredVersion) => + 'For evaluation purposes only. Redistribution prohibited. ' + + `Incompatible DevExpress license key version (${keyVersion}). ` + + `Download and register an updated DevExpress license key (${requiredVersion}+). ` + + 'Clear IDE/NuGet cache and rebuild your project (devexpress.com/DX1002).', + + resolveFailed: + 'Failed to resolve license key. Placeholder will remain.', +}); + +module.exports = { MESSAGES }; diff --git a/packages/devextreme/package.json b/packages/devextreme/package.json index 4a3b326af28e..ac67103364c7 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 944a73db8472..65c0688b56d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1360,6 +1360,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 @@ -17910,6 +17913,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==} @@ -24368,7 +24375,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 @@ -42999,6 +43006,12 @@ snapshots: picomatch: 4.0.3 webpack-virtual-modules: 0.6.2 + unplugin@3.0.0: + dependencies: + '@jridgewell/remapping': 2.3.5 + picomatch: 4.0.3 + webpack-virtual-modules: 0.6.2 + unquote@1.1.1: {} unrs-resolver@1.11.1: