P5a plan: PDF documents — seven Ornith tasks with acceptance tests first

docs/plans/p5a: 01 kind+fetch limits, 02 pdftext child + runner, 03 dcache PDF tables,
04 pdfpool, 05 pdfdoc render (map/pages/grep), 06 api, 07 digest citations, metrics, CLI,
skill, unit, README. Given tests compile against a panic-only skeleton and fail there for the
intended reasons; PDFs for tests are built by internal/pdftest (given), plus two qpdf-encrypted
fixtures.

Owner changes for the run: vendor go-pdfkit/extract v0.3.0 + reader v0.6.0 (pinned via
tools.go until task 02); AGENTS.md, tools/run-plan.sh (enforces a Co-Authored-By trailer for
the model), docs/implementer-log.md, `make gate`; gofmt (whitespace) on clean.go and guard;
TestUnsupportedType uses a zip now that PDFs are supported; requestDeadline 90→210 s and
writeTimeout 120→240 s for the PDF path's worst case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 20:47:38 -07:00
co-authored by Claude Opus 5.5
parent 3f2b78d4aa
commit cfde35397a
115 changed files with 26422 additions and 43 deletions
+365
View File
@@ -0,0 +1,365 @@
package reader
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/md5"
"crypto/rand"
"fmt"
"strings"
)
// Permissions say what a reader may do with a file it can open with the user
// password. They are advisory — nothing enforces them but the reader's own
// good manners — and a file opened with the owner password ignores them
// entirely.
type Permissions uint32
// The permissions a standard security handler can express. Anything not
// granted is refused; [AllPermissions] grants everything.
const (
PermPrint Permissions = 1 << 2
PermModify Permissions = 1 << 3
PermCopy Permissions = 1 << 4
PermAnnotate Permissions = 1 << 5
PermFillForms Permissions = 1 << 8
PermExtract Permissions = 1 << 9 // for accessibility
PermAssemble Permissions = 1 << 10
PermPrintFaithful Permissions = 1 << 11 // at full resolution
// AllPermissions grants every one of them.
AllPermissions = PermPrint | PermModify | PermCopy | PermAnnotate |
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
// An Encryption says how a file is to be protected.
//
// Two people can open it: whoever knows the user password, subject to the
// permissions, and whoever knows the owner password, subject to nothing. An
// empty user password means the file opens without one and the permissions are
// all it says.
type Encryption struct {
UserPassword string
OwnerPassword string
Permissions Permissions
// AES128 asks for the older method, which readers before 2008 understand.
// The default is AES-256, which is what a file should use today.
AES128 bool
}
// Encrypt protects everything written from here on. It must be called before
// anything is written, and the file it produces is not byte-for-byte
// reproducible: encryption needs randomness, by design.
func (w *Writer) Encrypt(e Encryption) {
if len(w.offsets) > 0 || len(w.pending) > 0 {
w.note(fmt.Errorf("reader: a file cannot be encrypted after it has been written to"))
return
}
enc, err := newEncrypter(e)
if err != nil {
w.note(err)
return
}
w.encrypt = enc
}
// An encrypter holds the file key and the dictionary that describes it.
type encrypter struct {
key []byte
method cryptMethod
dict Dict
id []byte
err error
// number is the object the /Encrypt dictionary is written under, which is
// the one thing in the file that is never encrypted.
number int
}
// newEncrypter derives a file key and builds the dictionary that lets a reader
// derive it again from a password.
func newEncrypter(e Encryption) (*encrypter, error) {
perm := permissionBase | uint32(e.Permissions)
id, err := randomBytes(16)
if err != nil {
return nil, err
}
if e.AES128 {
return newLegacyEncrypter(e, perm, id)
}
return newAES256Encrypter(e, perm, id)
}
// newAES256Encrypter builds the revision 6 form: a random file key, wrapped
// twice, once for each password.
func newAES256Encrypter(e Encryption, perm uint32, id []byte) (*encrypter, error) {
key, err := randomBytes(32)
if err != nil {
return nil, err
}
// An owner password nobody set is the user's: leaving it empty would
// mean anything at all opens the file with every permission, which is
// the opposite of what asking for encryption means.
ownerPassword := e.OwnerPassword
if ownerPassword == "" {
ownerPassword = e.UserPassword
}
user, userE, err := wrapKey([]byte(e.UserPassword), key, nil)
if err != nil {
return nil, err
}
owner, ownerE, err := wrapKey([]byte(ownerPassword), key, user)
if err != nil {
return nil, err
}
perms, err := permsEntry(key, perm)
if err != nil {
return nil, err
}
return &encrypter{
key: key,
method: cryptAESV3,
id: id,
dict: Dict{
"Filter": Name("Standard"),
"V": Integer(5),
"R": Integer(6),
"Length": Integer(256),
"P": Integer(int32(perm)),
"U": String(user),
"UE": String(userE),
"O": String(owner),
"OE": String(ownerE),
"Perms": String(perms),
"CF": Dict{"StdCF": Dict{"CFM": Name("AESV3"), "Length": Integer(32)}},
"StmF": Name("StdCF"),
"StrF": Name("StdCF"),
"EncryptMetadata": Bool(true),
},
}, nil
}
// wrapKey builds one 48-byte password entry and the 32 bytes that hold the
// file key wrapped for it.
func wrapKey(password, key, udata []byte) (entry, wrapped []byte, err error) {
salts, err := randomBytes(16)
if err != nil {
return nil, nil, err
}
validation, keySalt := salts[:8], salts[8:]
entry = append(append(hash2B(password, validation, udata, 6), validation...), keySalt...)
// hash2B always returns 32 bytes, so the cipher cannot refuse the key.
block, _ := aes.NewCipher(hash2B(password, keySalt, udata, 6))
wrapped = make([]byte, 32)
cipher.NewCBCEncrypter(block, make([]byte, aes.BlockSize)).CryptBlocks(wrapped, key)
return entry, wrapped, nil
}
// permsEntry is the block that lets a reader check the permissions have not
// been tampered with: they are encrypted with the file key itself.
func permsEntry(key []byte, perm uint32) ([]byte, error) {
tail, err := randomBytes(4)
if err != nil {
return nil, err
}
plain := []byte{
byte(perm), byte(perm >> 8), byte(perm >> 16), byte(perm >> 24),
0xFF, 0xFF, 0xFF, 0xFF,
'T', // metadata is encrypted too
'a', 'd', 'b', // the specification's own marker
tail[0], tail[1], tail[2], tail[3],
}
// The file key is 32 bytes by construction.
block, _ := aes.NewCipher(key)
out := make([]byte, 16)
// One block, with no chaining: this is the one place the format uses it.
block.Encrypt(out, plain)
return out, nil
}
// newLegacyEncrypter builds the revision 4 form, whose key comes from the
// passwords rather than the other way round.
func newLegacyEncrypter(e Encryption, perm uint32, id []byte) (*encrypter, error) {
const n = 16 // 128 bits
owner := legacyOwnerValue([]byte(e.OwnerPassword), []byte(e.UserPassword), n)
key := legacyFileKey(padPassword([]byte(e.UserPassword)), owner, id, int32(perm), n, 4, true)
user := legacyUserValue(key, id)
return &encrypter{
key: key,
method: cryptAESV2,
id: id,
dict: Dict{
"Filter": Name("Standard"),
"V": Integer(4),
"R": Integer(4),
"Length": Integer(128),
"P": Integer(int32(perm)),
"O": String(owner),
"U": String(user),
"CF": Dict{"StdCF": Dict{"CFM": Name("AESV2"), "Length": Integer(16)}},
"StmF": Name("StdCF"),
"StrF": Name("StdCF"),
"EncryptMetadata": Bool(true),
},
}, nil
}
// legacyOwnerValue is algorithm 3: the /O entry, which holds the user password
// encrypted under the owner's.
func legacyOwnerValue(ownerPassword, userPassword []byte, n int) []byte {
if len(ownerPassword) == 0 {
ownerPassword = userPassword
}
sum := md5Sum(padPassword(ownerPassword))
key := sum
for i := 0; i < 50; i++ {
key = md5Sum(key)
}
key = key[:n]
x := padPassword(userPassword)
for i := 0; i <= 19; i++ {
x = rc4Bytes(xorKey(key, i), x)
}
return x
}
// legacyUserValue is algorithm 5: the /U entry.
func legacyUserValue(key, id []byte) []byte {
x := rc4Bytes(key, md5Sum(append(append([]byte{}, pad...), id...)))
for i := 1; i <= 19; i++ {
x = rc4Bytes(xorKey(key, i), x)
}
return append(x, make([]byte, 16)...)
}
// randomSource is where randomness comes from. It is a variable so a test
// can take it away and see that a file is refused rather than written
// without the protection it was asked for.
var randomSource = rand.Read
// randomBytes is the only source of randomness here, and the reason an
// encrypted file is not reproducible.
func randomBytes(n int) ([]byte, error) {
out := make([]byte, n)
if _, err := randomSource(out); err != nil {
return nil, fmt.Errorf("reader: no randomness available: %w", err)
}
return out, nil
}
// apply encrypts one object's strings and, for a stream, its data.
func (e *encrypter) apply(num, gen int, o Object) Object {
switch v := o.(type) {
case String:
return String(e.encryptBytes(num, gen, v))
case Array:
out := make(Array, len(v))
for i, x := range v {
out[i] = e.apply(num, gen, x)
}
return out
case Dict:
out := Dict{}
for k, x := range v {
out[k] = e.apply(num, gen, x)
}
return out
case *Stream:
out := &Stream{Dict: Dict{}, Raw: e.encryptBytes(num, gen, v.Raw)}
for k, x := range v.Dict {
out.Dict[k] = e.apply(num, gen, x)
}
return out
}
return o
}
// encryptBytes protects one object's bytes.
func (e *encrypter) encryptBytes(num, gen int, data []byte) []byte {
dec := &decryptor{key: e.key}
key := dec.objectKey(num, gen, e.method)
iv, err := randomBytes(aes.BlockSize)
if err != nil {
// Writing the bytes as they are would hand out in the clear what was
// asked to be protected, so the failure is remembered and the whole
// file is refused.
e.note(err)
return data
}
// The file key is 32 bytes by construction.
block, _ := aes.NewCipher(key)
// The padding CBC calls for, always at least one whole block of it.
n := aes.BlockSize - len(data)%aes.BlockSize
padded := append(append([]byte{}, data...), bytes.Repeat([]byte{byte(n)}, n)...)
out := make([]byte, len(padded))
cipher.NewCBCEncrypter(block, iv).CryptBlocks(out, padded)
return append(iv, out...)
}
// md5Sum is the hash the pre-2.0 handlers are built on.
func md5Sum(b []byte) []byte {
sum := md5.Sum(b)
return sum[:]
}
// writeEncryptDict writes the /Encrypt dictionary and returns the trailer
// entries a reader needs to find and use it. The dictionary is the one object
// in the file that is never encrypted, since it says how to decrypt the rest.
func (w *Writer) writeEncryptDict() Dict {
if w.encrypt == nil {
return nil
}
ref := w.Reserve()
w.encrypt.number = ref.Num
w.writeInlineRaw(ref, w.encrypt.dict)
// The identifier takes part in the older key derivation and has to be in
// the trailer for a reader to reach it.
id := String(w.encrypt.id)
return Dict{"Encrypt": ref, "ID": Array{id, id}}
}
// note keeps the first thing that went wrong while encrypting, so a file that
// could not be protected is refused rather than written in the clear.
func (e *encrypter) note(err error) {
if e.err == nil {
e.err = err
}
}