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);
+ }
}
///