diff --git a/PdfSharpCore.Test/Security/EncryptedTextStringTests.cs b/PdfSharpCore.Test/Security/EncryptedTextStringTests.cs new file mode 100644 index 00000000..4276f6ba --- /dev/null +++ b/PdfSharpCore.Test/Security/EncryptedTextStringTests.cs @@ -0,0 +1,148 @@ +using System.IO; +using System.Text; +using FluentAssertions; +using PdfSharpCore.Pdf; +using PdfSharpCore.Pdf.IO; +using PdfSharpCore.Pdf.Security; +using Xunit; + +namespace PdfSharpCore.Test.Security +{ + /// + /// The byte order mark of a UTF-16BE text string belongs to the value of the string, so an + /// encrypted document has to encrypt it along with the text. Written outside the ciphertext it + /// is decrypted as if it were text, which shifts everything after it and leaves the string + /// unreadable: that is why the document properties of protected documents came out scrambled in + /// Acrobat and Firefox. See https://github.com/ststeiger/PdfSharpCore/issues/460. + /// + /// What the writer produces is checked with rather than by + /// reading it back, because a reader that shares the writer's mistake reads the writer's output + /// back perfectly, which is how this survived the existing round trip test. + /// + public class EncryptedTextStringTests + { + // Characters that cannot be written in a single byte, so the string has to go out as UTF-16BE. + private const string Title = "Tîtlé wíth àccents"; + private const string Author = "Ångström"; + private const string OwnerPassword = "12343"; + + [Theory] + [InlineData(PdfDocumentSecurityLevel.Encrypted40Bit)] + [InlineData(PdfDocumentSecurityLevel.Encrypted128Bit)] + public void ADocumentPropertyOfAnEncryptedDocumentCanBeReadByAConformingReader( + PdfDocumentSecurityLevel level) + { + var document = new StandardSecurity(SaveEncryptedDocument(level)); + + document.DerivedKeyMatchesTheDocument.Should() + .BeTrue("this test can only judge the strings if it agrees with the document about the key"); + document.DecryptInfoString("/Title").Should().Be(Title); + document.DecryptInfoString("/Author").Should().Be(Author); + } + + [Theory] + [InlineData(PdfDocumentSecurityLevel.Encrypted40Bit)] + [InlineData(PdfDocumentSecurityLevel.Encrypted128Bit)] + public void TheByteOrderMarkIsNotWrittenOutsideTheCipherText(PdfDocumentSecurityLevel level) + { + var document = new StandardSecurity(SaveEncryptedDocument(level)); + + // The mark is encrypted with the text now, so it is no longer spelled out in the file. + document.RawInfoString("/Title").Should().NotStartWith(" + /// The hexadecimal string the writer produced before the byte order mark moved into the + /// bytes: the mark spelled out, then the text, broken after every 48 bytes of that text. + /// + private static string HexStringAsItWasWrittenBefore(string text) + { + var bytes = Encoding.BigEndianUnicode.GetBytes(text); + var hex = new StringBuilder("'); + return hex.ToString(); + } + + private static byte[] SaveEncryptedDocument(PdfDocumentSecurityLevel level) + { + var document = new PdfDocument(); + document.AddPage(); + document.Info.Title = Title; + document.Info.Author = Author; + + // The settings reported in the issue. + var settings = document.SecuritySettings; + settings.DocumentSecurityLevel = level; + settings.OwnerPassword = OwnerPassword; + settings.UserPassword = ""; + settings.PermitAnnotations = false; + settings.PermitAssembleDocument = false; + settings.PermitExtractContent = false; + settings.PermitFormsFill = false; + settings.PermitFullQualityPrint = true; + settings.PermitModifyDocument = false; + settings.PermitPrint = true; + + using var output = new MemoryStream(); + document.Save(output, false); + return output.ToArray(); + } + } +} diff --git a/PdfSharpCore.Test/Security/StandardSecurity.cs b/PdfSharpCore.Test/Security/StandardSecurity.cs new file mode 100644 index 00000000..9c6cd5d3 --- /dev/null +++ b/PdfSharpCore.Test/Security/StandardSecurity.cs @@ -0,0 +1,287 @@ +using System; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; + +namespace PdfSharpCore.Test.Security +{ + /// + /// An independent implementation of the standard security handler, enough of it to decrypt the + /// strings of a document encrypted with RC4 and an empty user password. Tests that check what + /// PdfSharpCore writes need a reader that does not share its assumptions: a writer and a reader + /// that make the same mistake agree with each other perfectly, which is how the fault in issue + /// 460 survived a round trip test. + /// + /// The key derivation is checked against the /U entry the document itself carries, so a test + /// using this class fails loudly if the derivation is wrong rather than quietly comparing noise. + /// Algorithms 1, 2, 4 and 5 of ISO 32000-1, clause 7.6. + /// + internal sealed class StandardSecurity + { + private readonly string _pdf; + private readonly byte[] _fileKey; + + public StandardSecurity(byte[] document) + { + _pdf = Latin1String(document); + + byte[] id = FromHex(Last(_pdf, @"/ID\s*\[\s*<([0-9A-Fa-f]+)>").Groups[1].Value); + var encrypt = ObjectBody(int.Parse(Last(_pdf, @"/Encrypt\s+(\d+)\s+\d+\s+R").Groups[1].Value)); + + Revision = int.Parse(Regex.Match(encrypt, @"/R\s+(\d+)").Groups[1].Value); + int permissions = int.Parse(Regex.Match(encrypt, @"/P\s+(-?\d+)").Groups[1].Value); + int keyLength = (encrypt.Contains("/Length") + ? int.Parse(Regex.Match(encrypt, @"/Length\s+(\d+)").Groups[1].Value) + : 40) / 8; + + _fileKey = FileKey(StringValue(encrypt, "/O"), permissions, id, Revision, keyLength); + + byte[] expected = UserEntry(_fileKey, id, Revision); + byte[] actual = StringValue(encrypt, "/U"); + DerivedKeyMatchesTheDocument = StartsWith(actual, expected, Revision == 2 ? 32 : 16); + } + + public int Revision { get; } + + /// Whether this class agrees with the document about the file encryption key. + public bool DerivedKeyMatchesTheDocument { get; } + + /// The entry of the /Info dictionary, decrypted and decoded the way a reader decodes it. + public string DecryptInfoString(string key) + { + byte[] plain = Rc4(ObjectKey(_fileKey, InfoObjectNumber, 0), StringValue(InfoDictionary, key)); + if (plain.Length >= 2 && plain[0] == 0xFE && plain[1] == 0xFF) + return Encoding.BigEndianUnicode.GetString(plain, 2, plain.Length - 2); + return Latin1String(plain); + } + + /// The entry of the /Info dictionary exactly as it stands in the file. + public string RawInfoString(string key) + { + var hex = Regex.Match(InfoDictionary, Regex.Escape(key) + @"\s*(<[0-9A-Fa-f]*>)"); + return hex.Success ? hex.Groups[1].Value : null; + } + + /// + /// Rewrites an /Info entry the way PdfSharpCore wrote it before the fix, with the byte order + /// mark spelled out in front of the ciphertext instead of encrypted with it. The result is + /// the same length as what it replaces, so the cross-reference offsets still hold. + /// + public byte[] RewriteAsWrittenBeforeTheFix(string key, string text) + { + byte[] withoutMark = Encoding.BigEndianUnicode.GetBytes(text); + string replacement = ""; + string original = RawInfoString(key); + + if (original == null) + throw new InvalidOperationException($"{key} is not a hexadecimal string in this document."); + if (replacement.Length != original.Length) + throw new InvalidOperationException( + $"{key}: replacement is {replacement.Length} characters and the original {original.Length}; " + + "the offsets in the document would no longer hold."); + + int at = _pdf.IndexOf(original, InfoDictionaryIndex, StringComparison.Ordinal); + string rewritten = _pdf.Substring(0, at) + replacement + _pdf.Substring(at + original.Length); + return Latin1Bytes(rewritten); + } + + private int InfoObjectNumber => int.Parse(Last(_pdf, @"/Info\s+(\d+)\s+\d+\s+R").Groups[1].Value); + private string InfoDictionary => ObjectBody(InfoObjectNumber); + private int InfoDictionaryIndex => ObjectMatch(InfoObjectNumber).Groups[1].Index; + + private Match ObjectMatch(int number) => + Regex.Match(_pdf, @"(? ObjectMatch(number).Groups[1].Value; + + // Algorithm 2, with the empty user password. + private static byte[] FileKey(byte[] owner, int permissions, byte[] id, int revision, int keyLength) + { + var input = new List(Padding); + input.AddRange(owner); + input.AddRange(BitConverter.GetBytes(permissions)); + input.AddRange(id); + + using var md5 = MD5.Create(); + byte[] hash = md5.ComputeHash(input.ToArray()); + if (revision >= 3) + { + for (var i = 0; i < 50; i++) + hash = md5.ComputeHash(Take(hash, keyLength)); + } + return Take(hash, keyLength); + } + + // Algorithm 1: the key an individual object is encrypted with. + private static byte[] ObjectKey(byte[] fileKey, int objectNumber, int generation) + { + var input = new List(fileKey) + { + (byte)objectNumber, (byte)(objectNumber >> 8), (byte)(objectNumber >> 16), + (byte)generation, (byte)(generation >> 8) + }; + using var md5 = MD5.Create(); + return Take(md5.ComputeHash(input.ToArray()), Math.Min(fileKey.Length + 5, 16)); + } + + // Algorithms 4 and 5: the /U entry for an empty user password. + private static byte[] UserEntry(byte[] fileKey, byte[] id, int revision) + { + if (revision == 2) + return Rc4(fileKey, Padding); + + var input = new List(Padding); + input.AddRange(id); + using var md5 = MD5.Create(); + + byte[] result = Rc4(fileKey, md5.ComputeHash(input.ToArray())); + for (var i = 1; i <= 19; i++) + { + var key = new byte[fileKey.Length]; + for (var j = 0; j < fileKey.Length; j++) + key[j] = (byte)(fileKey[j] ^ i); + result = Rc4(key, result); + } + return result; + } + + private static byte[] Rc4(byte[] key, byte[] data) + { + var s = new byte[256]; + for (var i = 0; i < 256; i++) + s[i] = (byte)i; + for (int i = 0, j = 0; i < 256; i++) + { + j = (j + s[i] + key[i % key.Length]) & 0xFF; + (s[i], s[j]) = (s[j], s[i]); + } + + var result = new byte[data.Length]; + for (int n = 0, i = 0, j = 0; n < data.Length; n++) + { + i = (i + 1) & 0xFF; + j = (j + s[i]) & 0xFF; + (s[i], s[j]) = (s[j], s[i]); + result[n] = (byte)(data[n] ^ s[(s[i] + s[j]) & 0xFF]); + } + return result; + } + + /// Reads a string entry, whether it is written as a hexadecimal or a literal string. + private static byte[] StringValue(string dictionary, string key) + { + var hex = Regex.Match(dictionary, Regex.Escape(key) + @"\s*<([0-9A-Fa-f\s]*)>", RegexOptions.Singleline); + if (hex.Success) + return FromHex(Regex.Replace(hex.Groups[1].Value, @"\s", "")); + + var literal = Regex.Match(dictionary, Regex.Escape(key) + @"\s*\(", RegexOptions.Singleline); + if (!literal.Success) + return null; + + var bytes = new List(); + var depth = 1; + for (int i = literal.Index + literal.Length; i < dictionary.Length; i++) + { + char c = dictionary[i]; + if (c == '\\') + { + char escaped = dictionary[++i]; + switch (escaped) + { + case 'n': bytes.Add((byte)'\n'); break; + case 'r': bytes.Add((byte)'\r'); break; + case 't': bytes.Add((byte)'\t'); break; + case 'b': bytes.Add(8); break; + case 'f': bytes.Add(12); break; + default: + if (escaped >= '0' && escaped <= '7') + { + int value = escaped - '0'; + for (var digit = 0; digit < 2 && i + 1 < dictionary.Length + && dictionary[i + 1] >= '0' && dictionary[i + 1] <= '7'; digit++) + value = value * 8 + (dictionary[++i] - '0'); + bytes.Add((byte)value); + } + else + bytes.Add((byte)escaped); + break; + } + continue; + } + if (c == '(') depth++; + if (c == ')' && --depth == 0) break; + bytes.Add((byte)c); + } + return bytes.ToArray(); + } + + private static bool StartsWith(byte[] actual, byte[] expected, int count) + { + if (actual == null || actual.Length < count || expected.Length < Math.Min(count, expected.Length)) + return false; + for (var i = 0; i < count && i < expected.Length; i++) + if (actual[i] != expected[i]) + return false; + return true; + } + + private static byte[] Take(byte[] bytes, int count) + { + var result = new byte[count]; + Array.Copy(bytes, result, count); + return result; + } + + /// + /// Latin-1 maps every byte to the character of the same value and back, which is what lets a + /// document be searched as text without disturbing its bytes. Written out rather than taken + /// from Encoding, which does not offer Latin-1 on every framework this builds for. + /// + internal static string Latin1String(byte[] bytes) + { + var text = new char[bytes.Length]; + for (var i = 0; i < bytes.Length; i++) + text[i] = (char)bytes[i]; + return new string(text); + } + + internal static byte[] Latin1Bytes(string text) + { + var bytes = new byte[text.Length]; + for (var i = 0; i < text.Length; i++) + bytes[i] = (byte)text[i]; + return bytes; + } + + private static string ToHex(byte[] bytes) + { + var text = new StringBuilder(); + foreach (byte b in bytes) + text.AppendFormat("{0:X2}", b); + return text.ToString(); + } + + private static byte[] FromHex(string hex) + { + var bytes = new byte[hex.Length / 2]; + for (var i = 0; i < bytes.Length; i++) + bytes[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16); + return bytes; + } + + private static Match Last(string text, string pattern) + { + Match last = null; + foreach (Match m in Regex.Matches(text, pattern, RegexOptions.Singleline)) + last = m; + return last; + } + + private static readonly byte[] Padding = + { + 0x28, 0xBF, 0x4E, 0x5E, 0x4E, 0x75, 0x8A, 0x41, 0x64, 0x00, 0x4E, 0x56, 0xFF, 0xFA, 0x01, 0x08, + 0x2E, 0x2E, 0x00, 0xB6, 0xD0, 0x68, 0x3E, 0x80, 0x2F, 0x0C, 0xA9, 0xFE, 0x64, 0x53, 0x69, 0x7A + }; + } +} diff --git a/PdfSharpCore/Pdf.Internal/PdfEncoders.cs b/PdfSharpCore/Pdf.Internal/PdfEncoders.cs index 7c050919..aed8620f 100644 --- a/PdfSharpCore/Pdf.Internal/PdfEncoders.cs +++ b/PdfSharpCore/Pdf.Internal/PdfEncoders.cs @@ -306,6 +306,23 @@ public static byte[] FormatStringLiteral(byte[] bytes, bool unicode, bool prefix Debug.Assert(!unicode || bytes.Length % 2 == 0, "Odd number of bytes in Unicode string."); + // The byte order mark belongs to the value of the string, not to its syntax, so it has to + // be part of what gets encrypted. Written outside the ciphertext it would be decrypted as + // if it were text, which shifts everything after it and leaves the whole string unreadable + // to every reader but this one. Putting it into the bytes here writes the same characters + // as before when nothing is encrypted. + int byteOrderMarkLength = 0; + if (unicode && prefix) + { + var withByteOrderMark = new byte[bytes.Length + 2]; + withByteOrderMark[0] = 0xFE; + withByteOrderMark[1] = 0xFF; + Array.Copy(bytes, 0, withByteOrderMark, 2, bytes.Length); + bytes = withByteOrderMark; + byteOrderMarkLength = 2; + prefix = false; + } + bool encrypted = false; if (securityHandler != null) { @@ -405,7 +422,10 @@ public static byte[] FormatStringLiteral(byte[] bytes, bool unicode, bool prefix for (int idx = 0; idx < count; idx += 2) { pdf.AppendFormat("{0:X2}{1:X2}", bytes[idx], bytes[idx + 1]); - if (idx != 0 && (idx % 48) == 0) + // The mark is part of the bytes now, so count from the text that follows it + // and the lines break where they always did. + int positionInText = idx - byteOrderMarkLength; + if (positionInText != 0 && (positionInText % 48) == 0) pdf.Append("\n"); } pdf.Append(">"); diff --git a/PdfSharpCore/Pdf/PdfString.cs b/PdfSharpCore/Pdf/PdfString.cs index 2ed2acbe..216d3b66 100644 --- a/PdfSharpCore/Pdf/PdfString.cs +++ b/PdfSharpCore/Pdf/PdfString.cs @@ -200,7 +200,8 @@ internal PdfStringFlags Flags { get { return _flags; } } - readonly PdfStringFlags _flags; + // Not readonly: decrypting a string can reveal that it is UTF-16BE, see EncryptionValue. + PdfStringFlags _flags; /// /// Gets the string value. @@ -221,6 +222,17 @@ internal byte[] EncryptionValue // BUG: May lead to trouble with the value semantics of PdfString set { + // A text string keeps its byte order mark inside the encrypted bytes, so whether it is + // UTF-16BE only becomes apparent once it has been decrypted. The flags were decided by + // the lexer, which saw the ciphertext and had nothing to go by, so the mark decides + // here and the flags are corrected to match what the value now holds. + if (value.Length >= 2 && value[0] == 0xFE && value[1] == 0xFF) + { + _value = PdfEncoders.RawUnicodeEncoding.GetString(value, 2, value.Length - 2); + _flags = (_flags & ~PdfStringFlags.EncodingMask) | PdfStringFlags.Unicode; + return; + } + var encoding = (PdfStringEncoding)(_flags & PdfStringFlags.EncodingMask); switch (encoding) { diff --git a/PdfSharpCore/Pdf/PdfStringObject.cs b/PdfSharpCore/Pdf/PdfStringObject.cs index 4ec22880..aae3bd91 100644 --- a/PdfSharpCore/Pdf/PdfStringObject.cs +++ b/PdfSharpCore/Pdf/PdfStringObject.cs @@ -123,9 +123,29 @@ public string Value /// internal byte[] EncryptionValue { - // TODO: Unicode case is not handled! - get { return _value == null ? new byte[0] : PdfEncoders.RawEncoding.GetBytes(_value); } - set { _value = PdfEncoders.RawEncoding.GetString(value, 0, value.Length); } + get + { + if (_value == null) + return new byte[0]; + return Encoding == PdfStringEncoding.Unicode + ? PdfEncoders.RawUnicodeEncoding.GetBytes(_value) + : PdfEncoders.RawEncoding.GetBytes(_value); + } + set + { + // As in PdfString: the byte order mark is inside the encrypted bytes, so it is only + // after decrypting that a string can be seen to be UTF-16BE. + if (value.Length >= 2 && value[0] == 0xFE && value[1] == 0xFF) + { + _value = PdfEncoders.RawUnicodeEncoding.GetString(value, 2, value.Length - 2); + Encoding = PdfStringEncoding.Unicode; + return; + } + + _value = Encoding == PdfStringEncoding.Unicode + ? PdfEncoders.RawUnicodeEncoding.GetString(value, 0, value.Length) + : PdfEncoders.RawEncoding.GetString(value, 0, value.Length); + } } ///