Quellcode durchsuchen

Merge pull request #2106 from PrivateBin/uint8array-encoding

prevent truncating characters above U+00FF to their lowest byte
El RIDO vor 5 Tagen
Ursprung
Commit
54877bec63
5 geänderte Dateien mit 495 neuen und 50 gelöschten Zeilen
  1. 3 0
      CHANGELOG.md
  2. 70 49
      js/privatebin.js
  3. 179 0
      js/test/AttachmentViewer.js
  4. 242 0
      js/test/CryptTool.js
  5. 1 1
      lib/Configuration.php

+ 3 - 0
CHANGELOG.md

@@ -12,6 +12,9 @@
 * FIXED: Comments must now be unique in cipher text within a document, not reuse existing cipher texts (#2100)
 * FIXED: Password peek button next to the password input when creating a document had no effect
 * FIXED: E-mail button sent several e-mails after being shown again or after its confirmation dialog was dismissed
+* FIXED: SVG attachment previews could turn non-ASCII characters into markup after sanitization, enabling script execution when opening the preview in a new tab
+* FIXED: SVG attachment previews use a data URL, so they no longer run in the instance's origin when opened in a new tab
+* FIXED: Password characters above U+00FF got truncated before key derivation, now passwords get UTF-8 encoded and existing documents remain readable
 
 ## 2.1.0 (2026-10-18)
 * ADDED: Added `shortenviachhoto` endpoint with an `chhoto` configuration section

+ 70 - 49
js/privatebin.js

@@ -1065,14 +1065,16 @@ window.PrivateBin = (function () {
         }
 
         /**
-         * convert UTF-8 string into a Uint8Array
+         * convert binary string into a Uint8Array
          *
-         * Iterates over the bytes of the message, writing them to the array
+         * Iterates over the characters of the message, writing their codes to
+         * the array. Only use this for binary strings, i.e. the output of atob,
+         * characters above U+00FF get truncated to their lowest byte.
          *
          * @name   CryptTool.stringToArraybuffer
          * @function
          * @private
-         * @param  {string} message UTF-8 string
+         * @param  {string} message binary string
          * @return {Uint8Array} array
          */
         function stringToArraybuffer(message)
@@ -1159,16 +1161,15 @@ window.PrivateBin = (function () {
          * @async
          * @function
          * @private
-         * @param  {string} key
-         * @param  {string} password
-         * @param  {array}  spec cryptographic specification
-         * @return {CryptoKey} derived key
+         * @param  {string}     key
+         * @param  {Uint8Array} passwordArray encoded password
+         * @param  {array}      spec cryptographic specification
+         * @return {CryptoKey}  derived key
          */
-        async function deriveKey(key, password, spec) {
+        async function deriveKey(key, passwordArray, spec) {
             let keyArray = stringToArraybuffer(key);
-            if (password.length > 0) {
-                let passwordArray = stringToArraybuffer(password),
-                    newKeyArray = new Uint8Array(keyArray.length + passwordArray.length);
+            if (passwordArray.length > 0) {
+                const newKeyArray = new Uint8Array(keyArray.length + passwordArray.length);
                 newKeyArray.set(keyArray, 0);
                 newKeyArray.set(passwordArray, keyArray.length);
                 keyArray = newKeyArray;
@@ -1268,7 +1269,11 @@ window.PrivateBin = (function () {
                     arraybufferToString(
                         await window.crypto.subtle.encrypt(
                             cryptoSettings(JSON.stringify(adata), spec),
-                            await deriveKey(key, password, spec),
+                            await deriveKey(
+                                key,
+                                new TextEncoder().encode(password),
+                                spec
+                            ),
                             await compress(message, compression, zlib)
                         ).catch(Alert.showError)
                     )
@@ -1307,16 +1312,27 @@ window.PrivateBin = (function () {
                     throw 'Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.';
                 }
             }
-            try {
-                plaintext = await window.crypto.subtle.decrypt(
-                    cryptoSettings(adataString, spec),
-                    await deriveKey(key, password, spec),
-                    stringToArraybuffer(
-                        atob(cipherMessage)
-                    )
-                );
-            } catch (err) {
-                console.error(err);
+            const passwordArray = [new TextEncoder().encode(password)];
+            // documents created by earlier versions truncated the characters
+            // of non-ASCII passwords to their lowest byte
+            if (/[\u0080-\uffff]/.test(password)) {
+                passwordArray.push(stringToArraybuffer(password));
+            }
+            let decryptionError;
+            for (const passwordAttempt of passwordArray) {
+                try {
+                    plaintext = await window.crypto.subtle.decrypt(
+                        cryptoSettings(adataString, spec),
+                        await deriveKey(key, passwordAttempt, spec),
+                        stringToArraybuffer(atob(cipherMessage))
+                    );
+                    break;
+                } catch (err) {
+                    decryptionError = err;
+                }
+            }
+            if (typeof plaintext === 'undefined') {
+                console.error(decryptionError);
                 return '';
             }
             try {
@@ -2842,23 +2858,19 @@ window.PrivateBin = (function () {
             dropzone;
 
         /**
-         * get blob URL from string data and mime type
+         * get blob URL from binary data and mime type
          *
          * @name   AttachmentViewer.getBlobUrl
          * @private
          * @function
-         * @param {string} data - raw data of attachment
-         * @param {string} data - mime type of attachment
+         * @param {Uint8Array} data - raw data of attachment
+         * @param {string} mimeType - mime type of attachment
          * @return {string} objectURL
          */
         function getBlobUrl(data, mimeType) {
             // Transform into a Blob
-            const buf = Uint8Array.from(
-                data,
-                character => character.charCodeAt(0)
-            );
             const blob = new window.Blob(
-                [buf],
+                [data],
                 {
                     type: mimeType
                 }
@@ -2897,12 +2909,19 @@ window.PrivateBin = (function () {
                 sanitizedMimeType = mimeType;
             }
 
-            // extract data and convert to binary
+            // extract data and convert to binary - atob returns a binary
+            // string, each character representing a single byte, so taking
+            // the character codes is lossless here, as opposed to strings in
+            // general, that may contain characters above U+00FF
             const base64Data = attachmentData.substring(base64Start);
-            const plainData = base64Data.length > 0 ? atob(base64Data) : '';
+            const plainData = Uint8Array.from(
+                base64Data.length > 0 ? atob(base64Data) : '',
+                character => character.charCodeAt(0)
+            );
 
-            let blobUrl = getBlobUrl(plainData, sanitizedMimeType);
+            const blobUrl = getBlobUrl(plainData, sanitizedMimeType);
             attachmentLink.setAttribute('href', blobUrl);
+            let previewUrl = blobUrl;
 
             if (typeof fileName !== 'undefined') {
                 attachmentLink.setAttribute('download', fileName);
@@ -2914,39 +2933,39 @@ window.PrivateBin = (function () {
             }
 
             // sanitize SVG preview
-            // prevents executing embedded scripts when CSP is not set and user
-            // right-clicks/long-taps and opens the SVG in a new tab - prevented
-            // in the preview by use of an img tag, which disables scripts, too
+            // prevents executing embedded scripts when the user right-clicks/
+            // long-taps and opens the SVG in a new tab - prevented in the
+            // preview by use of an img tag, which disables scripts, too
             if (mimeType.startsWith('image/svg')) {
                 try {
                     // attempt to UTF-8 decode the SVG data
-                    const svgBuffer = Uint8Array.from(
-                        plainData,
-                        character => character.charCodeAt(0)
-                    );
                     const utf8ValidatedSvgString = new TextDecoder(
                         'utf-8',
                         {fatal: true}
-                    ).decode(svgBuffer);
+                    ).decode(plainData);
                     const sanitizedData = DOMPurify.sanitize(
                         utf8ValidatedSvgString,
                         purifySvgConfig
                     );
+                    // Use a data URL instead of a blob URL: blob URLs share the
+                    // origin of this page, so any script surviving sanitization
+                    // would run in it, if the user opens the image in a new tab.
+                    // Data URLs get an opaque origin or are blocked from being
+                    // opened in a new tab. encodeURIComponent UTF-8 encodes the
+                    // sanitized string, that may contain any unicode character,
+                    // i.e. from decoded character references.
+                    previewUrl = 'data:image/svg+xml;charset=utf-8,' +
+                        encodeURIComponent(sanitizedData);
                     sanitizedMimeType = 'image/svg+xml';
-                    blobUrl = getBlobUrl(sanitizedData, sanitizedMimeType);
                 } catch (e) {
-                    // Invalid or non-UTF-8 SVG: download only, no preview as it
-                    // may be used to smuggle multi-byte sequences past DOMpurify
-                    // such as `&#x13c` to get `\x01<` & `&#x13e` to get `\x01>`
-                    sanitizedMimeType = 'application/octet-stream';
-                    blobUrl = getBlobUrl(plainData, sanitizedMimeType);
+                    // Invalid or non-UTF-8 SVG: download only, no preview
                 }
             }
 
             template.classList.remove('hidden');
             attachment.appendChild(template);
 
-            me.handleBlobAttachmentPreview(attachmentPreview, blobUrl, sanitizedMimeType);
+            me.handleBlobAttachmentPreview(attachmentPreview, previewUrl, sanitizedMimeType);
         };
 
 
@@ -3261,7 +3280,9 @@ window.PrivateBin = (function () {
                 return;
             }
 
-            const alreadyIncludesCurrentAttachment = targetElement.querySelectorAll(`[src='${blobUrl}']`).length > 0;
+            const alreadyIncludesCurrentAttachment = Array.from(
+                targetElement.querySelectorAll('[src]')
+            ).some(element => element.getAttribute('src') === blobUrl);
 
             if (blobUrl && !alreadyIncludesCurrentAttachment) {
                 if (mimeType.startsWith('image/')) {

+ 179 - 0
js/test/AttachmentViewer.js

@@ -278,6 +278,185 @@ describe('AttachmentViewer', function () {
         )
     });
 
+    describe('SVG preview encoding', function () {
+        this.timeout(30000);
+
+        // same as purifySvgConfig in privatebin.js
+        const purifySvgConfig = {
+            USE_PROFILES: {
+                svg: true,
+                svgFilters: true
+            }
+        };
+
+        // runs setAttachment on the given data URI and returns the created blobs
+        function getAttachmentBlobs(dataUri) {
+            const blobs = [];
+            document.body.innerHTML = bodyTemplate;
+            Object.defineProperty(
+                window.URL,
+                'createObjectURL',
+                {
+                    configurable: true,
+                    value: function (blob) {
+                        blobs.push(blob);
+                        return 'blob:' + blob.type + '/1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed';
+                    }
+                }
+            );
+            PrivateBin.AttachmentViewer.init();
+            PrivateBin.Model.init();
+            global.atob = common.atob;
+            PrivateBin.AttachmentViewer.setAttachment(dataUri, 'attachment');
+            return blobs;
+        }
+
+        // jsdom's Blob lacks arrayBuffer(), so read it using FileReader
+        async function readBlob(blob) {
+            const buffer = await new Promise((resolve, reject) => {
+                const reader = new window.FileReader();
+                reader.onload = () => resolve(reader.result);
+                reader.onerror = () => reject(reader.error);
+                reader.readAsArrayBuffer(blob);
+            });
+            return new Uint8Array(buffer);
+        }
+
+        // returns the bytes of a data URL, either base64 or percent-encoded
+        function readDataUrl(url) {
+            const match = /^data:([^,]*),(.*)$/s.exec(url);
+            assert.ok(match, 'uses a data URL, got: ' + url.substring(0, 50));
+            if (match[1].endsWith(';base64')) {
+                return new Uint8Array(Buffer.from(match[2], 'base64'));
+            }
+            assert.ok(/^[\x21-\x7e]*$/.test(match[2]), 'percent-encoded data URL only contains ASCII');
+            return new Uint8Array(Buffer.from(
+                match[2].replace(
+                    /%([0-9a-f]{2})/gi,
+                    (_, hex) => String.fromCharCode(parseInt(hex, 16))
+                ),
+                'latin1'
+            ));
+        }
+
+        // runs setAttachment on the given SVG source and returns the bytes of
+        // the sanitized SVG, used for the preview
+        async function getSvgPreviewBytes(svg) {
+            getAttachmentBlobs(
+                'data:image/svg+xml;base64,' + Buffer.from(svg, 'utf8').toString('base64')
+            );
+            const images = document.querySelectorAll('#attachmentPreview img');
+            assert.strictEqual(images.length, 1, 'shows one SVG preview');
+            return readDataUrl(images[0].getAttribute('src'));
+        }
+
+        const lookalikeVectors = {
+            'literal ļ/ľ (U+013C/U+013E)': '<svg xmlns="http://www.w3.org/2000/svg">\u013cscript\u013ealert(1)\u013c/script\u013e</svg>',
+            'character references &#x13c;/&#x13e; (U+013C/U+013E)': '<svg xmlns="http://www.w3.org/2000/svg">&#x13c;script&#x13e;alert(1)&#x13c;/script&#x13e;</svg>',
+            'CJK 丼/举 (U+4E3C/U+4E3E)': '<svg xmlns="http://www.w3.org/2000/svg">\u4e3cscript\u4e3ealert(1)\u4e3c/script\u4e3e</svg>'
+        };
+        for (const [name, svg] of Object.entries(lookalikeVectors)) {
+            it('does not turn look-alike characters into markup: ' + name, async function () {
+                const bytes = await getSvgPreviewBytes(svg),
+                    latin1 = Buffer.from(bytes).toString('latin1');
+                assert.ok(
+                    !/<\/?script/i.test(latin1),
+                    'sanitized SVG preview must not contain script tags, got: ' + latin1
+                );
+            });
+        }
+
+        it('UTF-8 encodes the sanitized SVG without loss', async function () {
+            await fc.assert(fc.asyncProperty(
+                fc.string({unit: 'binary'}),
+                async function (text) {
+                    // escape markup, so the text ends up as a single text node
+                    const escaped = text
+                        .replace(/&/g, '&amp;')
+                        .replace(/</g, '&lt;')
+                        .replace(/>/g, '&gt;'),
+                        svg = '<svg xmlns="http://www.w3.org/2000/svg"><text>' + escaped + '</text></svg>',
+                        // lone surrogates can't be UTF-8 encoded, so normalize the input first
+                        decodedSvg = Buffer.from(svg, 'utf8').toString('utf8'),
+                        expected = DOMPurify.sanitize(decodedSvg, purifySvgConfig),
+                        bytes = await getSvgPreviewBytes(svg),
+                        actual = new TextDecoder('utf-8', {fatal: true}).decode(bytes);
+                    PrivateBin.AttachmentViewer.removeAttachment();
+                    return expected === actual;
+                }
+            ),
+            {numRuns: 100});
+        });
+
+        it('previews SVGs using a data URL, not a same-origin blob URL', function () {
+            const svg = '<svg xmlns="http://www.w3.org/2000/svg"><text>Gotcha</text></svg>',
+                blobs = getAttachmentBlobs(
+                    'data:image/svg+xml;base64,' + Buffer.from(svg, 'utf8').toString('base64')
+                ),
+                images = document.querySelectorAll('#attachmentPreview img');
+            assert.ok(
+                !blobs.some(blob => blob.type === 'image/svg+xml'),
+                'creates no SVG blob'
+            );
+            assert.strictEqual(images.length, 1, 'shows one SVG preview');
+            assert.ok(
+                images[0].getAttribute('src').startsWith('data:image/svg+xml'),
+                'previews the SVG using a data URL'
+            );
+            assert.strictEqual(
+                Buffer.from(readDataUrl(images[0].getAttribute('src'))).toString('utf8'),
+                svg,
+                'previews the sanitized SVG'
+            );
+        });
+
+        it('does not preview SVGs that are not valid UTF-8', async function () {
+            // 0xC4 followed by 0x3C ("<") is not a valid UTF-8 sequence
+            const svg = Buffer.concat([
+                    Buffer.from('<svg xmlns="http://www.w3.org/2000/svg">', 'utf8'),
+                    Buffer.from([0xc4, 0x3c]),
+                    Buffer.from('</svg>', 'utf8')
+                ]),
+                blobs = getAttachmentBlobs(
+                    'data:image/svg+xml;base64,' + svg.toString('base64')
+                );
+            assert.ok(
+                !blobs.some(blob => blob.type === 'image/svg+xml'),
+                'creates no SVG blob for preview'
+            );
+            assert.strictEqual(
+                document.querySelectorAll('#attachmentPreview img').length, 0,
+                'shows no preview'
+            );
+            assert.strictEqual(
+                blobs[blobs.length - 1].type, 'application/octet-stream',
+                'offers download only'
+            );
+            assert.deepStrictEqual(
+                await readBlob(blobs[blobs.length - 1]),
+                new Uint8Array(svg),
+                'keeps the original bytes for download'
+            );
+        });
+
+        it('keeps the bytes of attachments for download intact', async function () {
+            await fc.assert(fc.asyncProperty(
+                fc.constantFrom('image/png', 'application/pdf', 'application/octet-stream', 'image/svg+xml'),
+                fc.uint8Array(),
+                async function (mimeType, data) {
+                    const blobs = getAttachmentBlobs(
+                            'data:' + mimeType + ';base64,' + Buffer.from(data).toString('base64')
+                        ),
+                        // the first blob is used for the download link
+                        bytes = await readBlob(blobs[0]);
+                    PrivateBin.AttachmentViewer.removeAttachment();
+                    return Buffer.from(bytes).equals(Buffer.from(data));
+                }
+            ),
+            {numRuns: 100});
+        });
+    });
+
     function mockCreateObjectUrl(includeType = true) {
         Object.defineProperty(
             window.URL,

+ 242 - 0
js/test/CryptTool.js

@@ -44,6 +44,181 @@ describe('CryptTool', function () {
             {numRuns: 3});
         });
 
+        // The below static unit tests are included to ensure deciphering of
+        // existing pastes still works, even when the format or encoding of
+        // messages and passwords changes in the future
+        // Of course you can easily decipher the following texts, if you like.
+        // Bonus points for finding their sources and hidden meanings.
+        it(
+            'supports PrivateBin v2 ciphertext with password',
+            async function () {
+                const clean = globalThis.cleanup();
+                // ensure zlib is getting loaded
+                PrivateBin.Controller.initZlib();
+                Object.defineProperty(window, 'crypto', {
+                    value: new WebCrypto(),
+                    configurable: true,
+                    enumerable: true,
+                    writable: false
+                });
+                global.atob = common.atob;
+                global.btoa = common.btoa;
+
+                const paste = await PrivateBin.CryptTool.decipher(
+                    atob('GXPVE/FVL2HbR0eT7R4D8syaBDYwENIgU9N//0d+j74='),
+                    // -- "That's amazing. I've got the same combination on my luggage."
+                    Array.apply(0, Array(6)).map((_,b) => b + 1).join(''),
+                    [
+                        'W9QvTb1da+IXB4Xmayv73zjK816I1egk4adfhL9DNks8ZSbyMV5SOUCj' +
+                        'EPv+Ccy+1nhWFVa9VT4oHi0qZqAECmKoko6oLVdaWFwL1hWMvUdBJfIK' +
+                        '13LImrI0l3VGQzXsTKxXgRVHMOn1VqYmE0puo671eUL1g1dx5H1lm2uc' +
+                        'jxrOuLWcHMEF+ZqbAx5xwae1aZ8QptBEDL9MiYov7PQpZduxE/jR+mHA' +
+                        'RzVJV1ZyGYIIiRBa1VpJvFN+0HSkdgYSU3TnzXw+sO+xvNaNv/dqVls1' +
+                        'Lk6mMPninJGxN5ZORw9+eU3sNTzBk0+wrX2lONxUIH3sm5mVZJmdlQ5j' +
+                        'sDC+Pe4lAlhbXfeTeNV6WwvQafmaY+6vrPZWzn5ty3/vrJtCcGYOFxN4' +
+                        'Ra6GwxO3/+JqKppYJPtPvnVYriObPlcEe+6nlXXDS9EM8W2xIdw2f54I' +
+                        'VIA7WHXPj46L6Y/8KpCmlQ5YS7fioWEm3zIbGtzfG8Feu8pT3NVD1wPk' +
+                        'blJcb9eCFEIzzWi9G2/e4HdzRyH4fCV7b1L0Bg50/BpCuaC5+v1eZZ8e' +
+                        'Ku286YB42sKovgKzio9P2WDAxtbq/35nRFbGhC7cvQOzRdibZqFGFIaq' +
+                        'Df0Niz5bvzxKW6eZloBBfm5cxfWdCAX7pTxg/z9Wy/fq3coApwkOS4Ya' +
+                        'NoAOvVZo9g==',
+                        [['DLAezyPai/y3Gzo8LmUe5A==', 'G550BaMPE+k=', 100000, 256, 128, 'aes', 'gcm', 'zlib'], 'plaintext', 0, 0]
+                    ]
+                );
+                clean();
+                const result = typeof paste === 'string' && paste.includes('securely packed in iron');
+                if (!result) console.log(paste);
+                assert.ok(result);
+            }
+        );
+
+        it(
+            'supports PrivateBin v2 ciphertext no password',
+            async function () {
+                const clean = globalThis.cleanup();
+                // ensure zlib is getting loaded
+                PrivateBin.Controller.initZlib();
+                Object.defineProperty(window, 'crypto', {
+                    value: new WebCrypto(),
+                    configurable: true,
+                    enumerable: true,
+                    writable: false
+                });
+                global.atob = common.atob;
+                global.btoa = common.btoa;
+
+                const paste = await PrivateBin.CryptTool.decipher(
+                    atob('RHK8Y2QURMzoDQrbJ5NaAGr3KhIxjepX3fXwXyXQWY0='),
+                    '', // no password
+                    [
+                        'IB87fuvRS6rBY0RqLxQjG5/Ng1cU1K/2IXQejhIpUWgFI6UWldM/7Vdk' +
+                        'ZxqlmUHIRWIpCrIToODG+5iVHm0RqeW0P98CRHG82Z+d8AAznp4pOyAt' +
+                        'KEa4ifwdoWiSBEbcOBOgggWVUw1nBZt9MHG8iyve+OOH1KPoIv+xVuJV' +
+                        'nBW/+nxQ2EbDcnSY4yixXeoEB1bPjSXLHrd+ifst9SbcsTuHCNng2K/t' +
+                        'mp3CdocycLttfcfPkgTYvYNC2Gd2oCEZP0+0a2dGfT7nzuGRCFbAx+b9' +
+                        'yFNswZHrvlU8CUdrawRSsNN06KZCHkEPUkunjBrdyALLqtn8w2TKA9nf' +
+                        '2n+27R5De7dcjOJZbN80PDsAVNOBDCdTuQF+vkUE+254SqQXp2zTAVX5' +
+                        '1MjpGgTI/xdSfVdg5vOoqymySPmCV4ZO1sFXPs6AXRg2jhzxhihjxlv8' +
+                        '55WY7mSsiU1G3/TBY0Ra+1QzcQ5bSy/ycWEtT72IicM8Hj2w3aF2QwFV' +
+                        'sz5tAyFTZD3swxR5bbxQZJ+LEQ==',
+                        ['RSSzQHsYsczisktZn6eesg==', 'Dow4nIXOFgI=', 100000, 256, 128, 'aes', 'gcm', 'zlib']
+                    ]
+                );
+                clean();
+                const result = typeof paste === 'string' && paste.includes('Stable, opaque, immortal \u2013 all by dint');
+                if (!result) console.log(paste);
+                assert.ok(result);
+            }
+        );
+
+        it(
+            'supports PrivateBin v2 ciphertext with non-ASCII password and no compression',
+            async function () {
+                const clean = globalThis.cleanup();
+                // ensure zlib is getting loaded
+                PrivateBin.Controller.initZlib();
+                Object.defineProperty(window, 'crypto', {
+                    value: new WebCrypto(),
+                    configurable: true,
+                    enumerable: true,
+                    writable: false
+                });
+                global.atob = common.atob;
+                global.btoa = common.btoa;
+
+                const paste = await PrivateBin.CryptTool.decipher(
+                    atob('BgdCS8FLXvEFnQPwkAE93b0urmkaTShdw1Tr5c350Uo='),
+                    // key derivation of passwords containing characters above U+007F
+                    'Schl\u00fcssel \u94a5\u5319',
+                    [
+                        'T0qlQhwqt1KtFY6r8iqg5gmP3O8yudwx0YPQk7xU4hyIq2SlsXmGGonq' +
+                        '6T4sDIJM6i7caSEZYsE/0KeCq0rCw3CZNaUoL9E54sx4ei17m5X9ikUe' +
+                        'feRX3n8M89jbE6vd1qJ13QvbG0mGYhxllfWDnETol0ws2nsfcqVMQJrX' +
+                        'nwlm+3PHH9/sqGChLrbTYMHOcUS1ec4/l5qIFfUBXqbO+U5V5nneZRz8' +
+                        '+JuPDk5oSiHTBHV2Jyd0Q3fX37O6bpqEz0Qxl54rLEkfUSRfM7RXCJE2' +
+                        'wcAaxL9vqGcit/QdBnrJrPyTL8Vdoz289dmx+3EJZEtoW1bSEYQ1mov2' +
+                        'c4bv8pEutAEnHf9nbBYZJhWdKlVZkqX5cjM11cPLCuebpQbmw9otVCWX' +
+                        'GAqU8safz+Nu4CkUXoAerX8lrKffJEOLOoGvlJjU2qZexfOjrbxP7XJ3' +
+                        'lbpddDA4OFVDTG9u99m14T4opmYK/iPwbq6PcI1H9fxzLRwyZjNOoaip' +
+                        'b7z6vvOyyl1gwnXN98ZRBys6SChO6tqWATenQgs20UUv6oi+vyBoYhLP' +
+                        'DWFfU4ndf4p2d0BRQldYoXEMl+FgkNMcnS2UR6PhBax/BGDgZlMUljil' +
+                        'rOxcCM5vd5jSZIWs/IV6pMxOAozymCfjMDfgjE2J6ub00V+92uwu6vsV' +
+                        'VlbN1ARFCEp6VX7tN1RNOBTYqlXU6vGbSgXLh7Qng50Pin+/6GKpuHzb' +
+                        'spNmEekIqgzlsNc+/V0BrPe6xvhveVyEANNBgqT5NoRju9ioval08lW0' +
+                        'OfrFZJ5LEk4jZ9TfMEMej1K5efZMUVP94q+D6fRMFwM=',
+                        [['RekNgsDiTSbYsXae8EnjTQ==', 'wsZdI484+Cs=', 100000, 256, 128, 'aes', 'gcm', 'none'], 'plaintext', 0, 0]
+                    ]
+                );
+                clean();
+                const result = typeof paste === 'string' && paste.includes('Stable, opaque, immortal \u2013 all by dint');
+                if (!result) console.log(paste);
+                assert.ok(result);
+            }
+        );
+
+        it(
+            'supports PrivateBin v2 ciphertext with UTF-8 encoded non-ASCII password',
+            async function () {
+                const clean = globalThis.cleanup();
+                // ensure zlib is getting loaded
+                PrivateBin.Controller.initZlib();
+                Object.defineProperty(window, 'crypto', {
+                    value: new WebCrypto(),
+                    configurable: true,
+                    enumerable: true,
+                    writable: false
+                });
+                global.atob = common.atob;
+                global.btoa = common.btoa;
+
+                const paste = await PrivateBin.CryptTool.decipher(
+                    atob('58THB9UKSts178TIRMfG1l+SikJC92wqO+zu6RSbzOk='),
+                    // key derivation of passwords using their UTF-8 encoding
+                    'Schl\u00fcssel \u94a5\u5319',
+                    [
+                        '2LitLhswv6zFfUg4AzEjxd6OHWq8PJRh0ImRpzMUyVz67kuZbTYXSzZt' +
+                        '3DLHg/cvT6GpixTZdtHt7z2JK9TXYkoYDcUTuH/xAkgy/k/6w0COOwMp' +
+                        'mUhtYRRTyrhnPEa82QwpVzxZ7zIHRDNLT2J49yLQ44hp564L6zPWQz6R' +
+                        'HGuAf1g3Is/dVRNhRaRPyuOrj/PXeA2/KVkpBbWTY2nPCct6pueLiEA/' +
+                        'pbtI/oOBOo/DQRgLh9MSpZ4a68H6DtWj9/Y6RINm6zn8cit5bOlUYeC5' +
+                        'tpZD2eqpiRZIcwekXcw64Y/dNNQZZ/MD+0ZmWFicBmWlLbuS3WlxeHAH' +
+                        'VLGIoZTydTKopZ6ubmQUbI1Fiz3GUDF3q5e9c7vGUO4TVFIKsCyza/8o' +
+                        '2SBfKt66DxTyWTetwcW5ql9L+wfxVf6WFLVkbax/HKvHgozPh6YiGHFJ' +
+                        'DXeNYenlr0R6zQGjHEi+72+gEgDpHtaZSPYVob+ioNO3EsV9WuxqysK+' +
+                        'YEmuw+0cFeF2l9ki69AjbtzNjrFgAsX1QR9an+BtUXFeMekwgkx0NkUd' +
+                        'Sla2eOcoIQSR8HFkOF4tCQPxp4Pgd+UFAMYDkv55sCzPxHm3JXbVCzhw' +
+                        '7N5zmeEI3wJiqHzLzcRK/JqnaPUinqwcv+skickiAAGUKRZwUmHirCyc' +
+                        'WbIqDxlddQ==',
+                        ['R9xhVxI3CtY6hp45e1ad0w==', 'LqC0jpwAdps=', 100000, 256, 128, 'aes', 'gcm', 'zlib']
+                    ]
+                );
+                clean();
+                const result = typeof paste === 'string' && paste.includes('securely packed in iron');
+                if (!result) console.log(paste);
+                assert.ok(result);
+            }
+        );
+
         it('does not truncate messages', async function () {
             const message = fs.readFileSync('test/compression-sample.txt', 'ascii').trim(),
                 clean = globalThis.cleanup();
@@ -151,6 +326,73 @@ conseq_or_bottom inv (interp (nth_iterate sBody n) (MemElem mem))
             ),
             {numRuns: 3});
         });
+
+        it('can en- and decrypt any unicode message and password', async function () {
+            await fc.assert(fc.asyncProperty(
+                fc.string({unit: 'binary', minLength: 1}),
+                fc.string({unit: 'binary'}),
+                async function (message, password) {
+                    const clean = globalThis.cleanup();
+                    // ensure zlib is getting loaded
+                    PrivateBin.Controller.initZlib();
+                    Object.defineProperty(window, 'crypto', {
+                        value: new WebCrypto(),
+                        configurable: true,
+                        enumerable: true,
+                        writable: false
+                    });
+                    global.atob = common.atob;
+                    global.btoa = common.btoa;
+                    const key = PrivateBin.CryptTool.getSymmetricKey(),
+                        cipherMessage = await PrivateBin.CryptTool.cipher(
+                            key, password, message, []
+                        ),
+                        plaintext = await PrivateBin.CryptTool.decipher(
+                            key, password, cipherMessage
+                        );
+                    clean();
+                    const result = (message === plaintext);
+                    if (!result) console.log(plaintext, cipherMessage);
+                    return result;
+                }
+            ),
+            {numRuns: 3});
+        });
+
+        it('does not accept passwords that only share the low byte of each character', async function () {
+            const passwordPairs = [
+                ['\u013c', '<'],             // U+013C vs <
+                ['\u4e2d\u6587', '-\u0087'], // U+4E2D U+6587 vs 0x2D 0x87
+                ['\u20ac', '\u00ac']         // U+20AC vs 0xAC
+            ];
+            for (const [password, lowBytePassword] of passwordPairs) {
+                const clean = globalThis.cleanup();
+                // ensure zlib is getting loaded
+                PrivateBin.Controller.initZlib();
+                Object.defineProperty(window, 'crypto', {
+                    value: new WebCrypto(),
+                    configurable: true,
+                    enumerable: true,
+                    writable: false
+                });
+                global.atob = common.atob;
+                global.btoa = common.btoa;
+                const key = PrivateBin.CryptTool.getSymmetricKey(),
+                    cipherMessage = await PrivateBin.CryptTool.cipher(
+                        key, password, 'secret', []
+                    ),
+                    plaintext = await PrivateBin.CryptTool.decipher(
+                        key, lowBytePassword, cipherMessage
+                    );
+                clean();
+                assert.strictEqual(
+                    plaintext, '',
+                    'password ' + JSON.stringify(lowBytePassword) +
+                    ' must not decrypt a document encrypted with ' +
+                    JSON.stringify(password)
+                );
+            }
+        });
     });
 
     describe('getSymmetricKey', function () {

+ 1 - 1
lib/Configuration.php

@@ -119,7 +119,7 @@ class Configuration
             'js/kjua-0.10.0.js'      => 'sha512-BYj4xggowR7QD150VLSTRlzH62YPfhpIM+b/1EUEr7RQpdWAGKulxWnOvjFx1FUlba4m6ihpNYuQab51H6XlYg==',
             'js/legacy.js'           => 'sha512-pRofxsrf5UItjiP22Dcjh3FAcBjF/n7h8U9/W5xqJk17U0N2U1oajhXypq/omo9jhwS1iVGOhWrRepoPeFns+w==',
             'js/prettify.js'         => 'sha512-puO0Ogy++IoA2Pb9IjSxV1n4+kQkKXYAEUtVzfZpQepyDPyXk8hokiYDS7ybMogYlyyEIwMLpZqVhCkARQWLMg==',
-            'js/privatebin.js'       => 'sha512-OjLGUI7ThGedSf2Fnuex16XhniPlEhEr6s08UJAJsLNIx3trf9hDtulW1LUXs5rqJPZG9B+fwBjq9Z9sqmMIrA==',
+            'js/privatebin.js'       => 'sha512-3OpAPsku1J5vyuedDhIC9o4EKc26NsML2EaVuX/0C9LknU5oG8JYuluK2pgdIqq/A3geYKuP6gRtZoayGS+yKQ==',
             'js/purify-3.4.17.js'    => 'sha512-udF2zPdEyVCAxIGeBbeSGFK1Fj5c4eHGi6NqOSsr8M75lGs39POzgUNxO3SFxOg4cHfAoZJgR2EaXdmDi6v6AQ==',
             'js/showdown-2.1.0.js'   => 'sha512-WYXZgkTR0u/Y9SVIA4nTTOih0kXMEd8RRV6MLFdL6YU8ymhR528NLlYQt1nlJQbYz4EW+ZsS0fx1awhiQJme1Q==',
             'js/zlib-1.3.2.js'       => 'sha512-RAhJgxg9siMIA8ky4c10Rc2zUgnK80olHB8Tt1IOYWY4Eh1WmrviQkDn+sgBlb38ZHq3tzufGC41kP360gmosQ==',