diff --git a/CHANGELOG.md b/CHANGELOG.md index a6d27e9fa..79104dcae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- PDF authentication accepts named crypt filters with an explicit or default + `None` method, and applies the 127-byte password limit for AES-256 revision 6. + - Spreadsheet recalculation recognizes STDEV.S, STDEV.P, VAR.S, and VAR.P, including their Excel and LibreOffice compatibility prefixes. diff --git a/src/odr/internal/pdf/pdf_encryption.cpp b/src/odr/internal/pdf/pdf_encryption.cpp index 834604ed2..c9d942434 100644 --- a/src/odr/internal/pdf/pdf_encryption.cpp +++ b/src/odr/internal/pdf/pdf_encryption.cpp @@ -195,7 +195,7 @@ std::optional cfm_method(const std::string &cfm) { if (cfm == "AESV3") { return EncryptionMethod::aes_v3; } - if (cfm == "Identity") { + if (cfm == "None" || cfm == "Identity") { return EncryptionMethod::none; } return std::nullopt; @@ -216,12 +216,27 @@ std::optional resolve_crypt_filter(const Dictionary &encrypt, return std::nullopt; } const Dictionary &filter = cf[name].as_dictionary(); + // ISO 32000-1 Table 25: an omitted CFM defaults to None. + if (filter.get("CFM").is_null()) { + return EncryptionMethod::none; + } if (!filter.get("CFM").is_name()) { return std::nullopt; } return cfm_method(filter["CFM"].as_name()); } +/// The method `/StmF` or `/StrF` selects, or @p fallback if the entry is +/// absent. +std::optional filter_method(const Dictionary &encrypt, + const std::string &key, + const EncryptionMethod fallback) { + if (!encrypt.get(key).is_name()) { + return fallback; + } + return resolve_crypt_filter(encrypt, encrypt[key].as_name()); +} + } // namespace std::optional @@ -259,11 +274,20 @@ Authenticator::create(const Dictionary &encrypt, const std::string &file_id0) { d.m_oe = encrypt["OE"].as_string(); d.m_ue = encrypt["UE"].as_string(); d.m_key_length = 32; - d.m_stream_method = EncryptionMethod::aes_v3; - d.m_string_method = EncryptionMethod::aes_v3; if (d.m_r != 6) { return std::nullopt; // R 5 (deprecated interim revision) is out of scope } + // The spec defaults to Identity, but AES-256 writers that omit the crypt + // filters still encrypt everything. + const auto stream_method = + filter_method(encrypt, "StmF", EncryptionMethod::aes_v3); + const auto string_method = + filter_method(encrypt, "StrF", EncryptionMethod::aes_v3); + if (!stream_method || !string_method) { + return std::nullopt; // unsupported crypt filter + } + d.m_stream_method = *stream_method; + d.m_string_method = *string_method; return d; } @@ -279,12 +303,10 @@ Authenticator::create(const Dictionary &encrypt, const std::string &file_id0) { if (d.m_v == 4) { // Crypt filters select the method for streams and strings; the defaults // are Identity (Table 20). - const std::string stmf = - encrypt.get("StmF").is_name() ? encrypt["StmF"].as_name() : "Identity"; - const std::string strf = - encrypt.get("StrF").is_name() ? encrypt["StrF"].as_name() : "Identity"; - const auto stream_method = resolve_crypt_filter(encrypt, stmf); - const auto string_method = resolve_crypt_filter(encrypt, strf); + const auto stream_method = + filter_method(encrypt, "StmF", EncryptionMethod::none); + const auto string_method = + filter_method(encrypt, "StrF", EncryptionMethod::none); if (!stream_method || !string_method) { return std::nullopt; // unsupported crypt filter } @@ -307,15 +329,17 @@ Authenticator::authenticate(const std::string &password) const { // ISO 32000-2 Algorithms 11/12: validate against the salts in /U and /O, // then unwrap the file key from /UE or /OE with AES-256-CBC (zero IV). const std::string zero_iv(aes_block, '\0'); + // Algorithm 2.A: only the first 127 bytes of the UTF-8 password count. + const std::string truncated = password.substr(0, 127); - if (hash_r6(password, m_u.substr(32, 8), {}) == m_u.substr(0, 32)) { - const std::string ik = hash_r6(password, m_u.substr(40, 8), {}); + if (hash_r6(truncated, m_u.substr(32, 8), {}) == m_u.substr(0, 32)) { + const std::string ik = hash_r6(truncated, m_u.substr(40, 8), {}); std::string key = crypto::util::decrypt_aes_cbc(ik, zero_iv, m_ue); return Decryptor(std::move(key), m_stream_method, m_string_method); } const std::string u48 = m_u.substr(0, 48); - if (hash_r6(password, m_o.substr(32, 8), u48) == m_o.substr(0, 32)) { - const std::string ik = hash_r6(password, m_o.substr(40, 8), u48); + if (hash_r6(truncated, m_o.substr(32, 8), u48) == m_o.substr(0, 32)) { + const std::string ik = hash_r6(truncated, m_o.substr(40, 8), u48); std::string key = crypto::util::decrypt_aes_cbc(ik, zero_iv, m_oe); return Decryptor(std::move(key), m_stream_method, m_string_method); } diff --git a/test/src/internal/pdf/pdf_encryption.cpp b/test/src/internal/pdf/pdf_encryption.cpp index fff1673b3..4a21f444a 100644 --- a/test/src/internal/pdf/pdf_encryption.cpp +++ b/test/src/internal/pdf/pdf_encryption.cpp @@ -159,6 +159,92 @@ TEST(PdfEncryption, qpdf_aes_128_r4) { EXPECT_NE(content.find(kMarker), std::string::npos); } +TEST(PdfEncryption, named_unencrypted_crypt_filter) { + const std::string owner(32, 'o'); + const std::string key = standard_security::compute_key_r2_r4( + "user", owner, -4, "id", 4, 16, true); + const std::string user = standard_security::compute_u_r2_r4(key, "id", 4); + for (const bool explicit_method : {false, true}) { + SCOPED_TRACE(explicit_method); + Dictionary encrypt = standard_encrypt(4, 4, 128, owner, user); + Dictionary filter; + if (explicit_method) { + filter["CFM"] = Object(Name("None")); + } + Dictionary filters; + filters["Clear"] = Object(std::move(filter)); + encrypt["CF"] = Object(std::move(filters)); + encrypt["StmF"] = Object(Name("Clear")); + encrypt["StrF"] = Object(Name("Clear")); + const auto authenticator = Authenticator::create(encrypt, "id"); + ASSERT_TRUE(authenticator.has_value()); + const auto decryptor = authenticator->authenticate("user"); + ASSERT_TRUE(decryptor.has_value()); + EXPECT_EQ(decryptor->decrypt_stream(kContentRef, kMarker), kMarker); + EXPECT_EQ(decryptor->decrypt_string(kContentRef, kMarker), kMarker); + } +} + +namespace { + +// Frozen qpdf 12.3.2 output, user/owner passwords: 127 'u'/'o' bytes. +Dictionary qpdf_r6_encrypt() { + Dictionary encrypt = standard_encrypt( + 5, 6, 256, + hex_decode( + "b6f62a02b186fa1bd0bf815c7b9374df14dd1e711287a4f5be1f623cdb2dae07" + "05fbe76667796505e0d1024c0b327eb9"), + hex_decode( + "7b27e3f32d74562fe3236910b01f794430c25b6227ccd22319eac7a773682fc7" + "e535e5435177c87a4ad8aa91ab2316a5")); + encrypt["OE"] = Object(StandardString(hex_decode( + "db29dc16413c275e33d0f297fde819f57173d601280cfefbe90f9c1899647f03"))); + encrypt["UE"] = Object(StandardString(hex_decode( + "3d2aa5d75ee4f90aee3fdf6a28af4b504912d9fe8678063910b673600e0c3c23"))); + return encrypt; +} + +const std::string kQpdfR6Stream = hex_decode( + "477bf3f6b8aa0697656e2844d037d183218f77ad3f2a926118a08b694f07ea2e" + "1fec24dbe016dac6bbffeba92757f4ccf227c9d378efafdb8e703fe38b501a22"); +constexpr const char *kQpdfR6Content = "BT /F1 12 Tf (KAT-MARKER-12345) Tj ET"; + +} // namespace + +TEST(PdfEncryption, qpdf_r6_truncates_user_and_owner_passwords) { + const auto authenticator = Authenticator::create(qpdf_r6_encrypt(), ""); + ASSERT_TRUE(authenticator.has_value()); + for (const char character : {'u', 'o'}) { + SCOPED_TRACE(character); + const std::string password(127, character); + EXPECT_FALSE(authenticator->authenticate(password.substr(1)).has_value()); + for (const std::string &candidate : {password, password + "ignored"}) { + const auto decryptor = authenticator->authenticate(candidate); + ASSERT_TRUE(decryptor.has_value()); + EXPECT_EQ(decryptor->decrypt_stream(kContentRef, kQpdfR6Stream), + kQpdfR6Content); + } + } +} + +TEST(PdfEncryption, r6_honors_named_crypt_filters) { + Dictionary encrypt = qpdf_r6_encrypt(); + Dictionary filter; + filter["CFM"] = Object(Name("AESV3")); + Dictionary filters; + filters["StdCF"] = Object(std::move(filter)); + encrypt["CF"] = Object(std::move(filters)); + encrypt["StmF"] = Object(Name("StdCF")); + encrypt["StrF"] = Object(Name("Identity")); + const auto authenticator = Authenticator::create(encrypt, ""); + ASSERT_TRUE(authenticator.has_value()); + const auto decryptor = authenticator->authenticate(std::string(127, 'u')); + ASSERT_TRUE(decryptor.has_value()); + EXPECT_EQ(decryptor->decrypt_stream(kContentRef, kQpdfR6Stream), + kQpdfR6Content); + EXPECT_EQ(decryptor->decrypt_string(kContentRef, kMarker), kMarker); +} + // A damaged file can end an AES string inside a block; the whole blocks before // it still decrypt. TEST(PdfEncryption, aes_drops_a_trailing_partial_block) {