Commit ad94237fab for handsontable.com

commit ad94237fab15240b543710d6dd662e67b562e8a7
Author: Krzysztof ‘Budzio’ Budnik <571316+budnix@users.noreply.github.com>
Date:   Mon Oct 5 14:49:04 2026 +0200

    DEV-3254: Cover the license key prose with the checksum (#13743)

    * DEV-3254: Cover the license key prose with the checksum

    The entitlement key checksum now covers the human-readable part as well as
    the payload, matching license-key 5.0.0 (DEV-3253, commits 7e99618, 7ca2899
    and 75154d2): SHA-512(canonical(prose) + "\n" + payload), where canonical is
    the prose in NFC with every whitespace character and every line break or tab
    saved as text (\n, \r, \t) removed.

    The prose is now required: the bare [...] block, edited or removed prose,
    and text other than whitespace after the block read as invalid. Rewrapped,
    one-line, decomposed and \n-escaped keys still read, so a key stored in an
    .env file, a CI secret or YAML keeps working.

    The fixture keys are regenerated with the license-key generator at 7ca2899
    and carry the same payloads as before, plus two new keys with an accented
    and a Japanese holder name.

    * DEV-3254: Add changelog entry for PR #13743

    * DEV-3254: Address review of the license key checksum change

    - Read a payload date as a date only when it is a string, as the canonical
      reader does: an array date no longer passes, and an object whose toString
      is not a function no longer throws out of the reader.
    - Remember a key only after its read finished, so a read that throws cannot
      pair the new key with the previous key's data.
    - Show the key in a template literal or a bound variable in the docs and the
      licenseKey API example: the key text contains quotes, so a quoted string or
      attribute cuts it short.
    - Test brackets in the prose, a non-string key, the read-then-remember order,
      and the bare block, edited prose and a re-wrapped key in Playwright.
    - Share the block/prose test helpers, fix JSDoc wording and comments.

    * DEV-3254: Address the review of the license key docs and tests

    - Pin the reader's whitespace set and its escape-before-whitespace order to
      the license-key generator with variants of a generated key, so dropping a
      character or swapping the two steps fails a test.
    - Say in the guide that a template literal only holds a key without a
      backtick or "${", and recommend single quotes (or backticks for a name
      with an apostrophe) for a key in a .env file.
    - Use an en dash in the licenseKey API example.

    * DEV-3254: Run NFC last in the canonical license key prose

    Remove the escaped line breaks and the whitespace first, then normalize to
    NFC, matching license-key bc03d89. With NFC first, a decomposed key
    rewrapped between a letter and its combining mark never composed, so it
    read as invalid even though rewrapping is allowed.

    (cherry picked from commit 08d2e9780662dd8934ba260ae9cb6f492a7f490a)

diff --git a/.changelogs/13743.json b/.changelogs/13743.json
new file mode 100644
index 0000000000..ac438c54c1
--- /dev/null
+++ b/.changelogs/13743.json
@@ -0,0 +1,8 @@
+{
+  "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.",
+  "type": "changed",
+  "issueOrPR": 13743,
+  "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 bd6910f798..1873c30b50 100644
--- a/docs/content/guides/getting-started/license-key/license-key.md
+++ b/docs/content/guides/getting-started/license-key/license-key.md
@@ -236,13 +236,18 @@ project. It includes 1 license:
 ```

 Pass the whole key string, exactly as you received it, in the same
-[`licenseKey`](@/api/options.md#licensekey) option:
+[`licenseKey`](@/api/options.md#licensekey) option. The key text contains double quotes (around the
+project name) and can contain apostrophes (in a company name), so do not paste it into a quoted
+string or a quoted HTML attribute - the first quote inside the key ends the value there. Read the key
+from your configuration, or write it as a template literal (in backticks), as in the examples below.
+A template literal works as long as the key text contains no backtick and no `${`; if it does, read
+the key from your configuration.

 ::: only-for javascript

 ```js
 const settings = {
-  licenseKey: 'This is a Handsontable license key for Acme Corp, ... [eyJwcm9kdWN0cyI6...3a4f8361]',
+  licenseKey: `This is a Handsontable license key for Acme Corp, issued on 2026-08-12 for the "Acme Portal" project. ... [eyJwcm9kdWN0cyI6...3a4f8361]`,
   //... other options
 }
 ```
@@ -252,7 +257,9 @@ const settings = {
 ::: only-for react

 ```jsx
-<HotTable licenseKey="This is a Handsontable license key for Acme Corp, ... [eyJwcm9kdWN0cyI6...3a4f8361]" />
+const licenseKey = `This is a Handsontable license key for Acme Corp, issued on 2026-08-12 for the "Acme Portal" project. ... [eyJwcm9kdWN0cyI6...3a4f8361]`;
+
+<HotTable licenseKey={licenseKey} />
 ```

 :::
@@ -263,7 +270,7 @@ const settings = {
 import { GridSettings } from "@handsontable/angular-wrapper";

 readonly gridSettings: GridSettings = {
-  licenseKey: 'This is a Handsontable license key for Acme Corp, ... [eyJwcm9kdWN0cyI6...3a4f8361]',
+  licenseKey: `This is a Handsontable license key for Acme Corp, issued on 2026-08-12 for the "Acme Portal" project. ... [eyJwcm9kdWN0cyI6...3a4f8361]`,
 };
 ```

@@ -272,17 +279,33 @@ readonly gridSettings: GridSettings = {
 ::: only-for vue

 ```html
-<HotTable licenseKey="This is a Handsontable license key for Acme Corp, ... [eyJwcm9kdWN0cyI6...3a4f8361]" />
+<script setup>
+const licenseKey = `This is a Handsontable license key for Acme Corp, issued on 2026-08-12 for the "Acme Portal" project. ... [eyJwcm9kdWN0cyI6...3a4f8361]`;
+</script>
+
+<template>
+  <HotTable :licenseKey="licenseKey" />
+</template>
 ```

 :::

 Keys issued in the 25-character format keep working without any change.

-Only the bracketed block is protected by a checksum. You can rewrap the text above it, or paste the
-key through an email client, and the key still works. Keep the block itself on one line, and end the
-key there - the block must be the last thing in the string. Space and newlines around the whole key
-are trimmed for you, so a key pasted with a trailing newline still works.
+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.
+
+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
+contains an apostrophe. Without quotes, a line break or a ` #` in the key cuts the value short, and
+double quotes end at the first double quote inside the key. Do not escape the quotes inside the key
+(`\"`) - the backslashes stay in the value, and the key becomes invalid. Never change any other
+character of the key, for example by replacing straight quotes with curly ones.

 Each license behaves differently around its date:

diff --git a/handsontable/src/dataMap/metaManager/metaSchema.ts b/handsontable/src/dataMap/metaManager/metaSchema.ts
index e0b211a05d..96f1ac6ffe 100644
--- a/handsontable/src/dataMap/metaManager/metaSchema.ts
+++ b/handsontable/src/dataMap/metaManager/metaSchema.ts
@@ -3853,8 +3853,10 @@ 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
-     * licenseKey: 'This is a Handsontable license key for Acme Corp, ... [eyJwcm9kdWN0cyI6...3a4f8361]',
+     * // 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;
+     * // 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]`,
      *
      * // for non-commercial use
      * licenseKey: 'non-commercial-and-evaluation',
diff --git a/handsontable/src/helpers/__tests__/entitlementLicenseInfo.unit.js b/handsontable/src/helpers/__tests__/entitlementLicenseInfo.unit.js
index 0545a5d944..7fb783b306 100644
--- a/handsontable/src/helpers/__tests__/entitlementLicenseInfo.unit.js
+++ b/handsontable/src/helpers/__tests__/entitlementLicenseInfo.unit.js
@@ -1,7 +1,6 @@
 /* eslint no-console: off */
 import {
   SUBSCRIPTION_KEY,
-  SUBSCRIPTION_KEY_WITH_PROSE,
   SUBSCRIPTION_EXTERNAL_KEY,
   NO_CONSOLE_WARNS_KEY,
   NO_UI_WARNS_KEY,
@@ -12,6 +11,7 @@ import {
   PERPETUAL_NO_UI_WARNS_KEY,
   HF_ONLY_KEY,
 } from '../../utils/entitlementLicenseKey/__tests__/fixtures';
+import { blockOf } from '../../utils/entitlementLicenseKey/__tests__/buildTestKey';

 const LICENSE_INFO_CLASS = 'hot-display-license-info';
 // Reference instants inside each window of the fixtures, so no test ever touches the real clock.
@@ -161,13 +161,19 @@ describe('entitlement license notification (via _injectProductInfo)', () => {
       expect(node).toBe(null);
     });

-    it('should read the complete artifact exactly as the block on its own', () => {
-      inject(SUBSCRIPTION_KEY_WITH_PROSE, { now: SUBSCRIPTION_NOTICE });
-
-      expect(console.warn).toHaveBeenCalledWith(
-        'Your Handsontable subscription license expires on 2027-08-12 (UTC). ' +
-        'To renew your license, contact sales@handsontable.com.'
-      );
+    [
+      ['on one line', SUBSCRIPTION_KEY.replace(/\s+/g, ' ')],
+      ['with Windows line endings and surrounding whitespace', `\n  ${SUBSCRIPTION_KEY.replace(/\n/g, '\r\n')}\r\n`],
+      ['with its line breaks saved as text ("\\n")', `${SUBSCRIPTION_KEY.replace(/\n/g, '\\n')}\n`],
+    ].forEach(([form, key]) => {
+      it(`should read the key pasted ${form}`, () => {
+        inject(key, { now: SUBSCRIPTION_NOTICE });
+
+        expect(console.warn).toHaveBeenCalledWith(
+          'Your Handsontable subscription license expires on 2027-08-12 (UTC). ' +
+          'To renew your license, contact sales@handsontable.com.'
+        );
+      });
     });
   });

@@ -284,6 +290,21 @@ describe('entitlement license notification (via _injectProductInfo)', () => {
       );
       expect(node).toBe(null);
     });
+
+    [
+      ['the bare block, without the prose', blockOf(SUBSCRIPTION_KEY)],
+      ['a key whose prose was edited', SUBSCRIPTION_KEY.replace('valid until 2027-08-12', 'valid until 2099-08-12')],
+      ['a key with text after the block', `${SUBSCRIPTION_KEY} extra`],
+    ].forEach(([form, key]) => {
+      it(`should treat ${form} as invalid`, () => {
+        inject(key, { now: SUBSCRIPTION_RUNNING });
+
+        expect(console.warn).toHaveBeenCalledWith(
+          'The license key for Handsontable is invalid. If you need any help, contact us at ' +
+          'support@handsontable.com.'
+        );
+      });
+    });
   });

   describe('the legacy path (regression)', () => {
diff --git a/handsontable/src/utils/entitlementLicenseKey/__tests__/buildTestKey.js b/handsontable/src/utils/entitlementLicenseKey/__tests__/buildTestKey.js
index 4a9b277954..31c5b92f9d 100644
--- a/handsontable/src/utils/entitlementLicenseKey/__tests__/buildTestKey.js
+++ b/handsontable/src/utils/entitlementLicenseKey/__tests__/buildTestKey.js
@@ -1,17 +1,47 @@
-import { sha512 } from '../sha512';
-import { stringToUtf8Bytes, stringToBase64Url } from '../encoding';
+import { canonicalizeProse, computeChecksum } from '../extractKeyData';
+import { stringToBase64Url } from '../encoding';

 /**
- * A minimal, TEST-ONLY entitlement license key builder. It assembles the machine-readable block
- * (the base64url payload plus its SHA-512 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.
+ * 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.
+ *
+ * @type {string}
+ */
+const TEST_KEY_PROSE = 'This is a test license key.';
+
+/**
+ * Returns the machine-readable `[...]` block of a key, without the prose in front of it. The block
+ * starts at the LAST "[", as in the reader, so a bracket inside the prose cannot hide it.
+ *
+ * @param {string} key The whole key.
+ * @returns {string}
+ */
+export function blockOf(key) {
+  return key.slice(key.lastIndexOf('['));
+}
+
+/**
+ * Returns the prose of a key - everything in front of its block.
+ *
+ * @param {string} key The whole key.
+ * @returns {string}
+ */
+export function proseOf(key) {
+  return key.slice(0, key.lastIndexOf('['));
+}
+
+/**
+ * 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.
  *
  * It is deliberately NOT the real generator. Generation - the prose, 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`.
+ * 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.
  *
  * 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
@@ -20,16 +50,16 @@ import { stringToUtf8Bytes, stringToBase64Url } from '../encoding';
  *
  * @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. It is neither parsed nor
- *   checksummed, so tests control it freely. Defaults to no prose at all.
+ * @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.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 = '', checksum, rawPayloadJson } = {}) {
+export function buildTestKey(payload, { prose = TEST_KEY_PROSE, checksum, rawPayloadJson } = {}) {
   const encodedPayload = stringToBase64Url(rawPayloadJson ?? JSON.stringify(payload));
-  const block = `[${encodedPayload}${checksum ?? sha512(stringToUtf8Bytes(encodedPayload))}]`;
+  const block = `[${encodedPayload}${checksum ?? computeChecksum(canonicalizeProse(prose), 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 a87931a85b..a52c82d410 100644
--- a/handsontable/src/utils/entitlementLicenseKey/__tests__/extractKeyData.unit.js
+++ b/handsontable/src/utils/entitlementLicenseKey/__tests__/extractKeyData.unit.js
@@ -1,11 +1,12 @@
-import { extractEntitlementKeyData, getProductEntitlement } from '../extractKeyData';
+import { extractEntitlementKeyData, getProductEntitlement, canonicalizeProse } from '../extractKeyData';
 import { detectLicenseKeyFormat, isEntitlementKey } from '../detectFormat';
 import { sha512 } from '../sha512';
 import { stringToUtf8Bytes } from '../encoding';
-import { buildTestKey } from './buildTestKey';
+import { buildTestKey, blockOf, proseOf } from './buildTestKey';
 import {
   SUBSCRIPTION_KEY,
-  SUBSCRIPTION_KEY_WITH_PROSE,
+  ACCENTED_HOLDER_KEY,
+  CJK_HOLDER_KEY,
   SUBSCRIPTION_EXTERNAL_KEY,
   TRIAL_KEY,
   PERPETUAL_KEY,
@@ -52,8 +53,10 @@ describe('entitlementLicenseKey/sha512', () => {
 describe('entitlementLicenseKey/detectFormat', () => {
   it('should recognize an entitlement key by its trailing block', () => {
     expect(detectLicenseKeyFormat(SUBSCRIPTION_KEY)).toBe('entitlement');
-    expect(detectLicenseKeyFormat(SUBSCRIPTION_KEY_WITH_PROSE)).toBe('entitlement');
-    expect(isEntitlementKey(SUBSCRIPTION_KEY_WITH_PROSE)).toBe(true);
+    expect(isEntitlementKey(SUBSCRIPTION_KEY)).toBe(true);
+    // The shape test does not validate: the bare block still routes to the entitlement reader,
+    // which then rejects it.
+    expect(isEntitlementKey(blockOf(SUBSCRIPTION_KEY))).toBe(true);
   });

   it('should tell the other key formats apart without validating them', () => {
@@ -137,24 +140,216 @@ describe('entitlementLicenseKey/extractKeyData', () => {
   });

   describe('the prose layer', () => {
-    it('should read the complete artifact exactly as the block on its own', () => {
-      expect(extractEntitlementKeyData(SUBSCRIPTION_KEY_WITH_PROSE))
-        .toEqual(extractEntitlementKeyData(SUBSCRIPTION_KEY));
+    const expected = () => extractEntitlementKeyData(SUBSCRIPTION_KEY);
+
+    it('should reject the bare block of a real key, whose checksum was computed over 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', () => {
+      const key = buildTestKey({ products: { handsontable: handsontableEntry() } }, { prose: '' });
+
+      expect(key.startsWith('[')).toBe(true);
+      expect(extractEntitlementKeyData(key)).toBeNull();
+      // Whitespace alone is empty prose, too.
+      expect(extractEntitlementKeyData(` \n\t${key}`)).toBeNull();
+    });
+
+    it('should reject a key whose prose was edited, added to, or replaced', () => {
+      const prose = proseOf(SUBSCRIPTION_KEY);
+      const block = blockOf(SUBSCRIPTION_KEY);
+
+      [
+        prose.replace('2027-08-12 (UTC)', '2099-08-12 (UTC)'), // one sentence changed
+        prose.replace('internal use', 'external use'),
+        prose.replace('Test Fixture', 'Test Fixturf'), // one letter changed
+        `${prose.split('\n\n')[0]}\n\n`, // the license sentence removed
+        `Anything at all.\n\n${prose}`, // text added in front
+        'Anything at all.\n\n', // replaced entirely
+        `${prose.replace(/\.$/m, '')}`, // a period dropped
+      ].forEach((edited) => {
+        expect(edited).not.toBe(prose);
+        expect(extractEntitlementKeyData(edited + block)).toBeNull();
+      });
+    });
+
+    it('should reject a key with text other than whitespace after the block', () => {
+      // None of these has a "[", so the block the reader finds is still the real one and only the
+      // trailing-text rule can reject the key.
+      ['x', ' x', '\n.', '"', ']', '\\', '\\x', '\\n x'].forEach((suffix) => {
+        expect(extractEntitlementKeyData(SUBSCRIPTION_KEY + suffix)).toBeNull();
+      });
     });

-    it('should survive prose that was rewritten, rewrapped, or replaced entirely', () => {
-      const block = SUBSCRIPTION_KEY;
+    it('should find the block behind brackets in the prose', () => {
+      const payload = { products: { handsontable: handsontableEntry() } };
+
+      [
+        'This is a license key for Acme [EU] Ltd.',
+        'Acme [EU] Ltd. [1] [',
+        'Acme ] Ltd. [[x]]',
+      ].forEach((prose) => {
+        const key = buildTestKey(payload, { prose });
+
+        expect(isEntitlementKey(key)).toBe(true);
+        expect(getProductEntitlement(extractEntitlementKeyData(key), 'handsontable'))
+          .toEqual(handsontableEntry());
+      });
+    });
+
+    it('should accept whitespace after the block', () => {
+      [' ', '\n', '\r\n', '\t', '\n\n  \n'].forEach((suffix) => {
+        expect(extractEntitlementKeyData(SUBSCRIPTION_KEY + suffix)).toEqual(expected());
+      });
+    });

-      ['Anything at all.\n\n', 'Line one\nline two\n\n', '   ', '[not a block] '].forEach((prose) => {
-        expect(extractEntitlementKeyData(prose + block)).not.toBeNull();
+    it('should read a key that was rewrapped, put on one line, or had its whitespace changed', () => {
+      const prose = proseOf(SUBSCRIPTION_KEY);
+      const block = blockOf(SUBSCRIPTION_KEY);
+
+      [
+        SUBSCRIPTION_KEY.replace(/\s+/g, ' '), // the one-line form the generator prints
+        prose.replace(/\s+/g, '') + block, // every whitespace character removed
+        prose.replace(/ /g, '\n') + block, // every space turned into a line break
+        prose.replace(/\n/g, '\r\n') + block, // Windows line endings
+        prose.replace(/\n/g, '\r') + block, // old Mac line endings
+        prose.replace(/\n\n/g, '\n') + block, // the blank lines collapsed
+        prose.replace(/^/gm, '\t') + block, // every line indented with a tab
+        prose.replace(/ /g, '\u00a0') + block, // non-breaking spaces from a word processor
+        prose.replace(/ /g, '\u3000') + block, // ideographic spaces
+        prose.replace('Handsontable', 'Hands\nontable') + block, // a line break inside a word
+        `\ufeff${SUBSCRIPTION_KEY}`, // a byte order mark from a file
+      ].forEach((variant) => {
+        expect(variant).not.toBe(SUBSCRIPTION_KEY);
+        expect(extractEntitlementKeyData(variant)).toEqual(expected());
+      });
+    });
+
+    // 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.
+    it('should ignore exactly the whitespace characters the generator ignores', () => {
+      const prose = proseOf(SUBSCRIPTION_KEY);
+      const block = blockOf(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];
+      // NEXT LINE, MONGOLIAN VOWEL SEPARATOR, ZERO WIDTH SPACE, and WORD JOINER are not in the set.
+      const kept = [0x85, 0x180e, 0x200b, 0x2060];
+      const variant = code => prose.replace(/ /g, String.fromCharCode(code)) + block;
+
+      ignored.forEach((code) => {
+        expect(extractEntitlementKeyData(variant(code))).toEqual(expected());
+      });
+      kept.forEach((code) => {
+        expect(extractEntitlementKeyData(variant(code))).toBeNull();
+      });
+    });
+
+    it('should remove a line break saved as text before the whitespace, not after it', () => {
+      const prose = proseOf(SUBSCRIPTION_KEY);
+      const block = blockOf(SUBSCRIPTION_KEY);
+
+      // "\n" is removed; "\ n" is not, because the space is only removed after the escapes are.
+      expect(extractEntitlementKeyData(prose.replace('Test Fixture', 'Test \\nFixture') + block))
+        .toEqual(expected());
+      expect(extractEntitlementKeyData(prose.replace('Test Fixture', 'Test \\ nFixture') + block)).toBeNull();
+    });
+
+    it('should read a key whose line breaks were saved as text, as some .env files and CI secrets do', () => {
+      const prose = proseOf(SUBSCRIPTION_KEY);
+      const block = blockOf(SUBSCRIPTION_KEY);
+
+      [
+        prose.replace(/\n/g, '\\n') + block, // "\n" as two characters
+        prose.replace(/\n/g, '\\r\\n') + block, // "\r\n" as four characters
+        prose.replace(/\n/g, '\\n').replace(/ /g, '\\t') + block, // "\t" as two characters
+        `${prose.replace(/\n/g, '\\n') + block}\n`, // and a real trailing newline
+        `${prose.replace(/\n/g, '\\n') + block}\\n`, // and a trailing newline saved as text, too
+        `${prose.replace(/\n/g, '\\r\\n') + block}\\r\\n`,
+      ].forEach((variant) => {
+        expect(variant).not.toContain('\n\n');
+        expect(extractEntitlementKeyData(variant)).toEqual(expected());
+      });
+    });
+
+    it('should reject a key that a store changed instead of only rewrapping it', () => {
+      const prose = proseOf(SUBSCRIPTION_KEY);
+      const block = blockOf(SUBSCRIPTION_KEY);
+
+      [
+        prose.replace(/\n/g, '\\\\n') + block, // line breaks escaped twice ("\\n") - a backslash is left
+        prose.replace(/\n/g, '/n') + block, // a wrong escape
+        prose.replace(/"/g, '') + block, // the quotes around the project name removed
+        prose.replace(/"/g, '\'') + block, // ... or swapped for apostrophes
+        prose.replace('"Fixture Project"', '\u201cFixture Project\u201d') + block, // curly quotes
+        `"${SUBSCRIPTION_KEY}"`, // the key kept inside its JSON quotes
+        `'${SUBSCRIPTION_KEY}'`,
+        prose.split('\n')[0] + block, // a store that kept the first line only
+      ].forEach((variant) => {
+        expect(extractEntitlementKeyData(variant)).toBeNull();
       });
     });

     it('should reject a key whose block was broken by a line wrap', () => {
-      const block = SUBSCRIPTION_KEY;
-      const wrapped = `${block.slice(0, 60)}\n${block.slice(60)}`;
+      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)}`;
+
+        expect(extractEntitlementKeyData(wrapped)).toBeNull();
+      });
+    });
+
+    it('should read a key whose prose has composed characters, also when it is stored decomposed (NFD)', () => {
+      const decomposed = ACCENTED_HOLDER_KEY.normalize('NFD');
+
+      expect(ACCENTED_HOLDER_KEY).toBe(ACCENTED_HOLDER_KEY.normalize('NFC'));
+      expect(decomposed).not.toBe(ACCENTED_HOLDER_KEY);
+      expect(extractEntitlementKeyData(ACCENTED_HOLDER_KEY)).toEqual(expected());
+      expect(extractEntitlementKeyData(decomposed)).toEqual(expected());
+      expect(extractEntitlementKeyData(ACCENTED_HOLDER_KEY.replace('\u00fc', 'u'))).toBeNull();
+    });
+
+    it('should read a decomposed (NFD) key rewrapped between a letter and its combining mark', () => {
+      // NFC has to run after the whitespace is removed, or the letter and the mark never compose.
+      const decomposed = [...ACCENTED_HOLDER_KEY.normalize('NFD')];
+      const markPositions = decomposed
+        .map((char, index) => (/\p{M}/u.test(char) ? index : -1))
+        .filter(index => index > 0);
+
+      expect(markPositions.length).toBeGreaterThan(0);
+
+      markPositions.forEach((index) => {
+        const wrapped = `${decomposed.slice(0, index).join('')}\n${decomposed.slice(index).join('')}`;
+
+        expect(extractEntitlementKeyData(wrapped)).toEqual(expected());
+      });
+    });
+
+    it('should read a key whose CJK prose was wrapped between two CJK characters', () => {
+      const prose = proseOf(CJK_HOLDER_KEY);
+      const block = blockOf(CJK_HOLDER_KEY);
+      const holder = '\u682a\u5f0f\u4f1a\u793e\u30c6\u30b9\u30c8\u30d5\u30a3\u30af\u30b9\u30c1\u30e3';
+
+      expect(prose).toContain(holder);
+      expect(extractEntitlementKeyData(CJK_HOLDER_KEY)).toEqual(expected());
+
+      for (let i = 1; i < holder.length; i++) {
+        const wrapped = prose.replace(holder, `${holder.slice(0, i)}\n${holder.slice(i)}`);
+
+        expect(extractEntitlementKeyData(wrapped + block)).toEqual(expected());
+      }

-      expect(extractEntitlementKeyData(wrapped)).toBeNull();
+      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.
+      expect(canonicalizeProse(proseOf(SUBSCRIPTION_KEY))).not.toMatch(/\s/);
+      expect(canonicalizeProse('a \\n b\n\tc\u3000d')).toBe('abcd');
     });
   });

@@ -187,7 +382,9 @@ describe('entitlementLicenseKey/extractKeyData', () => {
       const key = buildTestKey({ products: { handsontable: handsontableEntry() } });

       // A character outside the base64url alphabet, injected at the head of the block.
-      expect(extractEntitlementKeyData(`[!${key.slice(1)}`)).toBeNull();
+      const blockStart = key.lastIndexOf('[') + 1;
+
+      expect(extractEntitlementKeyData(`${key.slice(0, blockStart)}!${key.slice(blockStart)}`)).toBeNull();
       expect(extractEntitlementKeyData(buildTestKey(
         { products: { handsontable: handsontableEntry() } },
         { checksum: 'Z'.repeat(128) },
@@ -217,7 +414,8 @@ describe('entitlementLicenseKey/extractKeyData', () => {
     });

     it('should reject every date spelling that is not a real bare YYYY-MM-DD (J6)', () => {
-      ['2027-8-12', '12-08-2027', '2027-08-12T00:00:00Z', 1786455012, '2027-02-30', '', null]
+      // `['2027-08-12']` stringifies to a valid date, so only a type check rejects it.
+      ['2027-8-12', '12-08-2027', '2027-08-12T00:00:00Z', 1786455012, '2027-02-30', '', null, ['2027-08-12'], {}]
         .forEach((date) => {
           expect(extractEntitlementKeyData(buildTestKey({
             products: { handsontable: handsontableEntry({ usage_until: date }) },
@@ -344,6 +542,57 @@ describe('entitlementLicenseKey/extractKeyData', () => {
       expect(extractEntitlementKeyData(SUBSCRIPTION_KEY)).toBe(extractEntitlementKeyData(SUBSCRIPTION_KEY));
     });

+    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.
+      const crafted = buildTestKey({
+        products: { handsontable: handsontableEntry({ usage_until: { toString: 1, valueOf: 1 } }) },
+      });
+
+      expect(extractEntitlementKeyData(crafted)).toBeNull();
+    });
+
+    it('should never hand the previous key\'s data to a key whose read threw', () => {
+      jest.isolateModules(() => {
+        let throwOnce = false;
+
+        jest.doMock('../sha512', () => {
+          const { sha512: realSha512 } = jest.requireActual('../sha512');
+
+          return {
+            sha512: (bytes) => {
+              if (throwOnce) {
+                throwOnce = false;
+                throw new Error('read failed');
+              }
+
+              return realSha512(bytes);
+            },
+          };
+        });
+
+        // eslint-disable-next-line global-require
+        const { extractEntitlementKeyData: read } = require('../extractKeyData');
+        const subscription = read(SUBSCRIPTION_KEY);
+
+        throwOnce = true;
+        expect(() => read(TRIAL_KEY)).toThrow('read failed');
+
+        // The failed read must not have paired TRIAL_KEY with the subscription's data.
+        const trial = read(TRIAL_KEY);
+
+        expect(trial).not.toBe(subscription);
+        expect(getProductEntitlement(trial, 'handsontable').flags).toEqual(['trial']);
+      });
+      jest.dontMock('../sha512');
+    });
+
+    it('should read a non-string key as invalid', () => {
+      [undefined, null, 42, {}, ['[x]']].forEach((key) => {
+        expect(extractEntitlementKeyData(key)).toBeNull();
+      });
+    });
+
     it('should re-read when the key changes', () => {
       const first = extractEntitlementKeyData(SUBSCRIPTION_KEY);

diff --git a/handsontable/src/utils/entitlementLicenseKey/__tests__/fixtures.js b/handsontable/src/utils/entitlementLicenseKey/__tests__/fixtures.js
index d4028fc54b..f1ebb75be4 100644
--- a/handsontable/src/utils/entitlementLicenseKey/__tests__/fixtures.js
+++ b/handsontable/src/utils/entitlementLicenseKey/__tests__/fixtures.js
@@ -10,9 +10,11 @@
  * 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).
  *
- * The `*_KEY` constants are the machine-readable block on its own, which reads exactly like the
- * whole artifact - the prose is not covered by the checksum. `SUBSCRIPTION_KEY_WITH_PROSE` is the
- * one complete key, kept so the prose-tolerance rule stays covered.
+ * 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.
  *
  * Pin `Date.now` in tests; never rely on the real clock.
  */
@@ -33,100 +35,167 @@ export const MAINTENANCE_UNTIL = '2027-08-12';
  *
  * @type {string}
  */
-export const SUBSCRIPTION_KEY = '[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjo2MCwiZ3JhY2UiOjkwLCJmbGFncyI6W119fX03de5d59e480be17d3c8cd340cb242cab64cac7888cbfba6c975c194f6613b8103792a6929f66852d18c7ac27c8c25fa9ab8a25b5e25f331669746c833a4f8361]';
+export const 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.
+
+[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjo2MCwiZ3JhY2UiOjkwLCJmbGFncyI6W119fX0c4401c19afaf11f9c2f8df05b6aa3dc4bad6cdbbf7a535e77d4e3d95c137cf9d59a05bbc73ec35f6bdce3e3a1cfc18170c9662c877ee0477e2615fe32ab1b044]`;

 /**
  * As above, issued for external use, so both silencing flags are set (example A2).
  *
  * @type {string}
  */
-export const SUBSCRIPTION_EXTERNAL_KEY = '[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjo2MCwiZ3JhY2UiOjkwLCJmbGFncyI6WyJuby1jb25zb2xlLXdhcm5zIiwibm8tdWktd2FybnMiXX19fQbf6f3e8356a12fe1b2553ccd263dbc9a6f94c1fdc1aab949b3550865cb150c79027aa06a11c7618d5cbd1e14c4b73dc35f6bb81cd8c33c474194d082e2951106]';
+export const SUBSCRIPTION_EXTERNAL_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 external use, valid until 2027-08-12 (UTC). Use after that date is not permitted. To renew, contact sales@handsontable.com.
+
+[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjo2MCwiZ3JhY2UiOjkwLCJmbGFncyI6WyJuby1jb25zb2xlLXdhcm5zIiwibm8tdWktd2FybnMiXX19fQ54053e73eda38c08afbd0554d564ecf1d7b03bb93ea2ca739e4cd441049667a256a3b28f595e2e265373356f2a065aa70721a6594ff1441c13e400cc3a093c37]`;

 /**
  * Handsontable trial, `usage_until` 2026-09-26, notice 45, grace 15, flag `trial` (example A3).
  *
  * @type {string}
  */
-export const TRIAL_KEY = '[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI2LTA5LTI2Iiwibm90aWNlIjo0NSwiZ3JhY2UiOjE1LCJmbGFncyI6WyJ0cmlhbCJdfX19a687a9f4d0d496c1ec86d97d58e971b458995f1a68ca117a6b406fa2f179923dcd42a789e3c56426690cf12c89e70abfbb6f45b96ba55fe49386d9e1b0080f2c]';
+export const 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.
+
+[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI2LTA5LTI2Iiwibm90aWNlIjo0NSwiZ3JhY2UiOjE1LCJmbGFncyI6WyJ0cmlhbCJdfX195f2c28b185a0f34f2c85b97568c44f8930d30dd58f4901815807d550bc45716c98c6e9d01ea036efb6cfda123b7dfb7904cb5e946e0a407c0f5329e72691d715]`;

 /**
  * Handsontable, `release_until` 2027-08-12, notice 0, grace 0 (example A4).
  *
  * @type {string}
  */
-export const PERPETUAL_KEY = '[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwicmVsZWFzZV91bnRpbCI6IjIwMjctMDgtMTIiLCJub3RpY2UiOjAsImdyYWNlIjowLCJmbGFncyI6W119fX00065b06ae41f7d6a7eba9552db7dc97a1c2c9aa809d58fe31ce5474541fed536fe018324cb8e1cf56ca86a39e1b193a005a0d5e16298e065f9424b97e1262e3a]';
+export const PERPETUAL_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. 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]`;

 /**
  * A1 with the console silenced by request, the UI left alone (example A7).
  *
  * @type {string}
  */
-export const NO_CONSOLE_WARNS_KEY = '[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjo2MCwiZ3JhY2UiOjkwLCJmbGFncyI6WyJuby1jb25zb2xlLXdhcm5zIl19fX0e8024d8aa137ea69d18125485c53f8e014e6910059383a657fec7193de59e456b55865c91bc04fe2cd0e93260fc8b398cf9352908011ffa18eaa66665e54bd02]';
+export const NO_CONSOLE_WARNS_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.
+
+[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjo2MCwiZ3JhY2UiOjkwLCJmbGFncyI6WyJuby1jb25zb2xlLXdhcm5zIl19fX07781e15b303d299085f1762bc862e80e0971ce057cb0cb07047169dddcd8f97b09e70762bd79cdb86a94ffbdb1b4e3445578f25c2ed99ca652de73f5a44d09be]`;

 /**
  * A1 with the UI silenced by request, the console left alone (example A8).
  *
  * @type {string}
  */
-export const NO_UI_WARNS_KEY = '[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjo2MCwiZ3JhY2UiOjkwLCJmbGFncyI6WyJuby11aS13YXJucyJdfX190428756425f781d2cc83b29ddac1ba2072c9414e94f1abdcbd55e14c024f8fd6cdbcd07ba2a6cd35e5686be525e914b9293bc9685206f261e39a323fb6edf1fa]';
+export const NO_UI_WARNS_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.
+
+[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjo2MCwiZ3JhY2UiOjkwLCJmbGFncyI6WyJuby11aS13YXJucyJdfX1934a26b98650bca363a77bd859bb921d34d7c213a58355e5aac1a4eaa812ecf07468d66644ea22ff7f0db15b636fca5cd4ddf30ec2116cbae32ff6b110b50e623]`;

 /**
  * HyperFormula only - a checksum-valid key that is not a Handsontable license (example B1).
  *
  * @type {string}
  */
-export const HF_ONLY_KEY = '[eyJwcm9kdWN0cyI6eyJoeXBlcmZvcm11bGEiOnsiY2FwYWJpbGl0aWVzIjpbImZ1bmN0aW9uc18xIl0sInVzYWdlX3VudGlsIjoiMjAyNy0wOC0xMiIsIm5vdGljZSI6NjAsImdyYWNlIjo5MCwiZmxhZ3MiOltdfX1950c11d5d71342f92b041a2429c9f40e40b14738d6920d01df955c551701197c39ff659872d1341a3d3d8aec098495a1676143880291dc4ca4f072396ad8dc42b]';
+export const HF_ONLY_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 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]`;

 /**
  * Handsontable on `usage_until` 2027-08-12 plus HyperFormula on `release_until` 2025-03-31 (example C4).
  *
  * @type {string}
  */
-export const MIXED_KEY = '[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjo2MCwiZ3JhY2UiOjkwLCJmbGFncyI6W119LCJoeXBlcmZvcm11bGEiOnsiY2FwYWJpbGl0aWVzIjpbImZ1bmN0aW9uc18xIiwiZnVuY3Rpb25zXzIiXSwicmVsZWFzZV91bnRpbCI6IjIwMjUtMDMtMzEiLCJub3RpY2UiOjAsImdyYWNlIjowLCJmbGFncyI6W119fX0fc27742ba8bd686d2d9a8edc93e99730ee30d46c4406299140a3682e0c3c36f0d681a57b894d0480ff282f128ce1acfc4fd704914ae651616265c91a2c8bee03]';
+export const MIXED_KEY = `This is a Handsontable license key for Test Fixture, issued on 2026-08-12 for the "Fixture Project" project. It includes 2 licenses:
+
+> 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.
+
+> 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]`;

 /**
  * A1 with `notice: 0` - quiet before expiry, loud after (example E2).
  *
  * @type {string}
  */
-export const NO_NOTICE_KEY = '[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjowLCJncmFjZSI6OTAsImZsYWdzIjpbXX19fQ0d5cd3b6b00d7c6e77e4d03fcd3b609a1b17d2ee1f04afc6d0d3246aaaaae28f06629e83a4bb9309bd891bbb83e20f720ae65fef9c123b89b5c047b7c55a4a3f]';
+export const NO_NOTICE_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.
+
+[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjowLCJncmFjZSI6OTAsImZsYWdzIjpbXX19fQaa757b0e8f41d9b642f9d4816af9177a4f16b00ae1ccff0359b87795e1a5169d3d1facdee644cd56026e9e96e743c528cac973cf3814a4d680b8924449844773]`;

 /**
  * Individually negotiated terms: `usage_until` 2029-12-31, notice 180, grace 180, flag `custom` (example E4).
  *
  * @type {string}
  */
-export const CUSTOM_FLAG_KEY = '[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI5LTEyLTMxIiwibm90aWNlIjoxODAsImdyYWNlIjoxODAsImZsYWdzIjpbImN1c3RvbSJdfX196c74d52c783f41f16150492eeebf09806f6fdae81a1f4617eb755bf5fcfd303afd18df4e957ca4585269188f0c3d3d628560e313c0b0bc99d96a1d0be3fa8b62]';
+export const CUSTOM_FLAG_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 2029-12-31 (UTC). Use after that date is not permitted. To renew, contact sales@handsontable.com.
+
+[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI5LTEyLTMxIiwibm90aWNlIjoxODAsImdyYWNlIjoxODAsImZsYWdzIjpbImN1c3RvbSJdfX196a8ea38761d51d49dbbf59c526765d77b6dcc7d44358ac068073a2dd132ff9155ecb6ae751f6e0851cbcccfa23465c6278622b8ddf4b184bfa0b946527826d16]`;

 /**
  * A trial with the console silenced by request - the badge, popover and bar stay (examples A3 + A7).
  *
  * @type {string}
  */
-export const TRIAL_NO_CONSOLE_WARNS_KEY = '[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI2LTA5LTI2Iiwibm90aWNlIjo0NSwiZ3JhY2UiOjE1LCJmbGFncyI6WyJ0cmlhbCIsIm5vLWNvbnNvbGUtd2FybnMiXX19fQd46b908f88f5bb066023835791d733bfcfe080b5b455157207d2e47d8afee20ba225b795f67e64b4030f2d30e7d42bff20afbb38380dcc1b65357461f7a24c19]';
+export const TRIAL_NO_CONSOLE_WARNS_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.
+
+[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI2LTA5LTI2Iiwibm90aWNlIjo0NSwiZ3JhY2UiOjE1LCJmbGFncyI6WyJ0cmlhbCIsIm5vLWNvbnNvbGUtd2FybnMiXX19fQ9a0b1fb7f72971a4abf34ff5320f33a3f828849f6aa0a3e8bfaae0d0ee2bfc322a4035a42ae048fe790d026fb17b40b876f4b38553c44abac1bb6a0398734486]`;

 /**
  * A trial with the UI silenced by request - only the console speaks (examples A3 + A8).
  *
  * @type {string}
  */
-export const TRIAL_NO_UI_WARNS_KEY = '[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI2LTA5LTI2Iiwibm90aWNlIjo0NSwiZ3JhY2UiOjE1LCJmbGFncyI6WyJ0cmlhbCIsIm5vLXVpLXdhcm5zIl19fX0dbeff87ec565b90d59175cd5aa86b93abd4164fc2967f9156e289f5db90f0b8c19ddfb44efd92a165b2d1c568c0097c78c243701acf200487372dc8b45a0f1be]';
+export const TRIAL_NO_UI_WARNS_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.
+
+[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI2LTA5LTI2Iiwibm90aWNlIjo0NSwiZ3JhY2UiOjE1LCJmbGFncyI6WyJ0cmlhbCIsIm5vLXVpLXdhcm5zIl19fX0061fb508cce2411f2221e4218b2e05e791db52682a6997d885f9769f997027c52cdd87c75aab81e9dd670309874b466a87a74f7df268009d4f39acbad1fb3051]`;

 /**
  * A perpetual license with the UI silenced, so a lapsed maintenance date shows no bar (example A5).
  *
  * @type {string}
  */
-export const PERPETUAL_NO_UI_WARNS_KEY = '[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwicmVsZWFzZV91bnRpbCI6IjIwMjctMDgtMTIiLCJub3RpY2UiOjAsImdyYWNlIjowLCJmbGFncyI6WyJuby11aS13YXJucyJdfX19f99c791278f85a3af95d7af29340f3a6660d2067e193e3dc879c54a7467087c8e0ec2b849129b7c2835c9d580b225c69f64503d3851ecdcb06dd487550fae047]';
+export const PERPETUAL_NO_UI_WARNS_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. 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]`;
+
+/**
+ * A1 issued to a holder whose name has composed (NFC) characters - "\u00fc", "\u00d6", "\u0141" and
+ * others. The checksum covers the prose in NFC, so this key proves that the reader normalizes the
+ * prose the same way the generator does, and that a decomposed (NFD) copy still reads.
+ *
+ * @type {string}
+ */
+export const ACCENTED_HOLDER_KEY = `This is a Handsontable license key for Z\u00fcrich \u00d6d\u00f6n \u0141\u00f3d\u017a Ltd., 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.
+
+[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjo2MCwiZ3JhY2UiOjkwLCJmbGFncyI6W119fX0f65153334f11db05850685a07ff89f3710dbf46faba3e4cee4bb3a181272a69aea5fd082b85261a79d419c16cfaee5ac1bf1941f386cbbc1c89c41ba11767181]`;

 /**
- * The complete A1 artifact - the prose the customer reads, then the block. It reads identically to
- * `SUBSCRIPTION_KEY`, which is the point: the prose is neither parsed nor checksummed.
+ * A1 issued to a holder whose name is written in Japanese, so the prose has runs of CJK characters
+ * with no spaces between them - the text a mail client may break at any character.
  *
  * @type {string}
  */
-export const SUBSCRIPTION_KEY_WITH_PROSE = `This is a Handsontable license key for Test Fixture, issued on 2026-08-12 for the "Fixture Project" project. It includes 1 license:
+export const CJK_HOLDER_KEY = `This is a Handsontable license key for \u682a\u5f0f\u4f1a\u793e\u30c6\u30b9\u30c8\u30d5\u30a3\u30af\u30b9\u30c1\u30e3, 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]`;
+[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjo2MCwiZ3JhY2UiOjkwLCJmbGFncyI6W119fX0eea2196d4da54636b27e38cf5f71588326794d5043beb39d65ec3e88e57a09e89e9772fb8dd44a4d8e928b04303666323e75c42018a035acf06203a481c9cf39]`;
diff --git a/handsontable/src/utils/entitlementLicenseKey/encoding.ts b/handsontable/src/utils/entitlementLicenseKey/encoding.ts
index e463597526..7e3be6ecdd 100644
--- a/handsontable/src/utils/entitlementLicenseKey/encoding.ts
+++ b/handsontable/src/utils/entitlementLicenseKey/encoding.ts
@@ -194,8 +194,11 @@ export function base64ToString(base64: string): string | null {
  * @param {string} isoDate The date to parse.
  * @returns {number|null}
  */
-export function parseIsoDateToTimestamp(isoDate: string): number | null {
-  const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(`${isoDate}`);
+export function parseIsoDateToTimestamp(isoDate: unknown): number | null {
+  // Only a string is a date. Checked before the text test on purpose: an array
+  // like `['2027-08-12']` stringifies to a valid date and would otherwise pass,
+  // and an object whose `toString` is not a function would throw.
+  const match = typeof isoDate === 'string' ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate) : null;

   if (match === null) {
     return null;
diff --git a/handsontable/src/utils/entitlementLicenseKey/extractKeyData.ts b/handsontable/src/utils/entitlementLicenseKey/extractKeyData.ts
index e4ea6a3dd7..b435aa36af 100644
--- a/handsontable/src/utils/entitlementLicenseKey/extractKeyData.ts
+++ b/handsontable/src/utils/entitlementLicenseKey/extractKeyData.ts
@@ -13,6 +13,71 @@ import { base64ToString, stringToUtf8Bytes, parseIsoDateToTimestamp } from './en
 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.
+ *
+ * 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
+ * key generator exactly.
+ *
+ * @type {RegExp}
+ */
+const PROSE_WHITESPACE = /[\t\n\v\f\r \u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]+/g;
+
+/**
+ * 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.
+ *
+ * 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`,
+ * and many CI secret fields. The block survives that intact, so without this
+ * a genuine key would fail only because of how it was stored. The generator
+ * strips backslashes from every free-text field, so a key it issues never
+ * carries one of its own.
+ *
+ * @type {RegExp}
+ */
+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.
+ *
+ * Only the whitespace, its escaped forms, and the Unicode composition are
+ * ignored. A mail client that rewraps the text (also between two CJK
+ * characters or inside a word) or collapses a blank line, a `.env` file that
+ * saves a line break as `\n`, or a system that stores "u" + U+0308 instead of
+ * U+00FC leaves the key valid. A changed, added, or removed letter, digit, or
+ * symbol does not.
+ * Exported for the test key builder; the library calls `extractEntitlementKeyData`.
+ *
+ * @param {string} prose The text in front of the machine-readable block.
+ * @returns {string}
+ */
+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');
+}
+
+/**
+ * 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.
+ *
+ * @param {string} canonicalProse The prose, already passed through `canonicalizeProse`.
+ * @param {string} encodedPayload The base64url payload.
+ * @returns {string}
+ */
+export function computeChecksum(canonicalProse: string, encodedPayload: string): string {
+  return sha512(stringToUtf8Bytes(`${canonicalProse}\n${encodedPayload}`));
+}
+
 /**
  * Reports own-property presence without trusting a payload's inherited or
  * overridden `hasOwnProperty`.
@@ -111,7 +176,7 @@ function normalizeProductEntry(entry: unknown): ProductEntitlement | null {
   if (presentDateFields.length !== 1) {
     return null;
   }
-  if (parseIsoDateToTimestamp(`${entry[presentDateFields[0]]}`) === null) {
+  if (parseIsoDateToTimestamp(entry[presentDateFields[0]]) === null) {
     return null;
   }
   if (!isNonNegativeInteger(entry.notice) || !isNonNegativeInteger(entry.grace)) {
@@ -163,6 +228,21 @@ 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);

   if (content.length <= CHECKSUM_LENGTH) {
@@ -175,7 +255,7 @@ function readEntitlementKeyData(licenseKey: string): EntitlementKeyData | null {
   if (!ENCODED_PAYLOAD.test(encodedPayload) || !CHECKSUM.test(checksum)) {
     return null;
   }
-  if (sha512(stringToUtf8Bytes(encodedPayload)) !== checksum) {
+  if (computeChecksum(canonicalProse, encodedPayload) !== checksum) {
     return null;
   }

@@ -232,14 +312,17 @@ 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 block. A malformed or tampered key reads as `null` - reporting
- * an invalid key is the caller's job, not this function's.
+ * 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.
  *
- * Only the bracketed block matters. The prose in front of it is neither parsed
- * nor covered by the checksum, so the caller may pass the whole key or just the
- * `[...]` block, and 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.
+ * 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
+ * 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.
  *
  * Unknown products, capability tokens and flags are all tolerated, so nothing
  * about reading a key depends on the commercial vocabulary.
@@ -248,11 +331,17 @@ let memoizedData: EntitlementKeyData | null = null;
  * @returns {EntitlementKeyData|null}
  */
 export function extractEntitlementKeyData(licenseKey: string): EntitlementKeyData | null {
-  const key = `${licenseKey}`;
-
-  if (key !== memoizedKey) {
-    memoizedKey = key;
-    memoizedData = readEntitlementKeyData(key);
+  if (typeof licenseKey !== 'string') {
+    return null;
+  }
+  if (licenseKey !== memoizedKey) {
+    // Read first, remember second. Were the key remembered before the read,
+    // a read that throws would leave the new key paired with the previous
+    // key's data, and the next read of the new key would return it.
+    const data = readEntitlementKeyData(licenseKey);
+
+    memoizedKey = licenseKey;
+    memoizedData = data;
   }

   return memoizedData;
diff --git a/tests/e2e/license-branding.spec.ts b/tests/e2e/license-branding.spec.ts
index 93cb4e3e1e..a5f0c6eff2 100644
--- a/tests/e2e/license-branding.spec.ts
+++ b/tests/e2e/license-branding.spec.ts
@@ -260,6 +260,10 @@ 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.
+      { 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.' },
     ] as const;

@@ -344,7 +348,9 @@ test.describe('entitlement license key branding', () => {
   test.describe('a subscription past its grace period', () => {
     // 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.
-    for (const key of ['subscription', 'subscription-external'] as const) {
+    // `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) {
       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 116348e3ed..d0593c2b34 100644
--- a/tests/fixtures/demo/license-branding.html
+++ b/tests/fixtures/demo/license-branding.html
@@ -55,24 +55,25 @@
   <script>
     // The license keys, generated by the `license-key` package and shared with
     // the unit fixtures (handsontable/src/utils/entitlementLicenseKey/__tests__/fixtures.js).
-    // Only the machine-readable block is kept: the prose in front of it is
-    // neither parsed nor checksummed, so the block reads identically.
+    // 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.
     //
     //   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: '[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI2LTA5LTI2Iiwibm90aWNlIjo0NSwiZ3JhY2UiOjE1LCJmbGFncyI6WyJ0cmlhbCJdfX19a687a9f4d0d496c1ec86d97d58e971b458995f1a68ca117a6b406fa2f179923dcd42a789e3c56426690cf12c89e70abfbb6f45b96ba55fe49386d9e1b0080f2c]',
+      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]',
       // 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': '[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI2LTA5LTI2Iiwibm90aWNlIjo0NSwiZ3JhY2UiOjE1LCJmbGFncyI6WyJ0cmlhbCIsIm5vLXVpLXdhcm5zIl19fX0dbeff87ec565b90d59175cd5aa86b93abd4164fc2967f9156e289f5db90f0b8c19ddfb44efd92a165b2d1c568c0097c78c243701acf200487372dc8b45a0f1be]',
-      subscription: '[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjo2MCwiZ3JhY2UiOjkwLCJmbGFncyI6W119fX03de5d59e480be17d3c8cd340cb242cab64cac7888cbfba6c975c194f6613b8103792a6929f66852d18c7ac27c8c25fa9ab8a25b5e25f331669746c833a4f8361]',
-      'subscription-external': '[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjo2MCwiZ3JhY2UiOjkwLCJmbGFncyI6WyJuby1jb25zb2xlLXdhcm5zIiwibm8tdWktd2FybnMiXX19fQbf6f3e8356a12fe1b2553ccd263dbc9a6f94c1fdc1aab949b3550865cb150c79027aa06a11c7618d5cbd1e14c4b73dc35f6bb81cd8c33c474194d082e2951106]',
-      // The two install faults, which block from 18.1 on: the subscription key with its checksum
-      // broken at the payload boundary, and no key at all. `missing` is the empty string - the
+      '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]',
+      // 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: '[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjo2MCwiZ3JhY2UiOjkwLCJmbGFncyI6W119fX03de5d59e480be17d3c8cd340cb242cab64cac7888cbfba6c975c194f6613b8103792a6929f66852d18c7ac27c8c25fa9ab8a25b5e25f331669746c833a4f8300]',
+      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]',
       // 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',
@@ -84,6 +85,16 @@
       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)
+    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`;
+
     // 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
     // glyph, a corner holding frozen DATA cells, or a second (nested) grid
diff --git a/tests/fixtures/pages/LicenseBrandingPage.ts b/tests/fixtures/pages/LicenseBrandingPage.ts
index 09393201d5..1a383497f4 100644
--- a/tests/fixtures/pages/LicenseBrandingPage.ts
+++ b/tests/fixtures/pages/LicenseBrandingPage.ts
@@ -15,7 +15,8 @@ export const INSTANT = {
 } as const;

 type LicenseKeyName = 'trial' | 'trial-external' | 'subscription' | 'subscription-external' |
-  'tampered' | 'legacy-expired' | 'non-commercial-padded' | 'missing';
+  'tampered' | 'legacy-expired' | 'non-commercial-padded' | 'missing' |
+  'bare-block' | 'edited-prose' | 'subscription-pasted';
 type Variant = 'default' | 'no-row-headers' | 'no-headers-frozen' | 'narrow-corner' | 'dialog' |
   'nested' | 'narrow';