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';