Skip to content

Commit e50a61e

Browse files
committed
fix(pdf): V4/AES-128 key length reads top-level /Length, not /CF subdict's
Reported by docscan (who vendored pdf_decryptor.zig and hit it integrating blank-password PDF decryption). For V4/AESV2, qpdf orders the /CF crypt-filter subdict — whose /Length 16 is key BYTES — before the top-level /Length 128 (key BITS). The first-match /Length scan grabbed the nested 16 → divFloor(16,8)=2 → clamped to 5 → a 40-bit key → wrong file key → garbage decrypt. Now a depth-aware scan takes the brace-depth-1 /Length. RC4 (single /Length) and V5/AES-256 (UE key path, no /Length-derived key) were unaffected; only V4/AESV2 was broken, and it was undertested (suite covered RC4 + V5/R6, not V4 end-to-end). TDD: added a V4/AESV2 inline-fixture regression test (CF /Length 16 ordered before top-level /Length 128) asserting key_length==16 — confirmed RED (found 5) before the fix, GREEN after. All 13 pdf_decryptor tests + full ./test pass.
1 parent 53c9a49 commit e50a61e

1 file changed

Lines changed: 51 additions & 7 deletions

File tree

src/core/pdf_decryptor.zig

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -134,14 +134,36 @@ pub fn parseEncryptionParams(data: []const u8) ?EncryptionParams {
134134
}
135135
}
136136

137-
// Parse /Length (key length in bits)
138-
if (findDictValue(enc_dict, "/Length")) |v| {
139-
if (parseNumber(enc_dict, v)) |num| {
140-
params.key_length = @intCast(@max(5, @min(16, @divFloor(num.value, 8))));
137+
// Parse /Length (key length in bits). Depth-aware: take the TOP-LEVEL
138+
// (brace-depth-1) /Length, NOT the /CF crypt-filter subdict's /Length (which
139+
// is the per-filter key length in BYTES, not bits). qpdf and others order the
140+
// /CF subdict before the top-level /Length, so a naive first-match grabs the
141+
// nested 16 → @divFloor(16,8)=2 → clamps to 5 → a 40-bit key for V4/AES-128.
142+
{
143+
var depth: i32 = 0;
144+
var p: usize = 0;
145+
var found_len = false;
146+
while (p + 1 < enc_dict.len) : (p += 1) {
147+
if (enc_dict[p] == '<' and enc_dict[p + 1] == '<') {
148+
depth += 1;
149+
p += 1;
150+
} else if (enc_dict[p] == '>' and enc_dict[p + 1] == '>') {
151+
depth -= 1;
152+
p += 1;
153+
} else if (depth == 1 and std.mem.startsWith(u8, enc_dict[p..], "/Length")) {
154+
if (findDictValue(enc_dict[p..], "/Length")) |v| {
155+
if (parseNumber(enc_dict[p..], v)) |num| {
156+
params.key_length = @intCast(@max(5, @min(16, @divFloor(num.value, 8))));
157+
found_len = true;
158+
}
159+
}
160+
break;
161+
}
162+
}
163+
if (!found_len) {
164+
// Default key length based on version.
165+
params.key_length = if (params.version >= 2) 16 else 5;
141166
}
142-
} else {
143-
// Default key length based on version
144-
params.key_length = if (params.version >= 2) 16 else 5;
145167
}
146168

147169
// Parse /P (permissions)
@@ -1101,3 +1123,25 @@ test "computeV5Hash: R5 path is plain SHA-256(password ++ salt)" {
11011123
h.final(&want);
11021124
try std.testing.expectEqualSlices(u8, &want, &got);
11031125
}
1126+
1127+
test "parseEncryptionParams: V4/AES-128 uses top-level /Length 128, not /CF subdict's /Length 16" {
1128+
// Regression (reported by docscan, 2026-06-14). qpdf orders the /CF
1129+
// crypt-filter subdict — whose own /Length 16 is the key length in BYTES —
1130+
// BEFORE the top-level /Length 128 (key length in BITS). A naive first-match
1131+
// /Length scan grabs the nested 16, computes @divFloor(16,8)=2, clamps to 5,
1132+
// and derives a 40-bit key instead of 128-bit → wrong file key → garbage.
1133+
// The scan must take the top-level (brace-depth-1) /Length.
1134+
const data =
1135+
"%PDF-1.6\n" ++
1136+
"trailer << /Encrypt 5 0 R /Root 1 0 R /ID [(0123456789ABCDEF)(0123456789ABCDEF)] >>\n" ++
1137+
"5 0 obj\n" ++
1138+
"<< /CF << /StdCF << /AuthEvent /DocOpen /CFM /AESV2 /Length 16 >> >> " ++
1139+
"/Filter /Standard /V 4 /R 4 /Length 128 /P -44 " ++
1140+
"/O (" ++ ("O" ** 32) ++ ") /U (" ++ ("U" ** 32) ++ ") >>\n" ++
1141+
"endobj\n";
1142+
const params = parseEncryptionParams(data) orelse return error.ParseFailed;
1143+
try std.testing.expectEqual(@as(u8, 4), params.version);
1144+
try std.testing.expect(params.use_aes);
1145+
// Pre-fix: 5 (40-bit). Correct: 128 bits / 8 = 16 bytes.
1146+
try std.testing.expectEqual(@as(u8, 16), params.key_length);
1147+
}

0 commit comments

Comments
 (0)