Commit 3d5d92f0c9 for handsontable.com
commit 3d5d92f0c9d7db87a69ca8bf9fd8f7c7dafa3b63
Author: Krzysztof ‘Budzio’ Budnik <571316+budnix@users.noreply.github.com>
Date: Tue Oct 6 15:42:49 2026 +0200
DEV-3289: Read version 2 entitlement license keys, keep 4.x keys valid (#13760)
* DEV-3289: Read version 2 entitlement license keys, keep 4.x keys valid
Port the versioned key format of license-key PR #7 (reader byte-compatible
with vendor/entitlement-key-reader at 993d123, 748 of 748 variants equal):
- The block checksum is SHA-512(encodedPayload) in every version again, so
Handsontable 18.1 reads every newer key.
- A version 2 payload carries "v": 2 and "prose", a 64-hex digest of the
canonical prose. For v >= 2 the digest must match, the prose must not be
empty, and only whitespace may follow the block.
- A payload with no "v" is version 1 (license-key 4.x): nothing around its
block is checked, as in 18.1, so the trial keys already issued keep
working, also with a trailing period or quote pasted after the block.
- "v" and "prose" are read as own properties only.
- Whitespace and \n, \r, \t saved as text inside the block are ignored, so a
block an email client wrapped reads. On 18.1 such a key locks the grid.
Regenerate the fixtures with the license-key generator at 4c166fd from the
same records, add two 4.0.1 keys, and cover the version rules, wrapped
blocks, and own-property reads in unit tests and the Playwright license spec.
Update the license-key guide, the licenseKey API example, the reader's
AGENTS.md, and the unreleased #13743 changelog entry.
* DEV-3289: Add changelog entry for PR #13760
* DEV-3289: Keep the license key verification details out of the repo
Point the reader's AGENTS.md at the private license-key repository for how
a key is verified, and keep only the maintenance rules here. Make the code
comments, the test comments, and the license-key guide describe what a key
protects in general terms. No code changes.
* DEV-3289: Cover more tampered and pasted license key shapes
Add reader tests for truncated and empty stored values, a format version
that decodes to Infinity, a look-alike character in the text, two keys
pasted together, and malformed product entries in both key formats.
Tell 18.1 users in the license-key guide to pass the whole key before they
upgrade, since later versions check it more strictly.
* DEV-3289: Point the license key reader notes at license-key 5.1.0
* DEV-3289: Freeze the key reader result and add the 19.0 upgrade note
- Freeze the shared result of the key reader, as the canonical reader does,
so one caller cannot change what the next one reads.
- Limit the license-key guide's strict rules to newer keys, and add a
"License keys are checked more strictly" section to the 18.1 to 19.0
migration guide.
- Write the zero-width and no-break spaces in the reader tests as \u
escapes, so they show in editors and diffs.
(cherry picked from commit 68b5e970f95b6674664ddd3df2741fcb87371f81)
diff --git a/.changelogs/13743.json b/.changelogs/13743.json
index ac438c54c1..516f9dc25c 100644
--- a/.changelogs/13743.json
+++ b/.changelogs/13743.json
@@ -1,6 +1,6 @@
{
"issuesOrigin": "private",
- "title": "Changed the entitlement license key checksum to cover the human-readable text of the key as well as its bracketed block, so the block alone, edited text, or text after the block makes the key invalid, while a rewrapped, one-line, or `\\n`-escaped key still works.",
+ "title": "Changed entitlement license keys to protect their human-readable text as well as their bracketed block, so the block alone, edited text, or text after the block makes a current key invalid, while a rewrapped, one-line, or `\\n`-escaped key still works.",
"type": "changed",
"issueOrPR": 13743,
"breaking": false,
diff --git a/.changelogs/13760.json b/.changelogs/13760.json
new file mode 100644
index 0000000000..5278291277
--- /dev/null
+++ b/.changelogs/13760.json
@@ -0,0 +1,8 @@
+{
+ "issuesOrigin": "private",
+ "title": "Fixed entitlement license keys whose bracketed block was broken across lines, for example by an email client, reading as invalid and locking the grid.",
+ "type": "fixed",
+ "issueOrPR": 13760,
+ "breaking": false,
+ "framework": "none"
+}
diff --git a/docs/content/guides/getting-started/license-key/license-key.md b/docs/content/guides/getting-started/license-key/license-key.md
index 1873c30b50..28f1ca284d 100644
--- a/docs/content/guides/getting-started/license-key/license-key.md
+++ b/docs/content/guides/getting-started/license-key/license-key.md
@@ -292,13 +292,20 @@ const licenseKey = `This is a Handsontable license key for Acme Corp, issued on
Keys issued in the 25-character format keep working without any change.
-The checksum protects the whole key: the text and the bracketed block. If you edit the text, remove
-it, or paste the bracketed block alone, the key is invalid. The checksum ignores spaces and line
-breaks in the text, so you can rewrap it, paste it through an email client, or put the whole key on
-one line, and the key still works. Line breaks saved as the characters `\n`, as some `.env` files
-and CI secret fields store them, work too. Keep the block itself on one line, and end the key
-there - only whitespace, or line breaks saved as `\n`, may follow the block. Space and line
-breaks around the whole key are trimmed for you, so a key pasted with a trailing newline still works.
+Pass the key unchanged. A newer key protects its text as well as the bracketed block, so editing
+the text, removing it, pasting the bracketed block alone, or adding text after the block makes it
+invalid. Keys issued earlier keep working the way they did in Handsontable 18.1.
+Spaces and line breaks are ignored, in the text and inside the block, so you can rewrap the key,
+paste it out of an email that broke the block across lines, or put the whole key on one line, and the
+key still works. Line breaks saved as the characters `\n`, as some `.env` files and CI secret fields
+store them, work too. End the key with the block - only whitespace, or line breaks saved as `\n`,
+may follow it. Space and line breaks around the whole key are trimmed for you, so a key pasted with a
+trailing newline still works.
+
+Handsontable 18.1 does not ignore line breaks inside the block. If you use 18.1, make sure the block
+is on one line, with no spaces in it. Later versions check newer keys more strictly than 18.1, so
+before you upgrade from 18.1, make sure you pass the whole key, exactly as you received it - not
+only the bracketed block, and with nothing after it.
When you store the key in an environment variable, a `.env` file, or a CI secret, put it on one
line. In a `.env` file, also wrap the key in single quotes (`'...'`), or in backticks if the key text
diff --git a/handsontable/src/dataMap/metaManager/metaSchema.ts b/handsontable/src/dataMap/metaManager/metaSchema.ts
index 96f1ac6ffe..0a0938dfb0 100644
--- a/handsontable/src/dataMap/metaManager/metaSchema.ts
+++ b/handsontable/src/dataMap/metaManager/metaSchema.ts
@@ -3853,8 +3853,8 @@ export default (): Record<string, unknown> => {
* licenseKey: 'xxxxx-xxxxx-xxxxx-xxxxx-xxxxx', // your commercial license key
*
* // for an entitlement license key (trial, subscription, or perpetual),
- * // pass the whole key string exactly as you received it – the checksum
- * // covers the text too, so the `[...]` block on its own is not a valid key;
+ * // pass the whole key string exactly as you received it – the key
+ * // protects its text too, so the `[...]` block on its own is not a valid key;
* // the text contains quotes, so use a template literal
* licenseKey: `This is a Handsontable license key for Acme Corp, ... for the "Acme Portal" project. ... [eyJwcm9kdWN0cyI6...3a4f8361]`,
*
diff --git a/handsontable/src/utils/entitlementLicenseKey/__tests__/buildTestKey.js b/handsontable/src/utils/entitlementLicenseKey/__tests__/buildTestKey.js
index 31c5b92f9d..7c3bfc6b68 100644
--- a/handsontable/src/utils/entitlementLicenseKey/__tests__/buildTestKey.js
+++ b/handsontable/src/utils/entitlementLicenseKey/__tests__/buildTestKey.js
@@ -1,9 +1,9 @@
-import { canonicalizeProse, computeChecksum } from '../extractKeyData';
-import { stringToBase64Url } from '../encoding';
+import { canonicalizeProse, computePayloadChecksum, computeProseDigest } from '../extractKeyData';
+import { base64ToString, stringToBase64Url } from '../encoding';
/**
- * The prose a test key carries unless a test passes its own. The checksum covers the prose, and an
- * empty prose makes a key invalid, so every test key needs some.
+ * The text a test key carries unless a test passes its own. A current key with no text is invalid,
+ * so every test key needs some.
*
* @type {string}
*/
@@ -31,35 +31,57 @@ export function proseOf(key) {
}
/**
- * A minimal, TEST-ONLY entitlement license key builder. It assembles the key - the prose, then the
- * machine-readable block (the base64url payload plus its checksum, wrapped in brackets) - exactly
- * as the reader parses it, so tests can forge keys for the adversarial and edge cases the real
- * generator refuses to produce: both dates on one product, neither of them, a malformed window, an
- * unknown capability token, a tampered checksum, boundary dates.
+ * Returns the decoded payload of a key, as the object the generator serialized.
*
- * It is deliberately NOT the real generator. Generation - the prose, the schema, the strict record
+ * @param {string} key The whole key.
+ * @returns {object}
+ */
+export function payloadOf(key) {
+ const block = blockOf(key);
+ const encodedPayload = block.slice(1, block.indexOf(']')).replace(/\s+/g, '').slice(0, -128);
+
+ return JSON.parse(base64ToString(encodedPayload));
+}
+
+/**
+ * A minimal, TEST-ONLY entitlement license key builder, so tests can build keys for the
+ * adversarial and edge cases the real generator refuses to produce: both dates on one product,
+ * neither of them, a malformed window, an unknown capability token, a tampered key, boundary dates.
+ *
+ * It is deliberately NOT the real generator. Generation - the text, the schema, the strict record
* validation - stays in the private `license-key` repository; duplicating it here would create a
* second source of truth that drifts. Keys that a real generator CAN produce come from it instead,
- * as the fixtures in `./fixtures.js`. Because this builder computes the checksum with the reader's
- * own `canonicalizeProse`, it cannot catch a canonicalization bug - only a generated fixture can.
+ * as the fixtures in `./fixtures.js`. This builder uses the reader's own helpers, so it cannot catch
+ * a mismatch between the reader and the generator - only a generated fixture can.
*
- * This is not a security concern: the checksum recipe already ships in every Handsontable bundle by
- * design (there is no key material - the protection model is legal and contractual, the same as the
- * legacy mod-97 keys). It lives under `__tests__/` and is never imported from `src/`, so it cannot
- * reach the production bundle.
+ * By default it builds a key in the current format; `version: 1` builds one in the earlier format.
+ * It lives under `__tests__/` and is never imported from `src/`, so it cannot reach the production
+ * bundle.
*
* @param {object} payload The payload object to serialize.
* @param {object} [options] Build options.
- * @param {string} [options.prose] The prose to put in front of the block, and to checksum. Pass an
- * empty string to build the bare `[...]` block, which the reader must reject.
+ * @param {string} [options.prose] The text to put in front of the block. Pass an empty string to
+ * build the bare `[...]` block.
+ * @param {number} [options.version] The format version: 2 (the default) or 1.
* @param {string} [options.checksum] A checksum to use instead of the correct one, for tamper tests.
* @param {string} [options.rawPayloadJson] The payload JSON to encode verbatim, for the values
* `JSON.stringify` cannot produce (`1e999`, a duplicate key).
* @returns {string} The assembled license key.
*/
-export function buildTestKey(payload, { prose = TEST_KEY_PROSE, checksum, rawPayloadJson } = {}) {
- const encodedPayload = stringToBase64Url(rawPayloadJson ?? JSON.stringify(payload));
- const block = `[${encodedPayload}${checksum ?? computeChecksum(canonicalizeProse(prose), encodedPayload)}]`;
+export function buildTestKey(payload, { prose = TEST_KEY_PROSE, version = 2, checksum, rawPayloadJson } = {}) {
+ const fullPayload = { ...payload };
+
+ if (version >= 2) {
+ if (!Object.prototype.hasOwnProperty.call(fullPayload, 'v')) {
+ fullPayload.v = version;
+ }
+ if (!Object.prototype.hasOwnProperty.call(fullPayload, 'prose')) {
+ fullPayload.prose = computeProseDigest(canonicalizeProse(prose));
+ }
+ }
+
+ const encodedPayload = stringToBase64Url(rawPayloadJson ?? JSON.stringify(fullPayload));
+ const block = `[${encodedPayload}${checksum ?? computePayloadChecksum(encodedPayload)}]`;
return prose === '' ? block : `${prose}\n\n${block}`;
}
diff --git a/handsontable/src/utils/entitlementLicenseKey/__tests__/extractKeyData.unit.js b/handsontable/src/utils/entitlementLicenseKey/__tests__/extractKeyData.unit.js
index a52c82d410..cbed3e0205 100644
--- a/handsontable/src/utils/entitlementLicenseKey/__tests__/extractKeyData.unit.js
+++ b/handsontable/src/utils/entitlementLicenseKey/__tests__/extractKeyData.unit.js
@@ -1,10 +1,17 @@
-import { extractEntitlementKeyData, getProductEntitlement, canonicalizeProse } from '../extractKeyData';
+import {
+ extractEntitlementKeyData,
+ getProductEntitlement,
+ canonicalizeProse,
+ computeProseDigest,
+} from '../extractKeyData';
import { detectLicenseKeyFormat, isEntitlementKey } from '../detectFormat';
import { sha512 } from '../sha512';
import { stringToUtf8Bytes } from '../encoding';
-import { buildTestKey, blockOf, proseOf } from './buildTestKey';
+import { buildTestKey, blockOf, proseOf, payloadOf } from './buildTestKey';
import {
SUBSCRIPTION_KEY,
+ V1_SUBSCRIPTION_KEY,
+ V1_TRIAL_KEY,
ACCENTED_HOLDER_KEY,
CJK_HOLDER_KEY,
SUBSCRIPTION_EXTERNAL_KEY,
@@ -126,7 +133,7 @@ describe('entitlementLicenseKey/extractKeyData', () => {
it('should read a key whose product map is empty, granting nothing (H5)', () => {
const data = extractEntitlementKeyData(buildTestKey({ products: {} }));
- expect(data).toEqual({ products: {} });
+ expect(data).toEqual({ version: 2, products: {} });
expect(getProductEntitlement(data, 'handsontable')).toBeNull();
});
@@ -142,12 +149,12 @@ describe('entitlementLicenseKey/extractKeyData', () => {
describe('the prose layer', () => {
const expected = () => extractEntitlementKeyData(SUBSCRIPTION_KEY);
- it('should reject the bare block of a real key, whose checksum was computed over its prose', () => {
+ it('should reject the bare block of a real key, whose prose digest covers its prose', () => {
expect(extractEntitlementKeyData(blockOf(SUBSCRIPTION_KEY))).toBeNull();
expect(extractEntitlementKeyData(` \n${blockOf(SUBSCRIPTION_KEY)}\n`)).toBeNull();
});
- it('should reject a bare block whose checksum was computed over empty prose', () => {
+ it('should reject a bare block whose prose digest was computed over empty prose', () => {
const key = buildTestKey({ products: { handsontable: handsontableEntry() } }, { prose: '' });
expect(key.startsWith('[')).toBe(true);
@@ -226,9 +233,9 @@ describe('entitlementLicenseKey/extractKeyData', () => {
});
});
- // The expected verdicts below come from the license-key validator at bc03d89, run on the same
- // variants of this generated key. They pin the reader to the generator: `buildTestKey` checksums
- // with this reader's own `canonicalizeProse`, so it would agree with any change to it.
+ // The expected verdicts below come from the canonical license-key reader, run on the same
+ // variants of this generated key. They pin the reader to the generator: `buildTestKey` uses
+ // this reader's own helpers, so it would agree with any change to them.
it('should ignore exactly the whitespace characters the generator ignores', () => {
const prose = proseOf(SUBSCRIPTION_KEY);
const block = blockOf(SUBSCRIPTION_KEY);
@@ -291,14 +298,51 @@ describe('entitlementLicenseKey/extractKeyData', () => {
});
});
- it('should reject a key whose block was broken by a line wrap', () => {
+ it('should read a key whose block was broken by a line wrap, as a mail client does', () => {
const block = blockOf(SUBSCRIPTION_KEY);
const prose = proseOf(SUBSCRIPTION_KEY);
- ['\n', '\r\n', ' ', '\\n'].forEach((separator) => {
- const wrapped = `${prose}${block.slice(0, 60)}${separator}${block.slice(60)}`;
+ ['\n', '\r\n', ' ', '\t', '\u00a0', '\\n', '\\r\\n'].forEach((separator) => {
+ // Near the start of the block, and near its end.
+ [60, block.length - 40].forEach((at) => {
+ const wrapped = `${prose}${block.slice(0, at)}${separator}${block.slice(at)}`;
+
+ expect(extractEntitlementKeyData(wrapped)).toEqual(expected());
+ });
+ });
+
+ // Every 60 characters, the way the license email wraps the block.
+ const everySixty = block.match(/.{1,60}/g).join('\n');
+
+ expect(extractEntitlementKeyData(prose + everySixty)).toEqual(expected());
+ });
+
+ it('should ignore inside the block exactly the whitespace characters it ignores in the prose', () => {
+ // The same 25 characters and the same 4 exceptions as the text test below, as in the
+ // canonical reader.
+ const block = blockOf(SUBSCRIPTION_KEY);
+ const prose = proseOf(SUBSCRIPTION_KEY);
+ const ignored = [0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x20, 0xa0, 0x1680, 0x2000, 0x2001, 0x2002, 0x2003,
+ 0x2004, 0x2005, 0x2006, 0x2007, 0x2008, 0x2009, 0x200a, 0x2028, 0x2029, 0x202f, 0x205f, 0x3000, 0xfeff];
+ const kept = [0x85, 0x180e, 0x200b, 0x2060];
+ const variant = code => `${prose}${block.slice(0, 60)}${String.fromCharCode(code)}${block.slice(60)}`;
+
+ ignored.forEach((code) => {
+ expect(extractEntitlementKeyData(variant(code))).toEqual(expected());
+ });
+ kept.forEach((code) => {
+ expect(extractEntitlementKeyData(variant(code))).toBeNull();
+ });
+ });
+
+ it('should reject a block broken by anything but whitespace', () => {
+ const block = blockOf(SUBSCRIPTION_KEY);
+ const prose = proseOf(SUBSCRIPTION_KEY);
- expect(extractEntitlementKeyData(wrapped)).toBeNull();
+ ['-', '=', '\\', '\\x', '\u200b', '>'].forEach((separator) => {
+ const broken = `${prose}${block.slice(0, 60)}${separator}${block.slice(60)}`;
+
+ expect(extractEntitlementKeyData(broken)).toBeNull();
});
});
@@ -345,14 +389,194 @@ describe('entitlementLicenseKey/extractKeyData', () => {
expect(extractEntitlementKeyData(prose.replace(holder, holder.slice(1)) + block)).toBeNull();
});
- it('should never let the prose and the payload trade characters across the boundary', () => {
- // The canonical prose has no whitespace and no "\n", so the "\n" the checksum puts between the
- // two parts cannot be forged from either side.
+ it('should digest the prose with every whitespace character and escaped line break removed', () => {
expect(canonicalizeProse(proseOf(SUBSCRIPTION_KEY))).not.toMatch(/\s/);
expect(canonicalizeProse('a \\n b\n\tc\u3000d')).toBe('abcd');
});
});
+ describe('format versions', () => {
+ const entry = handsontableEntry();
+ const payload = { products: { handsontable: entry } };
+
+ it('should read a key generated with a format version as version 2', () => {
+ expect(extractEntitlementKeyData(SUBSCRIPTION_KEY).version).toBe(2);
+ expect(payloadOf(SUBSCRIPTION_KEY)).toEqual(expect.objectContaining({ v: 2 }));
+ expect(payloadOf(SUBSCRIPTION_KEY).prose).toMatch(/^[0-9a-f]{64}$/);
+ });
+
+ it('should keep reading the keys license-key 4.x issued, as version 1', () => {
+ const subscription = extractEntitlementKeyData(V1_SUBSCRIPTION_KEY);
+ const trial = extractEntitlementKeyData(V1_TRIAL_KEY);
+
+ expect(payloadOf(V1_TRIAL_KEY)).not.toHaveProperty('v');
+ expect(payloadOf(V1_TRIAL_KEY)).not.toHaveProperty('prose');
+ expect(subscription).toEqual({ version: 1, products: extractEntitlementKeyData(SUBSCRIPTION_KEY).products });
+ expect(trial).toEqual({ version: 1, products: extractEntitlementKeyData(TRIAL_KEY).products });
+ });
+
+ it('should not check the prose of a version 1 key, as license-key 4.x did not cover it', () => {
+ const prose = proseOf(V1_TRIAL_KEY);
+ const block = blockOf(V1_TRIAL_KEY);
+ const expected = extractEntitlementKeyData(V1_TRIAL_KEY);
+
+ expect(extractEntitlementKeyData(prose.replace('Test Fixture', 'Someone Else') + block)).toEqual(expected);
+ expect(extractEntitlementKeyData(block)).toEqual(expected);
+ });
+
+ it('should read a version 1 key with text after the block, as Handsontable 18.1 does', () => {
+ // A trial key pasted out of an email often keeps the sentence's period or a closing quote.
+ // 18.1 reads such a key, so rejecting it would only lock grids 18.1 runs.
+ const expected = extractEntitlementKeyData(V1_TRIAL_KEY);
+
+ ['.', '"', '\'', ' x', '\n-- \nSent from my phone'].forEach((suffix) => {
+ expect(extractEntitlementKeyData(V1_TRIAL_KEY + suffix)).toEqual(expected);
+ });
+ // A current key protects its text, so the same suffix still makes it invalid.
+ expect(extractEntitlementKeyData(`${TRIAL_KEY}.`)).toBeNull();
+ });
+
+ it('should read the format version and the prose digest from the payload\'s own fields only', () => {
+ const v2Expected = extractEntitlementKeyData(TRIAL_KEY);
+
+ // Each read gets a key string of its own (trailing spaces), so the one-entry memo cannot
+ // answer from a read made before the prototype changed.
+ [2, 3, 'x', 1, null].forEach((value, index) => {
+ const padding = ' '.repeat(index + 1);
+
+ Object.prototype.v = value; // eslint-disable-line no-extend-native
+
+ try {
+ expect(extractEntitlementKeyData(V1_TRIAL_KEY + padding))
+ .toEqual({ version: 1, products: extractEntitlementKeyData(TRIAL_KEY).products });
+ } finally {
+ delete Object.prototype.v;
+ }
+ });
+
+ // An inherited value cannot stand in for a missing one, nor replace the key's own.
+ const withoutDigest = buildTestKey({ ...payload, prose: undefined });
+
+ Object.prototype.prose = payloadOf(buildTestKey(payload)).prose; // eslint-disable-line no-extend-native
+
+ try {
+ expect(extractEntitlementKeyData(withoutDigest)).toBeNull();
+ expect(extractEntitlementKeyData(`${TRIAL_KEY} `)).toEqual(v2Expected);
+ } finally {
+ delete Object.prototype.prose;
+ }
+ });
+
+ it('should read a version 1 key whose block a mail client wrapped', () => {
+ const prose = proseOf(V1_TRIAL_KEY);
+ const block = blockOf(V1_TRIAL_KEY);
+ const expected = extractEntitlementKeyData(V1_TRIAL_KEY);
+
+ ['\n', '\r\n', ' ', '\\n'].forEach((separator) => {
+ expect(extractEntitlementKeyData(prose + block.match(/.{1,60}/g).join(separator))).toEqual(expected);
+ });
+ });
+
+ it('should close every block with the checksum a version 1 reader verifies (Handsontable 18.1)', () => {
+ // This is what lets Handsontable 18.1 read a key in the current format.
+ [SUBSCRIPTION_KEY, TRIAL_KEY, MIXED_KEY, CJK_HOLDER_KEY, V1_SUBSCRIPTION_KEY, V1_TRIAL_KEY].forEach((key) => {
+ const content = blockOf(key).slice(1, -1);
+
+ expect(sha512(stringToUtf8Bytes(content.slice(0, -128)))).toBe(content.slice(-128));
+ });
+ });
+
+ it('should reject a version 2 key whose prose digest is wrong, missing, or not a string', () => {
+ [
+ 'f'.repeat(64),
+ payloadOf(TRIAL_KEY).prose, // another key's digest
+ payloadOf(SUBSCRIPTION_KEY).prose.toUpperCase(),
+ payloadOf(SUBSCRIPTION_KEY).prose.slice(0, 63),
+ payloadOf(SUBSCRIPTION_KEY).prose.slice(0, 32),
+ '',
+ undefined, // dropped by JSON.stringify
+ null,
+ 64,
+ [payloadOf(SUBSCRIPTION_KEY).prose],
+ ].forEach((prose) => {
+ expect(extractEntitlementKeyData(buildTestKey({ ...payload, prose }, { prose: proseOf(SUBSCRIPTION_KEY) })))
+ .toBeNull();
+ });
+ });
+
+ it('should reject a version 2 key that drops its prose, even with a digest of empty prose', () => {
+ const bare = buildTestKey(payload, { prose: '' });
+
+ expect(payloadOf(bare).prose).toMatch(/^[0-9a-f]{64}$/);
+ expect(extractEntitlementKeyData(bare)).toBeNull();
+ });
+
+ it('should reject a format version that no generator writes', () => {
+ [0, 1, -2, 1.5, 2.5, '2', null, true, [2], {}].forEach((v) => {
+ expect(extractEntitlementKeyData(buildTestKey({ ...payload, v }))).toBeNull();
+ });
+ });
+
+ it('should reject a format version that decodes to Infinity', () => {
+ // `1e999` parses to Infinity; it must not pass as a whole number. The same raw payload with
+ // `"v":2` reads, so only the version can reject it.
+ const prose = 'This is a test license key.';
+ const digest = computeProseDigest(canonicalizeProse(prose));
+ const products = JSON.stringify(payload).slice(0, -1);
+ const withVersion = v => `${products},"v":${v},"prose":"${digest}"}`;
+
+ expect(extractEntitlementKeyData(buildTestKey(null, { prose, rawPayloadJson: withVersion('2') }))).not.toBeNull();
+ expect(extractEntitlementKeyData(buildTestKey(null, { prose, rawPayloadJson: withVersion('1e999') }))).toBeNull();
+ });
+
+ it('should not fold a compatibility character into another one (NFC, not NFKC)', () => {
+ // A fullwidth "F" is a different character; only NFKC would turn it into an ASCII "F".
+ const fullwidthF = String.fromCharCode(0xff26);
+
+ expect(extractEntitlementKeyData(SUBSCRIPTION_KEY.replace('Fixture', `${fullwidthF}ixture`))).toBeNull();
+ });
+
+ it('should read the last block when two keys were pasted together', () => {
+ const read = key => extractEntitlementKeyData(key);
+ const v1Trial = read(V1_TRIAL_KEY);
+
+ // A current key followed by an earlier-format one reads as the earlier-format key, the way
+ // Handsontable 18.1 reads it.
+ expect(read(`${TRIAL_KEY}\n\n${V1_TRIAL_KEY}`)).toEqual(v1Trial);
+ expect(read(`${V1_SUBSCRIPTION_KEY}\n\n${V1_TRIAL_KEY}`)).toEqual(v1Trial);
+ expect(read(`${TRIAL_KEY}\n\n${blockOf(V1_TRIAL_KEY)}`)).toEqual(v1Trial);
+ // A current key at the end protects its text, which now includes the first key.
+ expect(read(`${V1_TRIAL_KEY}\n\n${TRIAL_KEY}`)).toBeNull();
+ expect(read(`${SUBSCRIPTION_KEY}\n\n${TRIAL_KEY}`)).toBeNull();
+ });
+
+ it('should reject a malformed product entry in either format', () => {
+ const malformed = [
+ handsontableEntry({ release_until: SUBSCRIPTION_UNTIL }), // both dates
+ handsontableEntry({ usage_until: '2027-02-30' }),
+ handsontableEntry({ notice: -1 }),
+ handsontableEntry({ grace: 1.5 }),
+ handsontableEntry({ flags: 'trial' }),
+ ];
+
+ [1, 2].forEach((version) => {
+ malformed.forEach((malformedEntry) => {
+ expect(extractEntitlementKeyData(buildTestKey({ products: { handsontable: malformedEntry } }, { version })))
+ .toBeNull();
+ });
+ // The well-formed entry reads in the same format, so only the entry can reject it.
+ expect(extractEntitlementKeyData(buildTestKey(payload, { version }))).not.toBeNull();
+ });
+ });
+
+ it('should read a newer format version, still checking its prose digest', () => {
+ const key = buildTestKey({ ...payload, v: 3, seats: 25 });
+
+ expect(extractEntitlementKeyData(key)).toEqual({ version: 3, products: { handsontable: entry } });
+ expect(extractEntitlementKeyData(key.replace('test license', 'tested license'))).toBeNull();
+ });
+ });
+
describe('integrity', () => {
it('should reject a key whose checksum does not match its payload', () => {
const key = buildTestKey({ products: { handsontable: handsontableEntry() } });
@@ -362,7 +586,7 @@ describe('entitlementLicenseKey/extractKeyData', () => {
});
it('should reject a key whose payload was edited under an intact-looking block', () => {
- // The last character of the encoded payload, one position before the 128-character checksum.
+ // The last character of the encoded payload, one position before the checksum.
const payloadEnd = SUBSCRIPTION_KEY.length - 1 - 128;
const tampered = `${SUBSCRIPTION_KEY.slice(0, payloadEnd - 1)}X${SUBSCRIPTION_KEY.slice(payloadEnd)}`;
@@ -542,9 +766,42 @@ describe('entitlementLicenseKey/extractKeyData', () => {
expect(extractEntitlementKeyData(SUBSCRIPTION_KEY)).toBe(extractEntitlementKeyData(SUBSCRIPTION_KEY));
});
+ it('should freeze the shared result, so one caller cannot change what the next one reads', () => {
+ const data = extractEntitlementKeyData(MIXED_KEY);
+ const entry = getProductEntitlement(data, 'handsontable');
+
+ [data, data.products, entry, entry.capabilities, entry.flags].forEach((part) => {
+ expect(Object.isFrozen(part)).toBe(true);
+ });
+ expect(() => entry.capabilities.push('solver')).toThrow(TypeError);
+ expect(() => { entry.usage_until = '2099-01-01'; }).toThrow(TypeError);
+ expect(getProductEntitlement(extractEntitlementKeyData(MIXED_KEY), 'handsontable').capabilities)
+ .toEqual(['core']);
+ });
+
+ it('should freeze a deeply nested extra field without throwing', () => {
+ // The freeze walks with its own stack, so a key nesting an unknown field thousands of levels
+ // deep still reads as data instead of overflowing the call stack.
+ const depth = 20000;
+ const nested = `${'{"a":'.repeat(depth)}1${'}'.repeat(depth)}`;
+ const products = JSON.stringify({ handsontable: handsontableEntry() }).slice(0, -2);
+ const rawPayloadJson = `{"products":${products},"extra":${nested}}}}`;
+ const data = extractEntitlementKeyData(buildTestKey(null, { rawPayloadJson, version: 1 }));
+
+ expect(data).not.toBeNull();
+
+ let inner = getProductEntitlement(data, 'handsontable').extra;
+
+ while (inner.a !== 1) {
+ inner = inner.a;
+ }
+
+ expect(Object.isFrozen(inner)).toBe(true);
+ });
+
it('should read a date that cannot be turned into text as invalid, without throwing', () => {
// An object whose `toString` is not a function throws when it is turned into a string. The
- // checksum recipe ships in the bundle, so such a key can carry a valid checksum.
+ // reader must still return `null` for it, never throw.
const crafted = buildTestKey({
products: { handsontable: handsontableEntry({ usage_until: { toString: 1, valueOf: 1 } }) },
});
diff --git a/handsontable/src/utils/entitlementLicenseKey/__tests__/fixtures.js b/handsontable/src/utils/entitlementLicenseKey/__tests__/fixtures.js
index f1ebb75be4..2f1904c65a 100644
--- a/handsontable/src/utils/entitlementLicenseKey/__tests__/fixtures.js
+++ b/handsontable/src/utils/entitlementLicenseKey/__tests__/fixtures.js
@@ -10,11 +10,13 @@
* The holder is the obviously-fake "Test Fixture" on purpose, and every fixture mirrors one of the
* worked examples of the license specification (the example id is named on each).
*
- * Each `*_KEY` constant is the whole key, the prose and the block, because the checksum covers both:
- * the block on its own is not a valid key. The keys were generated by `license-key` at commit
- * 7ca2899 (DEV-3253, before the 5.0.0 tag) and carry exactly the payloads of the earlier 4.x
- * fixtures. The checksum rules of the reader here match bc03d89, and every key below still
- * validates there. Regenerate them if the checksum recipe changes before that release.
+ * Each `*_KEY` constant is the whole key, the text and the block. Every key except the `V1_*` ones
+ * is in the current format, which protects its text, so the block on its own is not a valid key.
+ * They were generated by the `license-key` generator released as 5.1.0 (the same output as its
+ * pre-release commit 4c166fd) from the same records as the earlier fixtures, so the text and the
+ * products are unchanged; the reader matches the canonical reader of `license-key` 5.1.0. The
+ * `V1_*` keys were generated by `license-key` 4.0.1, in the earlier format - the one
+ * the trial keys already issued use. Handsontable 18.1 reads both formats.
*
* Pin `Date.now` in tests; never rely on the real clock.
*/
@@ -39,7 +41,7 @@ export const SUBSCRIPTION_KEY = `This is a Handsontable license key for Test Fix
> 1. Subscription license under Handsontable Subscription License Agreement 2.0 of 2022-05-21, for Handsontable on the Enterprise package, for internal use, valid until 2027-08-12 (UTC). Use after that date is not permitted. To renew, contact sales@handsontable.com.
-[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjo2MCwiZ3JhY2UiOjkwLCJmbGFncyI6W119fX0c4401c19afaf11f9c2f8df05b6aa3dc4bad6cdbbf7a535e77d4e3d95c137cf9d59a05bbc73ec35f6bdce3e3a1cfc18170c9662c877ee0477e2615fe32ab1b044]`;
+[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjo2MCwiZ3JhY2UiOjkwLCJmbGFncyI6W119fSwidiI6MiwicHJvc2UiOiIwNjIzNWUxYjY5ZmQ1YjNmNjg2NjdhNTcxNmU5YWY5YWU5YTkxYjNkNzc5ZTBlY2FkODIyMjQ2NDU1YjUxZTA0In0e9c5c2a5838f3daf149124a7ad1a4e30710d8367b4814bb1a0092032677fa10fb605cb4325db23ae9078a30024d5df992c12074ab7a31321ef237d22573316fc]`;
/**
* As above, issued for external use, so both silencing flags are set (example A2).
@@ -50,7 +52,7 @@ export const SUBSCRIPTION_EXTERNAL_KEY = `This is a Handsontable license key for
> 1. Subscription license under Handsontable Subscription License Agreement 2.0 of 2022-05-21, for Handsontable on the Enterprise package, for external use, valid until 2027-08-12 (UTC). Use after that date is not permitted. To renew, contact sales@handsontable.com.
-[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjo2MCwiZ3JhY2UiOjkwLCJmbGFncyI6WyJuby1jb25zb2xlLXdhcm5zIiwibm8tdWktd2FybnMiXX19fQ54053e73eda38c08afbd0554d564ecf1d7b03bb93ea2ca739e4cd441049667a256a3b28f595e2e265373356f2a065aa70721a6594ff1441c13e400cc3a093c37]`;
+[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjo2MCwiZ3JhY2UiOjkwLCJmbGFncyI6WyJuby1jb25zb2xlLXdhcm5zIiwibm8tdWktd2FybnMiXX19LCJ2IjoyLCJwcm9zZSI6IjM1MTdlM2I0OTcyN2Q4MTRlZThlYzY5YWZlOWIwZjJjMWE3YWFmOTYwODc5ZTJlYTNlOTI3NzRhMjg5NDcwNmYifQd5c0390306d9214d72e9d2fe4cb70b658e29c92084b0be19ef2cf00fb79f02c408532752361d68c43e596701f0d56f7f5b41670f37f511fbf43e175b868a6c6e]`;
/**
* Handsontable trial, `usage_until` 2026-09-26, notice 45, grace 15, flag `trial` (example A3).
@@ -61,7 +63,7 @@ export const TRIAL_KEY = `This is a Handsontable license key for Test Fixture, i
> 1. Trial license under Handsontable Evaluation License Agreement 4.0 of 2026-03-02, for Handsontable on the Enterprise package, for internal use, valid until 2026-09-26 (UTC). Not licensed for production use. To purchase a license, contact sales@handsontable.com.
-[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI2LTA5LTI2Iiwibm90aWNlIjo0NSwiZ3JhY2UiOjE1LCJmbGFncyI6WyJ0cmlhbCJdfX195f2c28b185a0f34f2c85b97568c44f8930d30dd58f4901815807d550bc45716c98c6e9d01ea036efb6cfda123b7dfb7904cb5e946e0a407c0f5329e72691d715]`;
+[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI2LTA5LTI2Iiwibm90aWNlIjo0NSwiZ3JhY2UiOjE1LCJmbGFncyI6WyJ0cmlhbCJdfX0sInYiOjIsInByb3NlIjoiMzg3MzYxOTE5Yzc1YTQ0NTA4NGZmNmM1MmYwMmM0ZDJjNmMyMTVhYzc2YjFkMGRiYTZiMDkzODFmMDkwNjg1YSJ973a630de3fb4f97dd3d82647b57149489e48114b83ac7d5ca92d370a0be18246eecfedca9c2671de91a39840c8c2b6964b5756bf3fc2b7adbe0718167ea55b50]`;
/**
* Handsontable, `release_until` 2027-08-12, notice 0, grace 0 (example A4).
@@ -72,7 +74,7 @@ export const PERPETUAL_KEY = `This is a Handsontable license key for Test Fixtur
> 1. Perpetual license under Handsontable Perpetual License Agreement 2.0 of 2022-05-21, for Handsontable on the Enterprise package, for internal use. Maintenance and access to new versions both end on 2027-08-12. Versions released on or before that date may be used indefinitely. To renew maintenance, contact sales@handsontable.com.
-[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwicmVsZWFzZV91bnRpbCI6IjIwMjctMDgtMTIiLCJub3RpY2UiOjAsImdyYWNlIjowLCJmbGFncyI6W119fX0d902bff803f72b705792c547d303a24f7d3142775f6e3c3f4e6aa583a0b3b80caab85380dfa7478f89f53948895e4497454fe2537d7b29ce9a6136066d236e02]`;
+[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwicmVsZWFzZV91bnRpbCI6IjIwMjctMDgtMTIiLCJub3RpY2UiOjAsImdyYWNlIjowLCJmbGFncyI6W119fSwidiI6MiwicHJvc2UiOiI2MzMwZWI1MWVjODdjNGI5Y2ZkY2JkZTFjZmFjNjE4M2RlZDYxZTgyY2Q1ODBhMzY5YWNkNDYzYzZkMDE0MWE0In076eaf0a9391b25029afb0aaacc9f109493fdd0aaaa9aff762c90f14dab1b327d7520ea3683a3585d497c8115d595c5d4787ba4194ab742a935815a0d66c8ba3d]`;
/**
* A1 with the console silenced by request, the UI left alone (example A7).
@@ -83,7 +85,7 @@ export const NO_CONSOLE_WARNS_KEY = `This is a Handsontable license key for Test
> 1. Subscription license under Handsontable Subscription License Agreement 2.0 of 2022-05-21, for Handsontable on the Enterprise package, for internal use, valid until 2027-08-12 (UTC). Use after that date is not permitted. To renew, contact sales@handsontable.com.
-[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjo2MCwiZ3JhY2UiOjkwLCJmbGFncyI6WyJuby1jb25zb2xlLXdhcm5zIl19fX07781e15b303d299085f1762bc862e80e0971ce057cb0cb07047169dddcd8f97b09e70762bd79cdb86a94ffbdb1b4e3445578f25c2ed99ca652de73f5a44d09be]`;
+[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjo2MCwiZ3JhY2UiOjkwLCJmbGFncyI6WyJuby1jb25zb2xlLXdhcm5zIl19fSwidiI6MiwicHJvc2UiOiIwNjIzNWUxYjY5ZmQ1YjNmNjg2NjdhNTcxNmU5YWY5YWU5YTkxYjNkNzc5ZTBlY2FkODIyMjQ2NDU1YjUxZTA0In06005e380b7b82f3475a32465e3fc733cf9fc1fd837eb52576c4fb6032f8de01f5ac267593d1b4ed44a0305de62f584ab918752a2d9854fa15928cfa53727087f]`;
/**
* A1 with the UI silenced by request, the console left alone (example A8).
@@ -94,7 +96,7 @@ export const NO_UI_WARNS_KEY = `This is a Handsontable license key for Test Fixt
> 1. Subscription license under Handsontable Subscription License Agreement 2.0 of 2022-05-21, for Handsontable on the Enterprise package, for internal use, valid until 2027-08-12 (UTC). Use after that date is not permitted. To renew, contact sales@handsontable.com.
-[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjo2MCwiZ3JhY2UiOjkwLCJmbGFncyI6WyJuby11aS13YXJucyJdfX1934a26b98650bca363a77bd859bb921d34d7c213a58355e5aac1a4eaa812ecf07468d66644ea22ff7f0db15b636fca5cd4ddf30ec2116cbae32ff6b110b50e623]`;
+[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjo2MCwiZ3JhY2UiOjkwLCJmbGFncyI6WyJuby11aS13YXJucyJdfX0sInYiOjIsInByb3NlIjoiMDYyMzVlMWI2OWZkNWIzZjY4NjY3YTU3MTZlOWFmOWFlOWE5MWIzZDc3OWUwZWNhZDgyMjI0NjQ1NWI1MWUwNCJ91abdd30b6a6d8b5a5750e84cd843acea803b8500be07f46ff237b11abd4c23eb9223f4e5d1a9bb7f6ca168cb89e406a9bef9ef976af31e1f3138ea8dabf0a613]`;
/**
* HyperFormula only - a checksum-valid key that is not a Handsontable license (example B1).
@@ -105,7 +107,7 @@ export const HF_ONLY_KEY = `This is a Handsontable license key for Test Fixture,
> 1. Subscription license under Handsontable Subscription License Agreement 2.0 of 2022-05-21, for HyperFormula on the Essential package, for internal use, valid until 2027-08-12 (UTC). Use after that date is not permitted. To renew, contact sales@handsontable.com.
-[eyJwcm9kdWN0cyI6eyJoeXBlcmZvcm11bGEiOnsiY2FwYWJpbGl0aWVzIjpbImZ1bmN0aW9uc18xIl0sInVzYWdlX3VudGlsIjoiMjAyNy0wOC0xMiIsIm5vdGljZSI6NjAsImdyYWNlIjo5MCwiZmxhZ3MiOltdfX198d51a20a9654df523103eabaa27f595b0988b1872d45306da91ff13102ac148e2f69b65e27160b54eb8e9e99521135c1c4d375c449af456af3084fdd0e5779ac]`;
+[eyJwcm9kdWN0cyI6eyJoeXBlcmZvcm11bGEiOnsiY2FwYWJpbGl0aWVzIjpbImZ1bmN0aW9uc18xIl0sInVzYWdlX3VudGlsIjoiMjAyNy0wOC0xMiIsIm5vdGljZSI6NjAsImdyYWNlIjo5MCwiZmxhZ3MiOltdfX0sInYiOjIsInByb3NlIjoiYzhjYmI0NzcyOGM2NGMyMzYwY2UxMDIyYTY2ZjZiMjUyN2JiNzljZmU3NmU2ODExNzQyMzc4Njk2ZmRjMTliMCJ90241b947442f6333b333ecf5fd30b069af1d3b4770813f53d7d617af8297b2a2766bd7270ee9b4160f9ab1585330cbd8761b4604eddd16e581038b2134dbdcac]`;
/**
* Handsontable on `usage_until` 2027-08-12 plus HyperFormula on `release_until` 2025-03-31 (example C4).
@@ -118,7 +120,7 @@ export const MIXED_KEY = `This is a Handsontable license key for Test Fixture, i
> 2. Perpetual license under Handsontable Perpetual License Agreement 2.0 of 2022-05-21, for HyperFormula on the Pro package, for internal use. Maintenance and access to new versions both end on 2025-03-31. Versions released on or before that date may be used indefinitely. To renew maintenance, contact sales@handsontable.com.
-[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjo2MCwiZ3JhY2UiOjkwLCJmbGFncyI6W119LCJoeXBlcmZvcm11bGEiOnsiY2FwYWJpbGl0aWVzIjpbImZ1bmN0aW9uc18xIiwiZnVuY3Rpb25zXzIiXSwicmVsZWFzZV91bnRpbCI6IjIwMjUtMDMtMzEiLCJub3RpY2UiOjAsImdyYWNlIjowLCJmbGFncyI6W119fX08d2f8c57c2d22a6afecdc2a09d549990cb2bec927492e031143e536f84fb454c2c543b74444ad379fb22bee7102e54b99fb1997e3617c28c63480c6865a5402d]`;
+[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjo2MCwiZ3JhY2UiOjkwLCJmbGFncyI6W119LCJoeXBlcmZvcm11bGEiOnsiY2FwYWJpbGl0aWVzIjpbImZ1bmN0aW9uc18xIiwiZnVuY3Rpb25zXzIiXSwicmVsZWFzZV91bnRpbCI6IjIwMjUtMDMtMzEiLCJub3RpY2UiOjAsImdyYWNlIjowLCJmbGFncyI6W119fSwidiI6MiwicHJvc2UiOiI5NTQ5MGIzY2E1ZTEwZTVjMzdiYWM1NDc2NjQ1ZDI5NmYxNDA2MWEwZTUyZTY0M2VlM2Q3NmZmOGQ2MWVhYWVhIn059d68de72a5dbf77997715f328757fda1709ea9439d86491b86f795aa7c160914393013891ee0a724ef2b1123ead9bfc5821978d6c3703b20f440c42f39e31c7]`;
/**
* A1 with `notice: 0` - quiet before expiry, loud after (example E2).
@@ -129,7 +131,7 @@ export const NO_NOTICE_KEY = `This is a Handsontable license key for Test Fixtur
> 1. Subscription license under Handsontable Subscription License Agreement 2.0 of 2022-05-21, for Handsontable on the Enterprise package, for internal use, valid until 2027-08-12 (UTC). Use after that date is not permitted. To renew, contact sales@handsontable.com.
-[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjowLCJncmFjZSI6OTAsImZsYWdzIjpbXX19fQaa757b0e8f41d9b642f9d4816af9177a4f16b00ae1ccff0359b87795e1a5169d3d1facdee644cd56026e9e96e743c528cac973cf3814a4d680b8924449844773]`;
+[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjowLCJncmFjZSI6OTAsImZsYWdzIjpbXX19LCJ2IjoyLCJwcm9zZSI6IjA2MjM1ZTFiNjlmZDViM2Y2ODY2N2E1NzE2ZTlhZjlhZTlhOTFiM2Q3NzllMGVjYWQ4MjIyNDY0NTViNTFlMDQifQ54932996ff4ab4803f0fe24bec4329c534e541fe3b1e95f27dbc8f2faaf03fbe9f06d536cdcba6f8e0df22536279f6070ee1b7f695c24ea2d8a4a791f3f31384]`;
/**
* Individually negotiated terms: `usage_until` 2029-12-31, notice 180, grace 180, flag `custom` (example E4).
@@ -140,7 +142,7 @@ export const CUSTOM_FLAG_KEY = `This is a Handsontable license key for Test Fixt
> 1. Subscription license under Handsontable Subscription License Agreement 2.0 of 2022-05-21, for Handsontable on the Enterprise package, for internal use, valid until 2029-12-31 (UTC). Use after that date is not permitted. To renew, contact sales@handsontable.com.
-[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI5LTEyLTMxIiwibm90aWNlIjoxODAsImdyYWNlIjoxODAsImZsYWdzIjpbImN1c3RvbSJdfX196a8ea38761d51d49dbbf59c526765d77b6dcc7d44358ac068073a2dd132ff9155ecb6ae751f6e0851cbcccfa23465c6278622b8ddf4b184bfa0b946527826d16]`;
+[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI5LTEyLTMxIiwibm90aWNlIjoxODAsImdyYWNlIjoxODAsImZsYWdzIjpbImN1c3RvbSJdfX0sInYiOjIsInByb3NlIjoiYTdiZmU2NjVlYzUzMzc4NjdiNmE0ZDJmMTBjYmUyZTE0ZTI3MjAyNDU1NTI1M2FmOGEwMGFmMjFkOTM1NWI5MiJ9e3d94a0ed95e3174788f0ee8a568f751d063c720b049840ae60c60c844c7787062e6ebd9ae4b41f4625d123e0751f476ba92ba94784ee4ac0fab9637c3d81e24]`;
/**
* A trial with the console silenced by request - the badge, popover and bar stay (examples A3 + A7).
@@ -151,7 +153,7 @@ export const TRIAL_NO_CONSOLE_WARNS_KEY = `This is a Handsontable license key fo
> 1. Trial license under Handsontable Evaluation License Agreement 4.0 of 2026-03-02, for Handsontable on the Enterprise package, for internal use, valid until 2026-09-26 (UTC). Not licensed for production use. To purchase a license, contact sales@handsontable.com.
-[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI2LTA5LTI2Iiwibm90aWNlIjo0NSwiZ3JhY2UiOjE1LCJmbGFncyI6WyJ0cmlhbCIsIm5vLWNvbnNvbGUtd2FybnMiXX19fQ9a0b1fb7f72971a4abf34ff5320f33a3f828849f6aa0a3e8bfaae0d0ee2bfc322a4035a42ae048fe790d026fb17b40b876f4b38553c44abac1bb6a0398734486]`;
+[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI2LTA5LTI2Iiwibm90aWNlIjo0NSwiZ3JhY2UiOjE1LCJmbGFncyI6WyJ0cmlhbCIsIm5vLWNvbnNvbGUtd2FybnMiXX19LCJ2IjoyLCJwcm9zZSI6IjM4NzM2MTkxOWM3NWE0NDUwODRmZjZjNTJmMDJjNGQyYzZjMjE1YWM3NmIxZDBkYmE2YjA5MzgxZjA5MDY4NWEifQ52ae9c2cd1063dedd87ca7150a534436c56a5eabbd4daca4f85054527dfd21f1a58210a218e60b8bde8b5640cce1ebe5d29abc3771673ae2aed25b7586892a86]`;
/**
* A trial with the UI silenced by request - only the console speaks (examples A3 + A8).
@@ -162,7 +164,7 @@ export const TRIAL_NO_UI_WARNS_KEY = `This is a Handsontable license key for Tes
> 1. Trial license under Handsontable Evaluation License Agreement 4.0 of 2026-03-02, for Handsontable on the Enterprise package, for internal use, valid until 2026-09-26 (UTC). Not licensed for production use. To purchase a license, contact sales@handsontable.com.
-[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI2LTA5LTI2Iiwibm90aWNlIjo0NSwiZ3JhY2UiOjE1LCJmbGFncyI6WyJ0cmlhbCIsIm5vLXVpLXdhcm5zIl19fX0061fb508cce2411f2221e4218b2e05e791db52682a6997d885f9769f997027c52cdd87c75aab81e9dd670309874b466a87a74f7df268009d4f39acbad1fb3051]`;
+[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI2LTA5LTI2Iiwibm90aWNlIjo0NSwiZ3JhY2UiOjE1LCJmbGFncyI6WyJ0cmlhbCIsIm5vLXVpLXdhcm5zIl19fSwidiI6MiwicHJvc2UiOiIzODczNjE5MTljNzVhNDQ1MDg0ZmY2YzUyZjAyYzRkMmM2YzIxNWFjNzZiMWQwZGJhNmIwOTM4MWYwOTA2ODVhIn0c1dccd7855f9004db2a062d37bdec85f616193de27387b34733659025c4b89e39d18db26e78546db6a8406bc791ae32ecdca985330896764429cc1cf4b1b80b5]`;
/**
* A perpetual license with the UI silenced, so a lapsed maintenance date shows no bar (example A5).
@@ -173,7 +175,7 @@ export const PERPETUAL_NO_UI_WARNS_KEY = `This is a Handsontable license key for
> 1. Perpetual license under Handsontable Perpetual License Agreement 2.0 of 2022-05-21, for Handsontable on the Enterprise package, for internal use. Maintenance and access to new versions both end on 2027-08-12. Versions released on or before that date may be used indefinitely. To renew maintenance, contact sales@handsontable.com.
-[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwicmVsZWFzZV91bnRpbCI6IjIwMjctMDgtMTIiLCJub3RpY2UiOjAsImdyYWNlIjowLCJmbGFncyI6WyJuby11aS13YXJucyJdfX19296642b3a49180560f88906b63667c7154581ace23a4a0c014882fde06d6ff034e2d3ccd67612dd46f8796374ade8892a8b6064756b6cb76a7d99891ad3d158c]`;
+[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwicmVsZWFzZV91bnRpbCI6IjIwMjctMDgtMTIiLCJub3RpY2UiOjAsImdyYWNlIjowLCJmbGFncyI6WyJuby11aS13YXJucyJdfX0sInYiOjIsInByb3NlIjoiNjMzMGViNTFlYzg3YzRiOWNmZGNiZGUxY2ZhYzYxODNkZWQ2MWU4MmNkNTgwYTM2OWFjZDQ2M2M2ZDAxNDFhNCJ91e1cd7ca48909536fe8f7b4af70a2103e82b5803d9ca31509de3c825f69e0671d3a881fce825093b714d6aa05e97faed08f90ca5784e7e5fb6a5034d2cd911ec]`;
/**
* A1 issued to a holder whose name has composed (NFC) characters - "\u00fc", "\u00d6", "\u0141" and
@@ -186,7 +188,7 @@ export const ACCENTED_HOLDER_KEY = `This is a Handsontable license key for Z\u00
> 1. Subscription license under Handsontable Subscription License Agreement 2.0 of 2022-05-21, for Handsontable on the Enterprise package, for internal use, valid until 2027-08-12 (UTC). Use after that date is not permitted. To renew, contact sales@handsontable.com.
-[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjo2MCwiZ3JhY2UiOjkwLCJmbGFncyI6W119fX0f65153334f11db05850685a07ff89f3710dbf46faba3e4cee4bb3a181272a69aea5fd082b85261a79d419c16cfaee5ac1bf1941f386cbbc1c89c41ba11767181]`;
+[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjo2MCwiZ3JhY2UiOjkwLCJmbGFncyI6W119fSwidiI6MiwicHJvc2UiOiJlZTIwZGRlOTMyMjJlNGYyNTYwMTg2MGE5ZmE5ZjhmYjI2MTg0ZDA0NzE1NTFjZTU1ZDQ3NDI3NDQ0MGUyODk5In0a4cec1a5c59b02b17dc516e79159179c02688586173ca9dbef4d9423b1e5a60f0e962b5dc0ac208f58d38689b09d48340e90a330e19a9073470019f66ecd485a]`;
/**
* A1 issued to a holder whose name is written in Japanese, so the prose has runs of CJK characters
@@ -198,4 +200,27 @@ export const CJK_HOLDER_KEY = `This is a Handsontable license key for \u682a\u5f
> 1. Subscription license under Handsontable Subscription License Agreement 2.0 of 2022-05-21, for Handsontable on the Enterprise package, for internal use, valid until 2027-08-12 (UTC). Use after that date is not permitted. To renew, contact sales@handsontable.com.
-[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjo2MCwiZ3JhY2UiOjkwLCJmbGFncyI6W119fX0eea2196d4da54636b27e38cf5f71588326794d5043beb39d65ec3e88e57a09e89e9772fb8dd44a4d8e928b04303666323e75c42018a035acf06203a481c9cf39]`;
+[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjo2MCwiZ3JhY2UiOjkwLCJmbGFncyI6W119fSwidiI6MiwicHJvc2UiOiJjZmI4YThiNDhlMmJiYzk3MjQ5YzI0MWY4ZjAyZDlkMWEzYzNhZGY1M2Y5ZTBmZTk2MjBhNTM1NWZmYzRhN2NjIn0d7b7438cd13b8426d4118d1699812ab19dfad443a15b21b5253453c5c94b0f24af92426b0e9461393b65e1496961bdae03ba7e03d0c295b99e03f929b40dae7b]`;
+
+/**
+ * The SUBSCRIPTION_KEY record as `license-key` 4.0.1 issued it, in the earlier format (example A1).
+ *
+ * @type {string}
+ */
+export const V1_SUBSCRIPTION_KEY = `This is a Handsontable license key for Test Fixture, issued on 2026-08-12 for the "Fixture Project" project. It includes 1 license:
+
+> 1. Subscription license under Handsontable Subscription License Agreement 2.0 of 2022-05-21, for Handsontable on the Enterprise package, for internal use, valid until 2027-08-12 (UTC). Use after that date is not permitted. To renew, contact sales@handsontable.com.
+
+[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjo2MCwiZ3JhY2UiOjkwLCJmbGFncyI6W119fX03de5d59e480be17d3c8cd340cb242cab64cac7888cbfba6c975c194f6613b8103792a6929f66852d18c7ac27c8c25fa9ab8a25b5e25f331669746c833a4f8361]`;
+
+/**
+ * The TRIAL_KEY record as `license-key` 4.0.1 issued it - the shape of the trial keys the website
+ * form still issues (example A3).
+ *
+ * @type {string}
+ */
+export const V1_TRIAL_KEY = `This is a Handsontable license key for Test Fixture, issued on 2026-08-12 for the "Fixture Project" project. It includes 1 license:
+
+> 1. Trial license under Handsontable Evaluation License Agreement 4.0 of 2026-03-02, for Handsontable on the Enterprise package, for internal use, valid until 2026-09-26 (UTC). Not licensed for production use. To purchase a license, contact sales@handsontable.com.
+
+[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI2LTA5LTI2Iiwibm90aWNlIjo0NSwiZ3JhY2UiOjE1LCJmbGFncyI6WyJ0cmlhbCJdfX19a687a9f4d0d496c1ec86d97d58e971b458995f1a68ca117a6b406fa2f179923dcd42a789e3c56426690cf12c89e70abfbb6f45b96ba55fe49386d9e1b0080f2c]`;
diff --git a/handsontable/src/utils/entitlementLicenseKey/constants.ts b/handsontable/src/utils/entitlementLicenseKey/constants.ts
index 70a6dd38d1..950889c160 100644
--- a/handsontable/src/utils/entitlementLicenseKey/constants.ts
+++ b/handsontable/src/utils/entitlementLicenseKey/constants.ts
@@ -1,11 +1,18 @@
/**
- * The length of the checksum (SHA-512 as hex) that closes the machine-readable
+ * The length of the checksum that closes the machine-readable
* block of every entitlement license key.
*
* @type {number}
*/
export const CHECKSUM_LENGTH = 128;
+/**
+ * The length of the value a current key stores for its text.
+ *
+ * @type {number}
+ */
+export const PROSE_DIGEST_LENGTH = 64;
+
/**
* The two mutually exclusive date fields of a product entry. Exactly one of
* them is present:
diff --git a/handsontable/src/utils/entitlementLicenseKey/extractKeyData.ts b/handsontable/src/utils/entitlementLicenseKey/extractKeyData.ts
index b435aa36af..115bc87dec 100644
--- a/handsontable/src/utils/entitlementLicenseKey/extractKeyData.ts
+++ b/handsontable/src/utils/entitlementLicenseKey/extractKeyData.ts
@@ -1,12 +1,11 @@
import type { EntitlementKeyData, ProductEntitlement } from './types';
-import { CHECKSUM_LENGTH, DATE_FIELDS } from './constants';
+import { CHECKSUM_LENGTH, DATE_FIELDS, PROSE_DIGEST_LENGTH } from './constants';
import { sha512 } from './sha512';
import { base64ToString, stringToUtf8Bytes, parseIsoDateToTimestamp } from './encoding';
/**
- * The alphabet of the encoded payload - URL-safe base64 without padding. The
- * checksum (lowercase hex) is a subset of it, which is what lets the two be
- * split by a fixed length from the right.
+ * The alphabets of the two parts of the machine-readable block. The second
+ * part has a fixed length, so the two are split from the right.
*
* @type {RegExp}
*/
@@ -14,10 +13,11 @@ const ENCODED_PAYLOAD = /^[A-Za-z0-9\-_]+$/;
const CHECKSUM = /^[0-9a-f]+$/;
/**
- * The whitespace removed from the prose before it is checksummed: TAB, LF, VT,
- * FF, CR, SPACE, NO-BREAK SPACE, OGHAM SPACE MARK, the U+2000-U+200A spaces,
- * LINE SEPARATOR, PARAGRAPH SEPARATOR, NARROW NO-BREAK SPACE, MEDIUM
- * MATHEMATICAL SPACE, IDEOGRAPHIC SPACE, and the BOM.
+ * The whitespace the reader ignores, in the text and inside the block: TAB,
+ * LF, VT, FF, CR, SPACE, NO-BREAK SPACE,
+ * OGHAM SPACE MARK, the U+2000-U+200A spaces, LINE SEPARATOR, PARAGRAPH
+ * SEPARATOR, NARROW NO-BREAK SPACE, MEDIUM MATHEMATICAL SPACE, IDEOGRAPHIC
+ * SPACE, and the BOM.
*
* Listed explicitly instead of `\s`, whose set has changed between JavaScript
* engines (U+180E) and differs in other languages (U+0085), so it matches the
@@ -29,7 +29,7 @@ const PROSE_WHITESPACE = /[\t\n\v\f\r \u00a0\u1680\u2000-\u200a\u2028\u2029\u202
/**
* A line break or tab that was saved as text - a backslash followed by "n",
- * "r", or "t" - also removed before the prose is checksummed.
+ * "r", or "t" - also ignored.
*
* Several places a key is stored keep a line break that way rather than as a
* real one: a single-quoted or unquoted `.env` value, Docker's `--env-file`,
@@ -43,9 +43,23 @@ const PROSE_WHITESPACE = /[\t\n\v\f\r \u00a0\u1680\u2000-\u200a\u2028\u2029\u202
const ESCAPED_WHITESPACE = /\\[nrt]/g;
/**
- * Brings the human-readable text of a key to the form the checksum covers:
- * every escaped line break or tab (`\n`, `\r`, `\t` saved as text) and every
- * whitespace character removed, then Unicode NFC.
+ * Removes every escaped line break or tab (`\n`, `\r`, `\t` saved as text) and
+ * then every whitespace character, in that order.
+ *
+ * The block is one long word, so a mail client or an editor that wraps the key
+ * can break it across lines, and a `.env` file can save that line break as the
+ * text `\n`. The block's alphabet has neither, so removing them cannot change
+ * a genuine block.
+ *
+ * @param {string} text The text to remove the whitespace from.
+ * @returns {string}
+ */
+function removeWhitespace(text: string): string {
+ return text.replace(ESCAPED_WHITESPACE, '').replace(PROSE_WHITESPACE, '');
+}
+
+/**
+ * Brings the human-readable text of a key to the form the key protects.
*
* Only the whitespace, its escaped forms, and the Unicode composition are
* ignored. A mail client that rewraps the text (also between two CJK
@@ -61,21 +75,30 @@ const ESCAPED_WHITESPACE = /\\[nrt]/g;
export function canonicalizeProse(prose: string): string {
// NFC runs last: a line break between a letter and its combining mark (an
// NFD copy rewrapped there) has to be gone before the two can compose.
- return prose.replace(ESCAPED_WHITESPACE, '').replace(PROSE_WHITESPACE, '').normalize('NFC');
+ return removeWhitespace(prose).normalize('NFC');
+}
+
+/**
+ * Computes the checksum that closes the block. It must match the canonical
+ * reader in every key format, so Handsontable 18.1 keeps reading newer keys.
+ * Exported for the test key builder.
+ *
+ * @param {string} encodedPayload The encoded payload.
+ * @returns {string}
+ */
+export function computePayloadChecksum(encodedPayload: string): string {
+ return sha512(stringToUtf8Bytes(encodedPayload));
}
/**
- * Computes the checksum of an entitlement key: the SHA-512 (lowercase hex) of
- * the UTF-8 bytes of the canonical prose, a single "\n" and the encoded
- * payload. The "\n" cannot occur in either part, so the boundary between the
- * two is unambiguous. Exported for the test key builder.
+ * Computes the value a current key stores for its text. Exported for the test
+ * key builder.
*
- * @param {string} canonicalProse The prose, already passed through `canonicalizeProse`.
- * @param {string} encodedPayload The base64url payload.
+ * @param {string} canonicalProse The text, already passed through `canonicalizeProse`.
* @returns {string}
*/
-export function computeChecksum(canonicalProse: string, encodedPayload: string): string {
- return sha512(stringToUtf8Bytes(`${canonicalProse}\n${encodedPayload}`));
+export function computeProseDigest(canonicalProse: string): string {
+ return sha512(stringToUtf8Bytes(canonicalProse)).slice(0, PROSE_DIGEST_LENGTH);
}
/**
@@ -122,6 +145,44 @@ function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every(item => typeof item === 'string');
}
+/**
+ * Freezes the value and everything nested in it. The read result is shared
+ * between callers (see the memo below), so a caller that sorted or pushed into
+ * it would silently rewrite what the next caller reads.
+ *
+ * It walks with an explicit stack, not by recursion. An unknown extra field is
+ * kept as it is, and a key can nest one thousands of levels deep - recursion
+ * would then overflow the call stack and throw out of the reader, where a key
+ * is only ever allowed to read as data or as `null`.
+ *
+ * @param {*} value The value to freeze.
+ * @returns {*}
+ */
+function deepFreeze<T>(value: T): T {
+ const pending: unknown[] = [value];
+
+ while (pending.length > 0) {
+ const current = pending.pop();
+
+ if (isFreezable(current)) {
+ Object.freeze(current);
+ Object.keys(current).forEach(key => pending.push(current[key]));
+ }
+ }
+
+ return value;
+}
+
+/**
+ * Narrows an unknown value to an object (or array) that is not frozen yet.
+ *
+ * @param {*} value The value to check.
+ * @returns {boolean}
+ */
+function isFreezable(value: unknown): value is Record<string, unknown> {
+ return value !== null && typeof value === 'object' && !Object.isFrozen(value);
+}
+
/**
* Adds an own, ordinary property.
*
@@ -206,6 +267,49 @@ function normalizeProductEntry(entry: unknown): ProductEntitlement | null {
return normalized;
}
+/**
+ * Reads the format version of a payload. Returns `null` for a value no
+ * generator writes. A version newer than this reader knows is accepted, so a
+ * build already in the field keeps reading newer keys.
+ *
+ * @param {object} payload The decoded payload.
+ * @returns {number|null}
+ */
+function readFormatVersion(payload: Record<string, unknown>): number | null {
+ // Own properties only, so a value another script put on `Object.prototype`
+ // cannot change how a key is read.
+ if (!hasOwn(payload, 'v')) {
+ return 1;
+ }
+ if (!isNonNegativeInteger(payload.v) || payload.v < 2) {
+ return null;
+ }
+
+ return payload.v;
+}
+
+/**
+ * Checks that the text of a current key is intact and that nothing but
+ * whitespace follows its block. A key always states its terms, so the bare
+ * block is rejected. A key in the earlier format is read the way Handsontable
+ * 18.1 reads it.
+ *
+ * @param {object} payload The decoded payload.
+ * @param {string} prose The text in front of the block.
+ * @param {string} textAfterBlock The text after the block.
+ * @returns {boolean}
+ */
+function coversItsText(payload: Record<string, unknown>, prose: string, textAfterBlock: string): boolean {
+ if (canonicalizeProse(textAfterBlock) !== '') {
+ return false;
+ }
+
+ const canonicalProse = canonicalizeProse(prose);
+
+ return canonicalProse !== '' && hasOwn(payload, 'prose') && typeof payload.prose === 'string' &&
+ computeProseDigest(canonicalProse) === payload.prose;
+}
+
/**
* Reads and verifies one key. Split out from the memoized public entry point so
* the memo can wrap every exit path uniformly.
@@ -228,22 +332,7 @@ function readEntitlementKeyData(licenseKey: string): EntitlementKeyData | null {
return null;
}
- // The block closes the key. Text after it would be words the checksum does
- // not cover, so only whitespace may follow - judged by the same rule as the
- // prose, so a trailing line break saved as text ("\n") is allowed too.
- if (canonicalizeProse(licenseKey.slice(blockEnd + 1)) !== '') {
- return null;
- }
-
- const canonicalProse = canonicalizeProse(licenseKey.slice(0, blockStart));
-
- // A key always states its terms. Without this check, a bare block whose
- // checksum was computed over empty prose would read as valid.
- if (canonicalProse === '') {
- return null;
- }
-
- const content = licenseKey.slice(blockStart + 1, blockEnd);
+ const content = removeWhitespace(licenseKey.slice(blockStart + 1, blockEnd));
if (content.length <= CHECKSUM_LENGTH) {
return null;
@@ -255,7 +344,7 @@ function readEntitlementKeyData(licenseKey: string): EntitlementKeyData | null {
if (!ENCODED_PAYLOAD.test(encodedPayload) || !CHECKSUM.test(checksum)) {
return null;
}
- if (computeChecksum(canonicalProse, encodedPayload) !== checksum) {
+ if (computePayloadChecksum(encodedPayload) !== checksum) {
return null;
}
@@ -277,6 +366,15 @@ function readEntitlementKeyData(licenseKey: string): EntitlementKeyData | null {
return null;
}
+ const version = readFormatVersion(payload);
+
+ if (version === null) {
+ return null;
+ }
+ if (version >= 2 && !coversItsText(payload, licenseKey.slice(0, blockStart), licenseKey.slice(blockEnd + 1))) {
+ return null;
+ }
+
const products = {} as EntitlementKeyData['products'];
const entries = payload.products;
let malformed = false;
@@ -297,13 +395,13 @@ function readEntitlementKeyData(licenseKey: string): EntitlementKeyData | null {
return null;
}
- return { products };
+ return deepFreeze({ version, products });
}
// The license key is read twice per grid init - the bottom bar
// (`initLicenseNotification`) and the branding UI (`initLicenseBranding`) each resolve the license
-// state - and reading runs the full SHA-512 + base64 + JSON parse. A one-entry memo on the key makes
-// the second read free. The returned data is treated as read-only by every caller, so sharing one
+// state - and reading runs the full verification and decoding. A one-entry memo on the key makes
+// the second read free. The returned data is frozen, as in the canonical reader, so sharing one
// object is safe.
let memoizedKey: string | null = null;
let memoizedData: EntitlementKeyData | null = null;
@@ -311,22 +409,26 @@ let memoizedData: EntitlementKeyData | null = null;
/**
* Extracts the machine-readable data from an entitlement license key.
*
- * The checksum is verified first, so the returned data is guaranteed to belong
- * to an intact key. A malformed or tampered key reads as `null` - reporting an
+ * The key is verified first, so the returned data is guaranteed to belong to
+ * an intact key. A malformed or tampered key reads as `null` - reporting an
* invalid key is the caller's job, not this function's.
*
- * The checksum covers the prose in front of the block as well as the block, so
- * the caller has to pass the whole key. A key whose prose was edited or removed
- * (the bare `[...]` block) reads as `null`, and so does one with anything but
- * whitespace after the block. The prose is still never parsed, and its
+ * A current key protects its text as well: edited or removed text (the bare
+ * `[...]` block), or anything but whitespace after the block, reads as `null`.
+ * A key in the earlier format reads the way Handsontable 18.1 reads it. The
+ * verification rules are those of the canonical reader in the private
+ * `license-key` repository.
+ *
+ * The caller passes the whole key. The text is never parsed, and its
* whitespace and Unicode composition are ignored, so rewrapped or re-pasted
- * prose still validates. The block itself has to be intact: its alphabet has
- * no whitespace, so a newline inside it makes the key unreadable, exactly as it
- * does for the key generator.
+ * text still validates. Whitespace inside the block is ignored too, so a
+ * block wrapped by a mail client still validates.
*
* Unknown products, capability tokens and flags are all tolerated, so nothing
* about reading a key depends on the commercial vocabulary.
*
+ * The result is frozen. Copy an array before sorting or changing it.
+ *
* @param {string} licenseKey The license key to extract the data from.
* @returns {EntitlementKeyData|null}
*/
diff --git a/handsontable/src/utils/entitlementLicenseKey/types.ts b/handsontable/src/utils/entitlementLicenseKey/types.ts
index fb6121a32d..f32356194a 100644
--- a/handsontable/src/utils/entitlementLicenseKey/types.ts
+++ b/handsontable/src/utils/entitlementLicenseKey/types.ts
@@ -35,6 +35,11 @@ export interface ProductEntitlement {
* known ones.
*/
export interface EntitlementKeyData {
+ /**
+ * The format version of the key; 1 for the keys issued before versions
+ * existed.
+ */
+ version: number;
products: { [productName: string]: ProductEntitlement };
}
diff --git a/tests/e2e/license-branding.spec.ts b/tests/e2e/license-branding.spec.ts
index a5f0c6eff2..6bc2e75589 100644
--- a/tests/e2e/license-branding.spec.ts
+++ b/tests/e2e/license-branding.spec.ts
@@ -31,6 +31,17 @@ test.describe('entitlement license key branding', () => {
await expect(license.lock).toHaveCount(0);
});
+ test('reads a trial key license-key 4.x issued exactly like a current one', async () => {
+ // The trial keys already in the field are in the earlier format. They must keep reading as a
+ // running trial, not as an unreadable key that blocks the grid.
+ await license.goto(INSTANT.duringTrial, { key: 'trial-v1' });
+
+ await expect(license.badge).toBeAttached();
+ await expect(license.popoverTitle).toHaveText('Handsontable Trial');
+ await expect(license.bar).toHaveCount(0);
+ await expect(license.lock).toHaveCount(0);
+ });
+
test('paints the glyph inside the corner header cell, never overflowing it', async ({ page }) => {
await license.goto(INSTANT.duringTrial);
@@ -260,8 +271,8 @@ test.describe('entitlement license key branding', () => {
// sentences moved out of the bottom bar and into the modal, so the bar must be gone.
const FAULTS = [
{ key: 'tampered', title: 'The license key for Handsontable is invalid.' },
- // DEV-3254: the checksum covers the prose, so the block alone, or a key whose prose was
- // edited, is an unreadable key as well.
+ // A current key protects its text, so the block alone, or a key whose text was edited, is an
+ // unreadable key as well.
{ key: 'bare-block', title: 'The license key for Handsontable is invalid.' },
{ key: 'edited-prose', title: 'The license key for Handsontable is invalid.' },
{ key: 'missing', title: 'The license key for Handsontable is missing.' },
@@ -349,8 +360,10 @@ test.describe('entitlement license key branding', () => {
// A hard-stopped subscription is developer-facing only, however the key was issued: a console
// error and no front-end surface at all. 18.1 never blocks a paying customer.
// `subscription-pasted` is the same key with its whitespace turned into CRLF line breaks and a
- // trailing "\n" saved as text - it must read exactly like the original, or it would block.
- for (const key of ['subscription', 'subscription-external', 'subscription-pasted'] as const) {
+ // trailing "\n" saved as text, and `subscription-wrapped` the same key with its block broken
+ // into lines as a mail client wraps it - both must read exactly like the original, or a
+ // pasting customer's grid would block.
+ for (const key of ['subscription', 'subscription-external', 'subscription-pasted', 'subscription-wrapped'] as const) {
test(`stays console-only for a "${key}" key: no lock, no bar, no badge`, async () => {
await license.goto(INSTANT.subscriptionHardStop, { key });
diff --git a/tests/fixtures/demo/license-branding.html b/tests/fixtures/demo/license-branding.html
index d0593c2b34..6665a42b5e 100644
--- a/tests/fixtures/demo/license-branding.html
+++ b/tests/fixtures/demo/license-branding.html
@@ -56,24 +56,27 @@
// The license keys, generated by the `license-key` package and shared with
// the unit fixtures (handsontable/src/utils/entitlementLicenseKey/__tests__/fixtures.js).
// Each is the whole key folded to one line - the form the generator prints
- // for pasting. The checksum covers the prose too, so the block alone would
- // read as invalid.
+ // for pasting. These keys are in the current format, which protects the
+ // text, so the block alone would read as invalid.
//
// trial — usage_until 2026-09-26, notice 45, grace 15, flag `trial`
// trial-external — the same, with `no-ui-warns` added
// subscription — usage_until 2027-08-12, notice 60, grace 90, no flags
// subscription-external — the same, with `no-console-warns` + `no-ui-warns`
const LICENSE_KEYS = {
- trial: 'This is a Handsontable license key for Test Fixture, issued on 2026-08-12 for the "Fixture Project" project. It includes 1 license: > 1. Trial license under Handsontable Evaluation License Agreement 4.0 of 2026-03-02, for Handsontable on the Enterprise package, for internal use, valid until 2026-09-26 (UTC). Not licensed for production use. To purchase a license, contact sales@handsontable.com. [eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI2LTA5LTI2Iiwibm90aWNlIjo0NSwiZ3JhY2UiOjE1LCJmbGFncyI6WyJ0cmlhbCJdfX195f2c28b185a0f34f2c85b97568c44f8930d30dd58f4901815807d550bc45716c98c6e9d01ea036efb6cfda123b7dfb7904cb5e946e0a407c0f5329e72691d715]',
+ trial: 'This is a Handsontable license key for Test Fixture, issued on 2026-08-12 for the "Fixture Project" project. It includes 1 license: > 1. Trial license under Handsontable Evaluation License Agreement 4.0 of 2026-03-02, for Handsontable on the Enterprise package, for internal use, valid until 2026-09-26 (UTC). Not licensed for production use. To purchase a license, contact sales@handsontable.com. [eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI2LTA5LTI2Iiwibm90aWNlIjo0NSwiZ3JhY2UiOjE1LCJmbGFncyI6WyJ0cmlhbCJdfX0sInYiOjIsInByb3NlIjoiMzg3MzYxOTE5Yzc1YTQ0NTA4NGZmNmM1MmYwMmM0ZDJjNmMyMTVhYzc2YjFkMGRiYTZiMDkzODFmMDkwNjg1YSJ973a630de3fb4f97dd3d82647b57149489e48114b83ac7d5ca92d370a0be18246eecfedca9c2671de91a39840c8c2b6964b5756bf3fc2b7adbe0718167ea55b50]',
// A trial issued for external use. `no-ui-warns` must silence the badge and the bar, and must
// NOT silence the hard-stop lock - the flag suppresses warnings, not enforcement.
- 'trial-external': 'This is a Handsontable license key for Test Fixture, issued on 2026-08-12 for the "Fixture Project" project. It includes 1 license: > 1. Trial license under Handsontable Evaluation License Agreement 4.0 of 2026-03-02, for Handsontable on the Enterprise package, for internal use, valid until 2026-09-26 (UTC). Not licensed for production use. To purchase a license, contact sales@handsontable.com. [eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI2LTA5LTI2Iiwibm90aWNlIjo0NSwiZ3JhY2UiOjE1LCJmbGFncyI6WyJ0cmlhbCIsIm5vLXVpLXdhcm5zIl19fX0061fb508cce2411f2221e4218b2e05e791db52682a6997d885f9769f997027c52cdd87c75aab81e9dd670309874b466a87a74f7df268009d4f39acbad1fb3051]',
- subscription: 'This is a Handsontable license key for Test Fixture, issued on 2026-08-12 for the "Fixture Project" project. It includes 1 license: > 1. Subscription license under Handsontable Subscription License Agreement 2.0 of 2022-05-21, for Handsontable on the Enterprise package, for internal use, valid until 2027-08-12 (UTC). Use after that date is not permitted. To renew, contact sales@handsontable.com. [eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjo2MCwiZ3JhY2UiOjkwLCJmbGFncyI6W119fX0c4401c19afaf11f9c2f8df05b6aa3dc4bad6cdbbf7a535e77d4e3d95c137cf9d59a05bbc73ec35f6bdce3e3a1cfc18170c9662c877ee0477e2615fe32ab1b044]',
- 'subscription-external': 'This is a Handsontable license key for Test Fixture, issued on 2026-08-12 for the "Fixture Project" project. It includes 1 license: > 1. Subscription license under Handsontable Subscription License Agreement 2.0 of 2022-05-21, for Handsontable on the Enterprise package, for external use, valid until 2027-08-12 (UTC). Use after that date is not permitted. To renew, contact sales@handsontable.com. [eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjo2MCwiZ3JhY2UiOjkwLCJmbGFncyI6WyJuby1jb25zb2xlLXdhcm5zIiwibm8tdWktd2FybnMiXX19fQ54053e73eda38c08afbd0554d564ecf1d7b03bb93ea2ca739e4cd441049667a256a3b28f595e2e265373356f2a065aa70721a6594ff1441c13e400cc3a093c37]',
+ 'trial-external': 'This is a Handsontable license key for Test Fixture, issued on 2026-08-12 for the "Fixture Project" project. It includes 1 license: > 1. Trial license under Handsontable Evaluation License Agreement 4.0 of 2026-03-02, for Handsontable on the Enterprise package, for internal use, valid until 2026-09-26 (UTC). Not licensed for production use. To purchase a license, contact sales@handsontable.com. [eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI2LTA5LTI2Iiwibm90aWNlIjo0NSwiZ3JhY2UiOjE1LCJmbGFncyI6WyJ0cmlhbCIsIm5vLXVpLXdhcm5zIl19fSwidiI6MiwicHJvc2UiOiIzODczNjE5MTljNzVhNDQ1MDg0ZmY2YzUyZjAyYzRkMmM2YzIxNWFjNzZiMWQwZGJhNmIwOTM4MWYwOTA2ODVhIn0c1dccd7855f9004db2a062d37bdec85f616193de27387b34733659025c4b89e39d18db26e78546db6a8406bc791ae32ecdca985330896764429cc1cf4b1b80b5]',
+ subscription: 'This is a Handsontable license key for Test Fixture, issued on 2026-08-12 for the "Fixture Project" project. It includes 1 license: > 1. Subscription license under Handsontable Subscription License Agreement 2.0 of 2022-05-21, for Handsontable on the Enterprise package, for internal use, valid until 2027-08-12 (UTC). Use after that date is not permitted. To renew, contact sales@handsontable.com. [eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjo2MCwiZ3JhY2UiOjkwLCJmbGFncyI6W119fSwidiI6MiwicHJvc2UiOiIwNjIzNWUxYjY5ZmQ1YjNmNjg2NjdhNTcxNmU5YWY5YWU5YTkxYjNkNzc5ZTBlY2FkODIyMjQ2NDU1YjUxZTA0In0e9c5c2a5838f3daf149124a7ad1a4e30710d8367b4814bb1a0092032677fa10fb605cb4325db23ae9078a30024d5df992c12074ab7a31321ef237d22573316fc]',
+ 'subscription-external': 'This is a Handsontable license key for Test Fixture, issued on 2026-08-12 for the "Fixture Project" project. It includes 1 license: > 1. Subscription license under Handsontable Subscription License Agreement 2.0 of 2022-05-21, for Handsontable on the Enterprise package, for external use, valid until 2027-08-12 (UTC). Use after that date is not permitted. To renew, contact sales@handsontable.com. [eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjo2MCwiZ3JhY2UiOjkwLCJmbGFncyI6WyJuby1jb25zb2xlLXdhcm5zIiwibm8tdWktd2FybnMiXX19LCJ2IjoyLCJwcm9zZSI6IjM1MTdlM2I0OTcyN2Q4MTRlZThlYzY5YWZlOWIwZjJjMWE3YWFmOTYwODc5ZTJlYTNlOTI3NzRhMjg5NDcwNmYifQd5c0390306d9214d72e9d2fe4cb70b658e29c92084b0be19ef2cf00fb79f02c408532752361d68c43e596701f0d56f7f5b41670f37f511fbf43e175b868a6c6e]',
+ // The trial key as license-key 4.x issued it, in the earlier format - the shape of the
+ // trial keys already in the field. It must read exactly like `trial`.
+ 'trial-v1': 'This is a Handsontable license key for Test Fixture, issued on 2026-08-12 for the "Fixture Project" project. It includes 1 license: > 1. Trial license under Handsontable Evaluation License Agreement 4.0 of 2026-03-02, for Handsontable on the Enterprise package, for internal use, valid until 2026-09-26 (UTC). Not licensed for production use. To purchase a license, contact sales@handsontable.com. [eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI2LTA5LTI2Iiwibm90aWNlIjo0NSwiZ3JhY2UiOjE1LCJmbGFncyI6WyJ0cmlhbCJdfX19a687a9f4d0d496c1ec86d97d58e971b458995f1a68ca117a6b406fa2f179923dcd42a789e3c56426690cf12c89e70abfbb6f45b96ba55fe49386d9e1b0080f2c]',
// The two install faults, which block from 18.1 on: the subscription key with the last two
// characters of its checksum changed, and no key at all. `missing` is the empty string - the
// fixture only sets `licenseKey` when the value is non-empty.
- tampered: 'This is a Handsontable license key for Test Fixture, issued on 2026-08-12 for the "Fixture Project" project. It includes 1 license: > 1. Subscription license under Handsontable Subscription License Agreement 2.0 of 2022-05-21, for Handsontable on the Enterprise package, for internal use, valid until 2027-08-12 (UTC). Use after that date is not permitted. To renew, contact sales@handsontable.com. [eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjo2MCwiZ3JhY2UiOjkwLCJmbGFncyI6W119fX0c4401c19afaf11f9c2f8df05b6aa3dc4bad6cdbbf7a535e77d4e3d95c137cf9d59a05bbc73ec35f6bdce3e3a1cfc18170c9662c877ee0477e2615fe32ab1b000]',
+ tampered: 'This is a Handsontable license key for Test Fixture, issued on 2026-08-12 for the "Fixture Project" project. It includes 1 license: > 1. Subscription license under Handsontable Subscription License Agreement 2.0 of 2022-05-21, for Handsontable on the Enterprise package, for internal use, valid until 2027-08-12 (UTC). Use after that date is not permitted. To renew, contact sales@handsontable.com. [eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjo2MCwiZ3JhY2UiOjkwLCJmbGFncyI6W119fSwidiI6MiwicHJvc2UiOiIwNjIzNWUxYjY5ZmQ1YjNmNjg2NjdhNTcxNmU5YWY5YWU5YTkxYjNkNzc5ZTBlY2FkODIyMjQ2NDU1YjUxZTA0In0e9c5c2a5838f3daf149124a7ad1a4e30710d8367b4814bb1a0092032677fa10fb605cb4325db23ae9078a30024d5df992c12074ab7a31321ef237d2257331600]',
// A real legacy key that expired on 23/05/2011: valid once, merely lapsed - it must KEEP its
// bottom bar while the two faults above take the modal.
'legacy-expired': 'd0134-95841-770f2-c4f21-3751d',
@@ -85,15 +88,20 @@
missing: '',
};
- // Three forms of the subscription key that test the checksum over the prose:
- // bare-block - the machine-readable block alone, without the prose (invalid)
- // edited-prose - one date in the prose changed, the block untouched (invalid)
- // subscription-pasted - every space turned into a CRLF line break, and a line break saved
- // as the two characters "\n" at the end, as some .env files and CI
- // secrets store one (valid)
+ // Four forms of the subscription key that test that the key protects its text, and the
+ // stores a key goes through:
+ // bare-block - the machine-readable block alone, without the prose (invalid)
+ // edited-prose - one date in the prose changed, the block untouched (invalid)
+ // subscription-pasted - every space turned into a CRLF line break, and a line break saved
+ // as the two characters "\n" at the end, as some .env files and CI
+ // secrets store one (valid)
+ // subscription-wrapped - the block broken into lines of 60 characters, as a mail client
+ // wraps it (valid)
LICENSE_KEYS['bare-block'] = LICENSE_KEYS.subscription.slice(LICENSE_KEYS.subscription.lastIndexOf('['));
LICENSE_KEYS['edited-prose'] = LICENSE_KEYS.subscription.replace('valid until 2027-08-12', 'valid until 2099-08-12');
LICENSE_KEYS['subscription-pasted'] = `${LICENSE_KEYS.subscription.replace(/ /g, '\r\n')}\\n`;
+ LICENSE_KEYS['subscription-wrapped'] = LICENSE_KEYS.subscription.slice(0, LICENSE_KEYS.subscription.lastIndexOf('[')) +
+ LICENSE_KEYS.subscription.slice(LICENSE_KEYS.subscription.lastIndexOf('[')).match(/.{1,60}/g).join('\n');
// The grid shapes the branding has to survive. Each one exists because it
// moves the corner: no corner cell at all, a corner narrower than the
diff --git a/tests/fixtures/pages/LicenseBrandingPage.ts b/tests/fixtures/pages/LicenseBrandingPage.ts
index 1a383497f4..2df3f0c56a 100644
--- a/tests/fixtures/pages/LicenseBrandingPage.ts
+++ b/tests/fixtures/pages/LicenseBrandingPage.ts
@@ -16,7 +16,7 @@ export const INSTANT = {
type LicenseKeyName = 'trial' | 'trial-external' | 'subscription' | 'subscription-external' |
'tampered' | 'legacy-expired' | 'non-commercial-padded' | 'missing' |
- 'bare-block' | 'edited-prose' | 'subscription-pasted';
+ 'bare-block' | 'edited-prose' | 'subscription-pasted' | 'subscription-wrapped' | 'trial-v1';
type Variant = 'default' | 'no-row-headers' | 'no-headers-frozen' | 'narrow-corner' | 'dialog' |
'nested' | 'narrow';