From b77784b8ab87ddda3b8a102a54feb9398ee62a61 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Tue, 25 Aug 2026 17:09:04 +0200 Subject: [PATCH] Say how a file is protected and what it allows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A document that opened has already been decrypted, and said nothing about what it had been protected with on the way. Document.Protection now reports it: the method in words — RC4-40, RC4-128, AES-128, AES-256, or none for a file that declares a security handler and then protects nothing with it — the handler's revision, the permissions the file grants, and whether the password it opened with was the owner's, in which case those permissions do not apply. Working out which password opened a file meant carrying that back from the two key derivations, so a legacy candidate now says whether reaching it meant knowing the owner's password rather than the user's. Permissions gains Allows and String, so that what a file grants can be put in front of a person as words rather than as a bit pattern. --- crypt.go | 92 +++++++++++++++++++++++++++++++++++++--------- crypt_test.go | 34 +++++++++++++++++ cryptbuild_test.go | 8 +++- encrypt.go | 34 +++++++++++++++++ encrypt_test.go | 90 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 239 insertions(+), 19 deletions(-) diff --git a/crypt.go b/crypt.go index b7cea0a..993cfe4 100644 --- a/crypt.go +++ b/crypt.go @@ -44,10 +44,58 @@ type decryptor struct { strings cryptMethod streams cryptMethod revision int + perm Permissions + owner bool // the password given was the owner's, not the user's skipObj int // the /Encrypt dictionary's own object number, never decrypted skipKnown bool // whether skipObj is meaningful } +// A Protection is what a file's security handler says about it: how it is +// encrypted, and what a reader that opened it with the user password may do. +type Protection struct { + // Method names the algorithm the way a person would say it: "RC4-40", + // "RC4-128", "AES-128", "AES-256", or "none" for a file that declares an + // /Encrypt dictionary and then encrypts nothing with it. + Method string + // Revision is the standard security handler's revision: 2, 3, 4, 5 or 6. + Revision int + // Permissions is what the file grants whoever opened it with the user + // password. + Permissions Permissions + // Owner is true when the password the file was opened with was the + // owner's, in which case the permissions do not apply to this reader. + Owner bool +} + +// Protection reports how the file is protected, and false when it is not +// protected at all. A document that opened has already been decrypted; this +// says what it said about itself on the way. +func (d *Document) Protection() (Protection, bool) { + if d.decrypt == nil { + return Protection{}, false + } + return Protection{ + Method: d.decrypt.methodName(), + Revision: d.decrypt.revision, + Permissions: d.decrypt.perm, + Owner: d.decrypt.owner, + }, true +} + +// methodName says which algorithm protects the streams, which is the one that +// matters: it is where the content is. +func (dec *decryptor) methodName() string { + switch dec.streams { + case cryptAESV3: + return "AES-256" + case cryptAESV2: + return "AES-128" + case cryptRC4: + return fmt.Sprintf("RC4-%d", len(dec.key)*8) + } + return "none" +} + // Encrypted reports whether the file declares an /Encrypt dictionary. func (d *Document) Encrypted() bool { return d.trailer != nil && d.trailer.Get("Encrypt").Kind() != KindNull @@ -120,23 +168,24 @@ func newDecryptor(enc Dict, id []byte, password string, r Resolver) (*decryptor, metadata = b } + dec.perm = Permissions(uint32(perm)) & AllPermissions if rev >= 5 { - key, err := deriveKeyR5(enc, password, r) + key, asOwner, err := deriveKeyR5(enc, password, r) if err != nil { return nil, err } - dec.key = key + dec.key, dec.owner = key, asOwner return dec, nil } n := length / 8 if rev == 2 { n = 5 } - key, err := deriveKeyLegacy(password, owner, user, id, perm, n, rev, metadata) + key, asOwner, err := deriveKeyLegacy(password, owner, user, id, perm, n, rev, metadata) if err != nil { return nil, err } - dec.key = key + dec.key, dec.owner = key, asOwner return dec, nil } @@ -181,25 +230,32 @@ func (dec *decryptor) readMethods(enc Dict, v int, r Resolver) error { // deriveKeyLegacy is the pre-2.0 key derivation, trying the password as the // user password and then as the owner password. -func deriveKeyLegacy(password string, owner, user, id []byte, perm int32, n, rev int, metadata bool) ([]byte, error) { +func deriveKeyLegacy(password string, owner, user, id []byte, perm int32, n, rev int, metadata bool) (key []byte, asOwner bool, err error) { for _, candidate := range legacyCandidates(password, owner, n, rev) { - key := legacyFileKey(candidate, owner, id, perm, n, rev, metadata) - if legacyUserKeyMatches(key, user, id, rev) { - return key, nil + k := legacyFileKey(candidate.padded, owner, id, perm, n, rev, metadata) + if legacyUserKeyMatches(k, user, id, rev) { + return k, candidate.owner, nil } } - return nil, ErrWrongPassword + return nil, false, ErrWrongPassword +} + +// A legacyCandidate is one padded password to try, and whether reaching it +// meant knowing the owner's rather than the user's. +type legacyCandidate struct { + padded []byte + owner bool } // legacyCandidates lists the padded passwords worth trying: the one given, the // empty one, and the user password the owner password unlocks. -func legacyCandidates(password string, owner []byte, n, rev int) [][]byte { - out := [][]byte{padPassword([]byte(password))} +func legacyCandidates(password string, owner []byte, n, rev int) []legacyCandidate { + out := []legacyCandidate{{padded: padPassword([]byte(password))}} if password != "" { - out = append(out, padPassword(nil)) + out = append(out, legacyCandidate{padded: padPassword(nil)}) } if u := userFromOwner([]byte(password), owner, n, rev); u != nil { - out = append(out, u) + out = append(out, legacyCandidate{padded: u, owner: true}) } return out } @@ -284,14 +340,14 @@ func xorKey(key []byte, v int) []byte { // deriveKeyR5 is the PDF 2.0 derivation, /R 5 and /R 6: the password is // validated against a salted hash and then unwraps the file key. -func deriveKeyR5(enc Dict, password string, r Resolver) ([]byte, error) { +func deriveKeyR5(enc Dict, password string, r Resolver) (key []byte, asOwner bool, err error) { user, _ := ToString(resolved(enc, "U", r)) userE, _ := ToString(resolved(enc, "UE", r)) owner, _ := ToString(resolved(enc, "O", r)) ownerE, _ := ToString(resolved(enc, "OE", r)) rev := int(intOf(resolved(enc, "R", r), 6)) if len(user) < 48 { - return nil, fmt.Errorf("reader: /U is %d bytes, not the 48 this revision needs", len(user)) + return nil, false, fmt.Errorf("reader: /U is %d bytes, not the 48 this revision needs", len(user)) } pw := []byte(password) for _, candidate := range [][]byte{pw, nil} { @@ -299,15 +355,15 @@ func deriveKeyR5(enc Dict, password string, r Resolver) ([]byte, error) { break } if key := unlockR5(candidate, user, userE, nil, rev); key != nil { - return key, nil + return key, false, nil } if len(owner) >= 48 { if key := unlockR5(candidate, owner, ownerE, user[:48], rev); key != nil { - return key, nil + return key, true, nil } } } - return nil, ErrWrongPassword + return nil, false, ErrWrongPassword } // unlockR5 checks one password against a 48-byte /U or /O entry and, when it diff --git a/crypt_test.go b/crypt_test.go index b8ce166..56b8141 100644 --- a/crypt_test.go +++ b/crypt_test.go @@ -370,3 +370,37 @@ func TestDecryptAfterAnOffsetRepair(t *testing.T) { t.Errorf("/Producer = %q, want %q", s, encryptedProducer) } } + +func TestTheMethodAFileIsProtectedWithIsNamedInWords(t *testing.T) { + // Every method the reader understands, including the older ones no + // writer here produces, and a file that declares a handler and then + // protects nothing with it. + cases := []struct { + name string + opts encOptions + want string + }{ + {"40-bit RC4", encOptions{v: 1, r: 2, length: 40}, "RC4-40"}, + {"128-bit RC4", encOptions{v: 2, r: 3, length: 128}, "RC4-128"}, + {"RC4 through a crypt filter", encOptions{v: 4, r: 4, length: 128, method: cryptRC4}, "RC4-128"}, + {"AES-128", encOptions{v: 4, r: 4, length: 128, method: cryptAESV2}, "AES-128"}, + {"AES-256", encOptions{v: 5, r: 6, length: 256, method: cryptAESV3}, "AES-256"}, + {"a handler that protects nothing", encOptions{v: 4, r: 4, length: 128, method: cryptNone}, "none"}, + } + for _, c := range cases { + d, err := Open(encryptedFile(t, c.opts)) + if err != nil { + t.Fatalf("%s: %v", c.name, err) + } + p, ok := d.Protection() + if !ok { + t.Fatalf("%s: reported as unprotected", c.name) + } + if p.Method != c.want { + t.Errorf("%s: method %q, want %q", c.name, p.Method, c.want) + } + if p.Revision != c.opts.r { + t.Errorf("%s: revision %d, want %d", c.name, p.Revision, c.opts.r) + } + } +} diff --git a/cryptbuild_test.go b/cryptbuild_test.go index d0168a2..0ad1f2e 100644 --- a/cryptbuild_test.go +++ b/cryptbuild_test.go @@ -197,8 +197,14 @@ func encryptedFile(t *testing.T, opt encOptions) []byte { meta = "false" } cfm := "V2" - if enc.method == cryptAESV2 { + switch enc.method { + case cryptAESV2: cfm = "AESV2" + case cryptNone: + // A file that declares a security handler and then protects + // nothing with it: rare, legal, and something a person asking + // what a file is protected with deserves to be told. + cfm = "None" } if opt.v >= 4 { encDict = fmt.Sprintf( diff --git a/encrypt.go b/encrypt.go index a5fc672..613550d 100644 --- a/encrypt.go +++ b/encrypt.go @@ -7,6 +7,7 @@ import ( "crypto/md5" "crypto/rand" "fmt" + "strings" ) // Permissions say what a reader may do with a file it can open with the user @@ -32,6 +33,39 @@ const ( PermFillForms | PermExtract | PermAssemble | PermPrintFaithful ) +// permissionNames pairs each permission with what it lets a reader do, in the +// order a person would want to read them. +var permissionNames = []struct { + bit Permissions + name string +}{ + {PermPrint, "print"}, + {PermPrintFaithful, "print at full resolution"}, + {PermModify, "modify"}, + {PermAssemble, "assemble"}, + {PermCopy, "copy"}, + {PermExtract, "extract for accessibility"}, + {PermAnnotate, "annotate"}, + {PermFillForms, "fill in forms"}, +} + +// Allows reports whether every one of the given permissions is granted. +func (p Permissions) Allows(want Permissions) bool { return p&want == want } + +// String lists what is granted, in words. +func (p Permissions) String() string { + var out []string + for _, e := range permissionNames { + if p.Allows(e.bit) { + out = append(out, e.name) + } + } + if len(out) == 0 { + return "nothing" + } + return strings.Join(out, ", ") +} + // permissionBase is the pattern of reserved bits the specification requires // around the permissions themselves. const permissionBase uint32 = 0xFFFFF0C0 diff --git a/encrypt_test.go b/encrypt_test.go index 8a2010f..609e3bb 100644 --- a/encrypt_test.go +++ b/encrypt_test.go @@ -363,3 +363,93 @@ func TestRandomnessRunningOutAtEachStep(t *testing.T) { }) } } + +func TestWhatAProtectedFileSaysAboutItself(t *testing.T) { + // A file that opened has already been decrypted; what it said about + // itself on the way is what a person needs to be told. + cases := []struct { + name string + enc Encryption + open string + wantMethod string + wantRev int + wantOwner bool + }{ + {"AES-256 opened by the user", Encryption{ + UserPassword: "u", OwnerPassword: "o", Permissions: PermPrint, + }, "u", "AES-256", 6, false}, + {"AES-256 opened by the owner", Encryption{ + UserPassword: "u", OwnerPassword: "o", Permissions: PermPrint, + }, "o", "AES-256", 6, true}, + {"AES-128 opened by the user", Encryption{ + UserPassword: "u", OwnerPassword: "o", Permissions: PermPrint | PermCopy, AES128: true, + }, "u", "AES-128", 4, false}, + {"AES-128 opened by the owner", Encryption{ + UserPassword: "u", OwnerPassword: "o", Permissions: PermPrint | PermCopy, AES128: true, + }, "o", "AES-128", 4, true}, + } + for _, c := range cases { + out := protectedFile(t, false, c.enc) + d, err := OpenWithPassword(out, c.open) + if err != nil { + t.Fatalf("%s: %v", c.name, err) + } + p, ok := d.Protection() + if !ok { + t.Fatalf("%s: an encrypted file says it is not protected", c.name) + } + if p.Method != c.wantMethod { + t.Errorf("%s: method %q, want %q", c.name, p.Method, c.wantMethod) + } + if p.Revision != c.wantRev { + t.Errorf("%s: revision %d, want %d", c.name, p.Revision, c.wantRev) + } + if p.Owner != c.wantOwner { + t.Errorf("%s: opened as owner = %v, want %v", c.name, p.Owner, c.wantOwner) + } + if p.Permissions != c.enc.Permissions { + t.Errorf("%s: permissions %v, want %v", c.name, p.Permissions, c.enc.Permissions) + } + } + + // A file with nothing to hide says so. + w := NewWriter("1.7") + pagesRef := w.Reserve() + page := w.Add(Dict{"Type": Name("Page"), "Parent": pagesRef}) + w.Put(pagesRef, Dict{"Type": Name("Pages"), "Kids": Array{page}, "Count": Integer(1)}) + root := w.Add(Dict{"Type": Name("Catalog"), "Pages": pagesRef}) + plain, err := w.Finish(Dict{"Root": root}) + if err != nil { + t.Fatal(err) + } + d, err := Open(plain) + if err != nil { + t.Fatal(err) + } + if p, ok := d.Protection(); ok { + t.Errorf("an unencrypted file reported %+v", p) + } +} + +func TestPermissionsInWords(t *testing.T) { + cases := []struct { + perm Permissions + want string + }{ + {0, "nothing"}, + {PermPrint, "print"}, + {PermPrint | PermCopy, "print, copy"}, + {AllPermissions, "print, print at full resolution, modify, assemble, copy, extract for accessibility, annotate, fill in forms"}, + } + for _, c := range cases { + if got := c.perm.String(); got != c.want { + t.Errorf("%d: %q, want %q", uint32(c.perm), got, c.want) + } + } + if !AllPermissions.Allows(PermPrint | PermCopy) { + t.Error("everything does not allow printing and copying") + } + if (PermPrint).Allows(PermPrint | PermCopy) { + t.Error("printing alone allowed copying") + } +}