From ece998672b97709ff3d637e3294dfc4ff4fb159e Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Mon, 22 Jun 2026 23:07:44 +0200 Subject: [PATCH] Cover encryption param parsing (crypt) encryptParamsFromDict and initEncrypt were only exercised for the V2/R3 RC4 case. Add coverage at the Open() surface: - a V4/R4 fixture using a /CF StdCF crypt filter (CFM /V2), decrypting a stream end-to-end through the /CF + /StmF parsing path; - rejection of a non-Standard security handler; - a malformed-/Encrypt sweep (wrong type, missing /V and /R, short O/U, short V5 OE/UE/Perms) that must error, never panic. Factor the shared PDF assembly out of buildRC4EncryptedStreamPDF so the V2 and V4 builders reuse it. initEncrypt 78.9% -> 89.5%; encryptParamsFromDict 65.9% -> 95.1%. --- crypt_test.go | 130 +++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 113 insertions(+), 17 deletions(-) diff --git a/crypt_test.go b/crypt_test.go index c0262f5..ba3aea2 100644 --- a/crypt_test.go +++ b/crypt_test.go @@ -129,18 +129,10 @@ func rc4Crypt(key, data []byte) []byte { return out } -// buildRC4EncryptedStreamPDF builds a V2/R3 RC4-encrypted PDF (empty password) -// whose object 4 is a stream carrying RC4-encrypted plaintext. -func buildRC4EncryptedStreamPDF(t *testing.T, plaintext []byte) []byte { - t.Helper() - owner := bytes.Repeat([]byte{0x5a}, 32) - id0 := bytes.Repeat([]byte{0x7c}, 16) - const bits = 128 - var p int32 = -44 - fileKey := emptyPwRC4Key(owner, id0, p, bits) - u := emptyPwU(fileKey, id0) - enc := rc4Crypt(objKeyRC4(fileKey, 4, 0), plaintext) - +// assembleEncryptedPDF builds a classical-xref PDF — catalog (1), pages (2), +// the given /Encrypt dict body (3), and an already-encrypted stream (4) — with +// the trailer wired to /Encrypt 3 0 R and /ID [id0 id0]. +func assembleEncryptedPDF(encryptBody string, encStream, id0 []byte) []byte { var buf bytes.Buffer off := func() int { return buf.Len() } fmt.Fprint(&buf, "%PDF-1.7\n%\xE2\xE3\xCF\xD3\n") @@ -150,12 +142,10 @@ func buildRC4EncryptedStreamPDF(t *testing.T, plaintext []byte) []byte { offsets[2] = off() fmt.Fprint(&buf, "2 0 obj\n<< /Type /Pages /Count 0 /Kids [] >>\nendobj\n") offsets[3] = off() - fmt.Fprintf(&buf, - "3 0 obj\n<< /Filter /Standard /V 2 /R 3 /Length %d /O <%s> /U <%s> /P %d >>\nendobj\n", - bits, hex.EncodeToString(owner), hex.EncodeToString(u), p) + fmt.Fprintf(&buf, "3 0 obj\n%s\nendobj\n", encryptBody) offsets[4] = off() - fmt.Fprintf(&buf, "4 0 obj\n<< /Length %d >>\nstream\n", len(enc)) - buf.Write(enc) + fmt.Fprintf(&buf, "4 0 obj\n<< /Length %d >>\nstream\n", len(encStream)) + buf.Write(encStream) fmt.Fprint(&buf, "\nendstream\nendobj\n") xrefOff := off() @@ -171,6 +161,40 @@ func buildRC4EncryptedStreamPDF(t *testing.T, plaintext []byte) []byte { return buf.Bytes() } +// buildRC4EncryptedStreamPDF builds a V2/R3 RC4-encrypted PDF (empty password) +// whose object 4 is a stream carrying RC4-encrypted plaintext. +func buildRC4EncryptedStreamPDF(t *testing.T, plaintext []byte) []byte { + t.Helper() + owner := bytes.Repeat([]byte{0x5a}, 32) + id0 := bytes.Repeat([]byte{0x7c}, 16) + const bits = 128 + var p int32 = -44 + fileKey := emptyPwRC4Key(owner, id0, p, bits) + u := emptyPwU(fileKey, id0) + enc := rc4Crypt(objKeyRC4(fileKey, 4, 0), plaintext) + body := fmt.Sprintf("<< /Filter /Standard /V 2 /R 3 /Length %d /O <%s> /U <%s> /P %d >>", + bits, hex.EncodeToString(owner), hex.EncodeToString(u), p) + return assembleEncryptedPDF(body, enc, id0) +} + +// buildV4RC4EncryptedStreamPDF builds a V4/R4 PDF whose StdCF crypt filter uses +// CFM /V2 (RC4) — same empty-password key derivation as V2/R3, reached through +// the V4 /CF + /StmF parsing path. +func buildV4RC4EncryptedStreamPDF(t *testing.T, plaintext []byte) []byte { + t.Helper() + owner := bytes.Repeat([]byte{0x5a}, 32) + id0 := bytes.Repeat([]byte{0x7c}, 16) + const bits = 128 + var p int32 = -44 + fileKey := emptyPwRC4Key(owner, id0, p, bits) + u := emptyPwU(fileKey, id0) + enc := rc4Crypt(objKeyRC4(fileKey, 4, 0), plaintext) + body := fmt.Sprintf("<< /Filter /Standard /V 4 /R 4 /Length %d /O <%s> /U <%s> /P %d "+ + "/CF << /StdCF << /CFM /V2 /Length 16 >> >> /StmF /StdCF /StrF /StdCF /EncryptMetadata true >>", + bits, hex.EncodeToString(owner), hex.EncodeToString(u), p) + return assembleEncryptedPDF(body, enc, id0) +} + // Open must accept an RC4-encrypted PDF secured with the empty user password // and decrypt its stream content end-to-end. func TestOpenDecryptsRC4Stream(t *testing.T) { @@ -189,3 +213,75 @@ func TestOpenDecryptsRC4Stream(t *testing.T) { t.Fatalf("decrypted stream mismatch:\n got %q\nwant %q", got, plaintext) } } + +// The V4 path resolves the stream cipher through /CF + /StmF rather than /V +// directly, so it must be exercised end-to-end too. +func TestOpenDecryptsV4RC4Stream(t *testing.T) { + plaintext := []byte("BT (V4 crypt filter) Tj ET") + data := buildV4RC4EncryptedStreamPDF(t, plaintext) + r, err := Open(bytes.NewReader(data)) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer r.Close() + got, err := r.DecodeStream(Reference{Number: 4, Generation: 0}) + if err != nil { + t.Fatalf("DecodeStream: %v", err) + } + if !bytes.Equal(got, plaintext) { + t.Fatalf("V4 decrypted stream mismatch:\n got %q\nwant %q", got, plaintext) + } +} + +// buildPDFWithEncryptObj wraps an arbitrary /Encrypt dict body as object 3 of a +// classical-xref PDF, with the trailer pointing /Encrypt at it. +func buildPDFWithEncryptObj(t *testing.T, encryptBody string) []byte { + t.Helper() + var buf bytes.Buffer + off := func() int { return buf.Len() } + fmt.Fprint(&buf, "%PDF-1.7\n%\xE2\xE3\xCF\xD3\n") + offsets := make([]int, 4) + offsets[1] = off() + fmt.Fprint(&buf, "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n") + offsets[2] = off() + fmt.Fprint(&buf, "2 0 obj\n<< /Type /Pages /Count 0 /Kids [] >>\nendobj\n") + offsets[3] = off() + fmt.Fprintf(&buf, "3 0 obj\n%s\nendobj\n", encryptBody) + xrefOff := off() + fmt.Fprint(&buf, "xref\n0 4\n") + fmt.Fprintf(&buf, "%010d %05d f \n", 0, 65535) + for i := 1; i <= 3; i++ { + fmt.Fprintf(&buf, "%010d %05d n \n", offsets[i], 0) + } + id := "<00112233445566778899aabbccddeeff>" + fmt.Fprintf(&buf, "trailer\n<< /Size 4 /Root 1 0 R /Encrypt 3 0 R /ID [%s %s] >>\n", id, id) + fmt.Fprintf(&buf, "startxref\n%d\n%%%%EOF\n", xrefOff) + return buf.Bytes() +} + +// Only the /Standard security handler is supported; any other /Filter must be +// rejected rather than silently treated as unencrypted. +func TestEncryptNonStandardFilterRejected(t *testing.T) { + data := buildPDFWithEncryptObj(t, "<< /Filter /FooSecurity /V 2 /R 3 /Length 128 >>") + if _, err := Open(bytes.NewReader(data)); err == nil { + t.Fatal("expected an error for a non-Standard /Filter, got nil") + } +} + +// Malformed /Encrypt dictionaries (wrong type, missing fields, short V5 entries) +// must surface as errors during Open, never panics. +func TestEncryptMalformedNoPanic(t *testing.T) { + cases := map[string]string{ + "encrypt not a dict": "42", + "missing V and R": "<< /Filter /Standard >>", + "short O and U": "<< /Filter /Standard /V 2 /R 3 /Length 128 /O <00> /U <00> /P 0 >>", + "v5 short entries": "<< /Filter /Standard /V 5 /R 6 /Length 256 /O <00> /U <00> /OE <00> /UE <00> /Perms <00> /P 0 >>", + } + for name, body := range cases { + t.Run(name, func(t *testing.T) { + if _, err := Open(bytes.NewReader(buildPDFWithEncryptObj(t, body))); err == nil { + t.Fatal("expected an error, got nil") + } + }) + } +}