Support SASLprep for AES-256 revision 6 passwords

This commit is contained in:
Calixte Denizet 2026-07-16 16:58:58 +02:00
parent e909f1f64f
commit 2ea9c26d77
6 changed files with 254 additions and 23 deletions

View File

@ -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) {

51
src/core/sasl_prep.js Normal file
View File

@ -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 };

View File

@ -944,3 +944,4 @@
!issue20504.pdf
!issue20504_skia.pdf
!issue21579.pdf
!saslprep-r6.pdf

36
test/pdfs/saslprep-r6.pdf Normal file
View File

@ -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ÛÐÉ™ aÇ1ðTg õîáÓ†;vž€±)üôzík—8p/Ö·XÜNr ã<08>º8ä0
â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

View File

@ -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"
}
]

View File

@ -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");
});