Skip to content

Commit 6e18a52

Browse files
authored
Merge pull request #21583 from calixteman/sasl_prep
Support SASLprep for AES-256 revision 6 passwords
2 parents 244fb9e + 2ea9c26 commit 6e18a52

6 files changed

Lines changed: 218 additions & 23 deletions

File tree

src/core/crypto.js

Lines changed: 41 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { Dict, isDict, isName, Name } from "./primitives.js";
3434
import { calculateMD5 } from "./calculate_md5.js";
3535
import { calculateSHA256 } from "./calculate_sha256.js";
3636
import { DecryptStream } from "./decrypt_stream.js";
37+
import { saslPrep } from "./sasl_prep.js";
3738

3839
/**
3940
* @typedef {typeof AES128Cipher | typeof AES256Cipher | typeof ARCFourCipher
@@ -853,6 +854,15 @@ class CipherTransform {
853854
}
854855
}
855856

857+
function utf8PasswordToBytes(password) {
858+
try {
859+
password = utf8StringToString(password);
860+
} catch {
861+
warn("CipherTransformFactory: Unable to convert UTF8 encoded password.");
862+
}
863+
return stringToBytes(password);
864+
}
865+
856866
class CipherTransformFactory {
857867
#fileId;
858868

@@ -1128,18 +1138,19 @@ class CipherTransformFactory {
11281138
this.encryptMetadata = encryptMetadata;
11291139

11301140
const fileIdBytes = stringToBytes(fileId);
1131-
let passwordBytes;
1141+
let passwordBytes, rawPasswordBytes;
11321142
if (password) {
1133-
if (algorithm === 5) {
1134-
try {
1135-
password = utf8StringToString(password);
1136-
} catch {
1137-
warn(
1138-
"CipherTransformFactory: Unable to convert UTF8 encoded password."
1139-
);
1143+
if (revision === 6) {
1144+
const preppedPassword = saslPrep(password);
1145+
passwordBytes = utf8PasswordToBytes(preppedPassword);
1146+
if (preppedPassword !== password) {
1147+
rawPasswordBytes = utf8PasswordToBytes(password);
11401148
}
1149+
} else if (algorithm === 5) {
1150+
passwordBytes = utf8PasswordToBytes(password);
1151+
} else {
1152+
passwordBytes = stringToBytes(password);
11411153
}
1142-
passwordBytes = stringToBytes(password);
11431154
}
11441155

11451156
let encryptionKey;
@@ -1163,20 +1174,27 @@ class CipherTransformFactory {
11631174
const ownerEncryption = stringToBytes(dict.get("OE"));
11641175
const userEncryption = stringToBytes(dict.get("UE"));
11651176
const perms = stringToBytes(dict.get("Perms"));
1166-
encryptionKey = this.#createEncryptionKey20(
1167-
revision,
1168-
passwordBytes,
1169-
ownerPassword,
1170-
ownerValidationSalt,
1171-
ownerKeySalt,
1172-
uBytes,
1173-
userPassword,
1174-
userValidationSalt,
1175-
userKeySalt,
1176-
ownerEncryption,
1177-
userEncryption,
1178-
perms
1179-
);
1177+
for (const candidate of rawPasswordBytes
1178+
? [passwordBytes, rawPasswordBytes]
1179+
: [passwordBytes]) {
1180+
encryptionKey = this.#createEncryptionKey20(
1181+
revision,
1182+
candidate,
1183+
ownerPassword,
1184+
ownerValidationSalt,
1185+
ownerKeySalt,
1186+
uBytes,
1187+
userPassword,
1188+
userValidationSalt,
1189+
userKeySalt,
1190+
ownerEncryption,
1191+
userEncryption,
1192+
perms
1193+
);
1194+
if (encryptionKey) {
1195+
break;
1196+
}
1197+
}
11801198
}
11811199
if (!encryptionKey) {
11821200
if (!password) {

src/core/sasl_prep.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/* Copyright 2026 Mozilla Foundation
2+
*
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License.
5+
* You may obtain a copy of the License at
6+
*
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
*/
15+
16+
const NON_ASCII_SPACES = new Set([
17+
0x00a0, 0x1680, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006,
18+
0x2007, 0x2008, 0x2009, 0x200a, 0x200b, 0x202f, 0x205f, 0x3000,
19+
]);
20+
21+
const COMMONLY_MAPPED_TO_NOTHING = new Set([
22+
0x00ad, 0x034f, 0x1806, 0x180b, 0x180c, 0x180d, 0x200b, 0x200c, 0x200d,
23+
0x2060, 0xfe00, 0xfe01, 0xfe02, 0xfe03, 0xfe04, 0xfe05, 0xfe06, 0xfe07,
24+
0xfe08, 0xfe09, 0xfe0a, 0xfe0b, 0xfe0c, 0xfe0d, 0xfe0e, 0xfe0f, 0xfeff,
25+
]);
26+
27+
function saslPrep(str) {
28+
let mapped = "";
29+
for (const char of str) {
30+
const code = char.codePointAt(0);
31+
if (NON_ASCII_SPACES.has(code)) {
32+
mapped += " ";
33+
} else if (!COMMONLY_MAPPED_TO_NOTHING.has(code)) {
34+
mapped += char;
35+
}
36+
}
37+
38+
// SASLprep also specifies prohibited-output and bidirectional-text checks.
39+
// They are intentionally omitted here since this function is only used to
40+
// derive password candidates when opening PDFs. Being permissive lets us
41+
// handle non-conforming files whose producers omitted those checks too; the
42+
// derived candidate must still pass the PDF password verification.
43+
44+
// TODO: SASLprep is based on Unicode 3.2, whereas String.prototype.normalize
45+
// uses the Unicode version provided by the JavaScript runtime. Use frozen
46+
// Unicode 3.2 normalization data if this difference causes interoperability
47+
// problems in practice.
48+
return mapped.normalize("NFKC");
49+
}
50+
51+
export { saslPrep };

test/pdfs/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -944,3 +944,4 @@
944944
!issue20504.pdf
945945
!issue20504_skia.pdf
946946
!issue21579.pdf
947+
!saslprep-r6.pdf

test/pdfs/saslprep-r6.pdf

1.28 KB
Binary file not shown.

test/test_manifest.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14471,5 +14471,13 @@
1447114471
"rounds": 1,
1447214472
"type": "eq",
1447314473
"password": "pässwört"
14474+
},
14475+
{
14476+
"id": "saslprep-r6",
14477+
"file": "pdfs/saslprep-r6.pdf",
14478+
"md5": "2aee2434f164a00a52b98f29b6f9758c",
14479+
"rounds": 1,
14480+
"type": "eq",
14481+
"password": "S\u00AASL\u00ADprep"
1447414482
}
1447514483
]

test/unit/crypto_spec.js

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
} from "../../src/shared/util.js";
3434
import { calculateMD5 } from "../../src/core/calculate_md5.js";
3535
import { calculateSHA256 } from "../../src/core/calculate_sha256.js";
36+
import { saslPrep } from "../../src/core/sasl_prep.js";
3637

3738
describe("crypto", function () {
3839
// RFC 1321, A.5 Test suite
@@ -519,6 +520,47 @@ describe("crypto", function () {
519520
});
520521
});
521522

523+
describe("saslPrep", function () {
524+
it("should leave ASCII strings unchanged", function () {
525+
expect(saslPrep("")).toEqual("");
526+
expect(saslPrep("password")).toEqual("password");
527+
});
528+
529+
it("should map non-ASCII space characters to U+0020", function () {
530+
expect(saslPrep("a\u00A0b")).toEqual("a b");
531+
expect(saslPrep("a\u2003b")).toEqual("a b");
532+
expect(saslPrep("a\u3000b")).toEqual("a b");
533+
});
534+
535+
it("should remove characters commonly mapped to nothing", function () {
536+
expect(saslPrep("pass\u00ADword")).toEqual("password");
537+
expect(saslPrep("a\u200Db")).toEqual("ab");
538+
expect(saslPrep("a\uFEFFb")).toEqual("ab");
539+
});
540+
541+
it("should apply NFKC normalization", function () {
542+
expect(saslPrep("\u00AA")).toEqual("a");
543+
expect(saslPrep("\u2168")).toEqual("IX");
544+
expect(saslPrep("\uFB01")).toEqual("fi");
545+
});
546+
547+
it("should tolerate bidirectional text rejected by SASLprep", function () {
548+
// A RandALCat character followed by a non-RandALCat character would fail
549+
// the SASLprep bidirectional-text check, which we intentionally omit.
550+
expect(saslPrep("\u0627\u0031")).toEqual("\u0627\u0031");
551+
});
552+
553+
it("should normalize using the runtime Unicode version", function () {
554+
// U+1D2C was unassigned in Unicode 3.2, on which SASLprep is based, but
555+
// current Unicode normalization maps it to LATIN CAPITAL LETTER A.
556+
expect(saslPrep("\u1D2C")).toEqual("A");
557+
});
558+
559+
it("should prepare the password `SªSL­prep`", function () {
560+
expect(saslPrep("SªSL­prep")).toEqual("SaSLprep");
561+
});
562+
});
563+
522564
describe("CipherTransformFactory", function () {
523565
function buildDict(map) {
524566
const dict = new Dict();
@@ -617,6 +659,7 @@ describe("CipherTransformFactory", function () {
617659
let fileId1, fileId2, dict1, dict2, dict3;
618660
let aes256Dict, aes256IsoDict, aes256BlankDict, aes256IsoBlankDict;
619661
let aes256UnicodeDict;
662+
let aes256SaslPrepDict, aes256SaslPrepFallbackDict;
620663

621664
beforeAll(function () {
622665
fileId1 = unescape("%F6%C6%AF%17%F3rR%8DRM%9A%80%D1%EF%DF%18");
@@ -780,12 +823,63 @@ describe("CipherTransformFactory", function () {
780823
P: -1084,
781824
R: 5,
782825
});
826+
aes256SaslPrepDict = buildDict({
827+
Filter: Name.get("Standard"),
828+
V: 5,
829+
Length: 256,
830+
O: unescape(
831+
"%21%14%DF%F5%89%2A%5F%97%B6%B7%EB%F8P%02%7DY%FD%95%82%CA%BD" +
832+
"%1C%A6T%5E%8Bq%01%FC%9D%D2%00%84z%F4%89%E8%3EX%DFq%CF%CE%F2F" +
833+
"%A6%B2%95"
834+
),
835+
U: unescape(
836+
"%98%1E%80%12%14Y%93D%0By%2F%07q%5E%CC%7B%D1%CDg%2C%5B2%DD%B7" +
837+
"%C0%7D%C7%D5%1F5Q%23%BD%CA1%FDm%7B%B7%D1%60%29v%91%D23%E1%B4"
838+
),
839+
OE: unescape(
840+
"A%F1%BF%91%AA%8B%F03%D8m%CF%0B%14%19%D6%BF%8B%2AO%FAln%AAb%85" +
841+
"%F2SB%7C%CF%FBX"
842+
),
843+
UE: unescape(
844+
"w%20v%EF%AD%EC%02%AD%C6%1B%06%DEP%DE%BE8%DD%A5%DB%C77%8E%035L" +
845+
"%1D%FD%DD%13%83%AC%9B"
846+
),
847+
Perms: unescape("%0E%FE%02%22%FB5f%A68t%0Ca%11%17Jf"),
848+
P: -4,
849+
R: 6,
850+
});
851+
aes256SaslPrepFallbackDict = buildDict({
852+
Filter: Name.get("Standard"),
853+
V: 5,
854+
Length: 256,
855+
O: unescape(
856+
"%B1O%A6A%BD%E9%19%09%EC%FFuG%3C%BEWZ%2CN%F6%C7Cz%9A%E1%84%15" +
857+
"%0C66%AE%B4%E6%C0%C1%C2%C3%C4%C5%C6%C7%D0%D1%D2%D3%D4%D5%D6%D7"
858+
),
859+
U: unescape(
860+
"i%0C%B0%B9%B7%90%AAXN%C916E%2E%C2%CF%BEG%DE%1Dqf%8F%3A%BF%92" +
861+
"%FB%FA%C8o%23%11%A0%A1%A2%A3%A4%A5%A6%A7%B0%B1%B2%B3%B4%B5%B6" +
862+
"%B7"
863+
),
864+
OE: unescape(
865+
"%0BE%22%A4%AAX%DA%C5%D4%DAL%A0%91Q%3E%3AE%EE%B7%97W%CFm%B8%AC" +
866+
"%8Ak%80%B4%5D%EB%0F"
867+
),
868+
UE: unescape(
869+
"%D0%C0%E4rM%F7%E7%D2%FA%A0%90%7B%BC%BE%86p5%B5%27%A1%10%8A%86" +
870+
"%86%95%2B%92%B4%E1%F4%A5%9A"
871+
),
872+
Perms: unescape("%ED%D4%5FM%ED%9E%DE%F9%82%C8%A9%7F%C1%CC%C4%B4"),
873+
P: -4,
874+
R: 6,
875+
});
783876
});
784877

785878
afterAll(function () {
786879
fileId1 = fileId2 = dict1 = dict2 = dict3 = null;
787880
aes256Dict = aes256IsoDict = aes256BlankDict = aes256IsoBlankDict = null;
788881
aes256UnicodeDict = null;
882+
aes256SaslPrepDict = aes256SaslPrepFallbackDict = null;
789883
});
790884

791885
describe("#ctor", function () {
@@ -844,6 +938,29 @@ describe("CipherTransformFactory", function () {
844938
});
845939
});
846940

941+
describe("AES256 Revision 6 with SASLprep", function () {
942+
it("should accept a password requiring SASLprep", function () {
943+
ensurePasswordCorrect(
944+
aes256SaslPrepDict,
945+
fileId1,
946+
"S\u00AASL\u00ADprep"
947+
);
948+
});
949+
it("should accept an already prepared password", function () {
950+
ensurePasswordCorrect(aes256SaslPrepDict, fileId1, "SaSLprep");
951+
});
952+
it("should not accept wrong password", function () {
953+
ensurePasswordIncorrect(aes256SaslPrepDict, fileId1, "wrong");
954+
});
955+
it("should fall back to the raw password", function () {
956+
ensurePasswordCorrect(
957+
aes256SaslPrepFallbackDict,
958+
fileId1,
959+
"pass\u00ADword"
960+
);
961+
});
962+
});
963+
847964
it("should accept user password", function () {
848965
ensurePasswordCorrect(dict1, fileId1, "123456");
849966
});

0 commit comments

Comments
 (0)