diff --git a/src/core/crypto.js b/src/core/crypto.js index cfd8cf90a..ad199f6cf 100644 --- a/src/core/crypto.js +++ b/src/core/crypto.js @@ -34,6 +34,7 @@ import { Dict, isDict, isName, Name } from "./primitives.js"; import { calculateMD5 } from "./calculate_md5.js"; import { calculateSHA256 } from "./calculate_sha256.js"; import { DecryptStream } from "./decrypt_stream.js"; +import { saslPrep } from "./sasl_prep.js"; /** * @typedef {typeof AES128Cipher | typeof AES256Cipher | typeof ARCFourCipher @@ -853,6 +854,15 @@ class CipherTransform { } } +function utf8PasswordToBytes(password) { + try { + password = utf8StringToString(password); + } catch { + warn("CipherTransformFactory: Unable to convert UTF8 encoded password."); + } + return stringToBytes(password); +} + class CipherTransformFactory { #fileId; @@ -1128,18 +1138,19 @@ class CipherTransformFactory { this.encryptMetadata = encryptMetadata; const fileIdBytes = stringToBytes(fileId); - let passwordBytes; + let passwordBytes, rawPasswordBytes; if (password) { - if (algorithm === 5) { - try { - password = utf8StringToString(password); - } catch { - warn( - "CipherTransformFactory: Unable to convert UTF8 encoded password." - ); + if (revision === 6) { + const preppedPassword = saslPrep(password); + passwordBytes = utf8PasswordToBytes(preppedPassword); + if (preppedPassword !== password) { + rawPasswordBytes = utf8PasswordToBytes(password); } + } else if (algorithm === 5) { + passwordBytes = utf8PasswordToBytes(password); + } else { + passwordBytes = stringToBytes(password); } - passwordBytes = stringToBytes(password); } let encryptionKey; @@ -1163,20 +1174,27 @@ class CipherTransformFactory { const ownerEncryption = stringToBytes(dict.get("OE")); const userEncryption = stringToBytes(dict.get("UE")); const perms = stringToBytes(dict.get("Perms")); - encryptionKey = this.#createEncryptionKey20( - revision, - passwordBytes, - ownerPassword, - ownerValidationSalt, - ownerKeySalt, - uBytes, - userPassword, - userValidationSalt, - userKeySalt, - ownerEncryption, - userEncryption, - perms - ); + for (const candidate of rawPasswordBytes + ? [passwordBytes, rawPasswordBytes] + : [passwordBytes]) { + encryptionKey = this.#createEncryptionKey20( + revision, + candidate, + ownerPassword, + ownerValidationSalt, + ownerKeySalt, + uBytes, + userPassword, + userValidationSalt, + userKeySalt, + ownerEncryption, + userEncryption, + perms + ); + if (encryptionKey) { + break; + } + } } if (!encryptionKey) { if (!password) { diff --git a/src/core/sasl_prep.js b/src/core/sasl_prep.js new file mode 100644 index 000000000..1fcc39c8d --- /dev/null +++ b/src/core/sasl_prep.js @@ -0,0 +1,51 @@ +/* Copyright 2026 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const NON_ASCII_SPACES = new Set([ + 0x00a0, 0x1680, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, + 0x2007, 0x2008, 0x2009, 0x200a, 0x200b, 0x202f, 0x205f, 0x3000, +]); + +const COMMONLY_MAPPED_TO_NOTHING = new Set([ + 0x00ad, 0x034f, 0x1806, 0x180b, 0x180c, 0x180d, 0x200b, 0x200c, 0x200d, + 0x2060, 0xfe00, 0xfe01, 0xfe02, 0xfe03, 0xfe04, 0xfe05, 0xfe06, 0xfe07, + 0xfe08, 0xfe09, 0xfe0a, 0xfe0b, 0xfe0c, 0xfe0d, 0xfe0e, 0xfe0f, 0xfeff, +]); + +function saslPrep(str) { + let mapped = ""; + for (const char of str) { + const code = char.codePointAt(0); + if (NON_ASCII_SPACES.has(code)) { + mapped += " "; + } else if (!COMMONLY_MAPPED_TO_NOTHING.has(code)) { + mapped += char; + } + } + + // SASLprep also specifies prohibited-output and bidirectional-text checks. + // They are intentionally omitted here since this function is only used to + // derive password candidates when opening PDFs. Being permissive lets us + // handle non-conforming files whose producers omitted those checks too; the + // derived candidate must still pass the PDF password verification. + + // TODO: SASLprep is based on Unicode 3.2, whereas String.prototype.normalize + // uses the Unicode version provided by the JavaScript runtime. Use frozen + // Unicode 3.2 normalization data if this difference causes interoperability + // problems in practice. + return mapped.normalize("NFKC"); +} + +export { saslPrep }; diff --git a/test/pdfs/.gitignore b/test/pdfs/.gitignore index b2c905c79..19c9ff2ad 100644 --- a/test/pdfs/.gitignore +++ b/test/pdfs/.gitignore @@ -944,3 +944,4 @@ !issue20504.pdf !issue20504_skia.pdf !issue21579.pdf +!saslprep-r6.pdf diff --git a/test/pdfs/saslprep-r6.pdf b/test/pdfs/saslprep-r6.pdf new file mode 100644 index 000000000..26ea1dedd --- /dev/null +++ b/test/pdfs/saslprep-r6.pdf @@ -0,0 +1,36 @@ +%PDF-2.0 +% +1 0 obj +<< /Pages 2 0 R /Type /Catalog >> +endobj +2 0 obj +<< /Count 1 /Kids [ 3 0 R ] /MediaBox [ 0 0 400 200 ] /Type /Pages >> +endobj +3 0 obj +<< /Contents 4 0 R /Parent 2 0 R /Resources << /Font << /F1 5 0 R >> >> /Type /Page >> +endobj +4 0 obj +<< /Length 80 /Filter /FlateDecode >> +stream +9%p~;wVə a1Tg ӆ;v)zk8p/ַXNr 80 +k$ ,pendstream +endobj +5 0 obj +<< /BaseFont /Helvetica /Subtype /Type1 /Type /Font >> +endobj +6 0 obj +<< /CF << /StdCF << /AuthEvent /DocOpen /CFM /AESV3 /Length 32 >> >> /Filter /Standard /Length 256 /O <2114dff5892a5f97b6b7ebf850027d59fd9582cabd1ca6545e8b7101fc9dd200847af489e83e58df71cfcef246a6b295> /OE <41f1bf91aa8bf033d86dcf0b1419d6bf8b2a4ffa6c6eaa6285f253427ccffb58> /P -4 /Perms <0efe0222fb3566a638740c6111174a66> /R 6 /StmF /StdCF /StrF /StdCF /U <981e8012145993440b792f07715ecc7bd1cd672c5b32ddb7c07dc7d51f355123bdca31fd6d7bb7d160297691d233e1b4> /UE <772076efadec02adc61b06de50debe38dda5dbc7378e03354c1dfddd1383ac9b> /V 5 >> +endobj +xref +0 7 +0000000000 65535 f +0000000015 00000 n +0000000064 00000 n +0000000149 00000 n +0000000251 00000 n +0000000401 00000 n +0000000471 00000 n +trailer << /Root 1 0 R /Size 7 /ID [<20ecf0586687a9f2d48b7fa8bcafe19e><20ecf0586687a9f2d48b7fa8bcafe19e>] /Encrypt 6 0 R >> +startxref +1018 +%%EOF diff --git a/test/test_manifest.json b/test/test_manifest.json index c8930dcf2..b2607f01d 100644 --- a/test/test_manifest.json +++ b/test/test_manifest.json @@ -14471,5 +14471,13 @@ "rounds": 1, "type": "eq", "password": "pässwört" + }, + { + "id": "saslprep-r6", + "file": "pdfs/saslprep-r6.pdf", + "md5": "2aee2434f164a00a52b98f29b6f9758c", + "rounds": 1, + "type": "eq", + "password": "S\u00AASL\u00ADprep" } ] diff --git a/test/unit/crypto_spec.js b/test/unit/crypto_spec.js index 7179a8aa5..044473710 100644 --- a/test/unit/crypto_spec.js +++ b/test/unit/crypto_spec.js @@ -33,6 +33,7 @@ import { } from "../../src/shared/util.js"; import { calculateMD5 } from "../../src/core/calculate_md5.js"; import { calculateSHA256 } from "../../src/core/calculate_sha256.js"; +import { saslPrep } from "../../src/core/sasl_prep.js"; describe("crypto", function () { // RFC 1321, A.5 Test suite @@ -519,6 +520,47 @@ describe("crypto", function () { }); }); +describe("saslPrep", function () { + it("should leave ASCII strings unchanged", function () { + expect(saslPrep("")).toEqual(""); + expect(saslPrep("password")).toEqual("password"); + }); + + it("should map non-ASCII space characters to U+0020", function () { + expect(saslPrep("a\u00A0b")).toEqual("a b"); + expect(saslPrep("a\u2003b")).toEqual("a b"); + expect(saslPrep("a\u3000b")).toEqual("a b"); + }); + + it("should remove characters commonly mapped to nothing", function () { + expect(saslPrep("pass\u00ADword")).toEqual("password"); + expect(saslPrep("a\u200Db")).toEqual("ab"); + expect(saslPrep("a\uFEFFb")).toEqual("ab"); + }); + + it("should apply NFKC normalization", function () { + expect(saslPrep("\u00AA")).toEqual("a"); + expect(saslPrep("\u2168")).toEqual("IX"); + expect(saslPrep("\uFB01")).toEqual("fi"); + }); + + it("should tolerate bidirectional text rejected by SASLprep", function () { + // A RandALCat character followed by a non-RandALCat character would fail + // the SASLprep bidirectional-text check, which we intentionally omit. + expect(saslPrep("\u0627\u0031")).toEqual("\u0627\u0031"); + }); + + it("should normalize using the runtime Unicode version", function () { + // U+1D2C was unassigned in Unicode 3.2, on which SASLprep is based, but + // current Unicode normalization maps it to LATIN CAPITAL LETTER A. + expect(saslPrep("\u1D2C")).toEqual("A"); + }); + + it("should prepare the password `SªSL­prep`", function () { + expect(saslPrep("SªSL­prep")).toEqual("SaSLprep"); + }); +}); + describe("CipherTransformFactory", function () { function buildDict(map) { const dict = new Dict(); @@ -617,6 +659,7 @@ describe("CipherTransformFactory", function () { let fileId1, fileId2, dict1, dict2, dict3; let aes256Dict, aes256IsoDict, aes256BlankDict, aes256IsoBlankDict; let aes256UnicodeDict; + let aes256SaslPrepDict, aes256SaslPrepFallbackDict; beforeAll(function () { fileId1 = unescape("%F6%C6%AF%17%F3rR%8DRM%9A%80%D1%EF%DF%18"); @@ -780,12 +823,63 @@ describe("CipherTransformFactory", function () { P: -1084, R: 5, }); + aes256SaslPrepDict = buildDict({ + Filter: Name.get("Standard"), + V: 5, + Length: 256, + O: unescape( + "%21%14%DF%F5%89%2A%5F%97%B6%B7%EB%F8P%02%7DY%FD%95%82%CA%BD" + + "%1C%A6T%5E%8Bq%01%FC%9D%D2%00%84z%F4%89%E8%3EX%DFq%CF%CE%F2F" + + "%A6%B2%95" + ), + U: unescape( + "%98%1E%80%12%14Y%93D%0By%2F%07q%5E%CC%7B%D1%CDg%2C%5B2%DD%B7" + + "%C0%7D%C7%D5%1F5Q%23%BD%CA1%FDm%7B%B7%D1%60%29v%91%D23%E1%B4" + ), + OE: unescape( + "A%F1%BF%91%AA%8B%F03%D8m%CF%0B%14%19%D6%BF%8B%2AO%FAln%AAb%85" + + "%F2SB%7C%CF%FBX" + ), + UE: unescape( + "w%20v%EF%AD%EC%02%AD%C6%1B%06%DEP%DE%BE8%DD%A5%DB%C77%8E%035L" + + "%1D%FD%DD%13%83%AC%9B" + ), + Perms: unescape("%0E%FE%02%22%FB5f%A68t%0Ca%11%17Jf"), + P: -4, + R: 6, + }); + aes256SaslPrepFallbackDict = buildDict({ + Filter: Name.get("Standard"), + V: 5, + Length: 256, + O: unescape( + "%B1O%A6A%BD%E9%19%09%EC%FFuG%3C%BEWZ%2CN%F6%C7Cz%9A%E1%84%15" + + "%0C66%AE%B4%E6%C0%C1%C2%C3%C4%C5%C6%C7%D0%D1%D2%D3%D4%D5%D6%D7" + ), + U: unescape( + "i%0C%B0%B9%B7%90%AAXN%C916E%2E%C2%CF%BEG%DE%1Dqf%8F%3A%BF%92" + + "%FB%FA%C8o%23%11%A0%A1%A2%A3%A4%A5%A6%A7%B0%B1%B2%B3%B4%B5%B6" + + "%B7" + ), + OE: unescape( + "%0BE%22%A4%AAX%DA%C5%D4%DAL%A0%91Q%3E%3AE%EE%B7%97W%CFm%B8%AC" + + "%8Ak%80%B4%5D%EB%0F" + ), + UE: unescape( + "%D0%C0%E4rM%F7%E7%D2%FA%A0%90%7B%BC%BE%86p5%B5%27%A1%10%8A%86" + + "%86%95%2B%92%B4%E1%F4%A5%9A" + ), + Perms: unescape("%ED%D4%5FM%ED%9E%DE%F9%82%C8%A9%7F%C1%CC%C4%B4"), + P: -4, + R: 6, + }); }); afterAll(function () { fileId1 = fileId2 = dict1 = dict2 = dict3 = null; aes256Dict = aes256IsoDict = aes256BlankDict = aes256IsoBlankDict = null; aes256UnicodeDict = null; + aes256SaslPrepDict = aes256SaslPrepFallbackDict = null; }); describe("#ctor", function () { @@ -844,6 +938,29 @@ describe("CipherTransformFactory", function () { }); }); + describe("AES256 Revision 6 with SASLprep", function () { + it("should accept a password requiring SASLprep", function () { + ensurePasswordCorrect( + aes256SaslPrepDict, + fileId1, + "S\u00AASL\u00ADprep" + ); + }); + it("should accept an already prepared password", function () { + ensurePasswordCorrect(aes256SaslPrepDict, fileId1, "SaSLprep"); + }); + it("should not accept wrong password", function () { + ensurePasswordIncorrect(aes256SaslPrepDict, fileId1, "wrong"); + }); + it("should fall back to the raw password", function () { + ensurePasswordCorrect( + aes256SaslPrepFallbackDict, + fileId1, + "pass\u00ADword" + ); + }); + }); + it("should accept user password", function () { ensurePasswordCorrect(dict1, fileId1, "123456"); });