pdfrb 0.8.6 → 0.8.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 84368b352e856470e7d3f3374071df7724c21d5dba77596842ac5bacc419db59
4
- data.tar.gz: c0bfb8c0a8803aa74ecf43d1e3e35ff18b17bc457582705c409d8fae89b8f50f
3
+ metadata.gz: af5b6aea283bcc7325c584008268aaedc228f78882cb5408b615c55576cb8154
4
+ data.tar.gz: 29c3a794f7829144e4fa2275ba4a7b4124087abd7279f076689b06d14d5ac4c3
5
5
  SHA512:
6
- metadata.gz: 4d6aec972d3b32bc66f70098ab1122472fef43c675d4eaba1b77cb4c3b74231a515f9ad877d648c8b28d3dc874b55475327b20ade2b2dcc85d70e3e15712fdbc
7
- data.tar.gz: 65fd26f6e44e1bcaa18471e86662f53e46ae4c2ac9a91a82fb182adbb10c7ef6616e6ab5505379dbc42c414a6684f848864648c486567a59e2f9aa695de67f57
6
+ metadata.gz: 6491c0a75e0ef6eb6fa849fdf25de0d546c2055b099e3f533595a073e370a30c37693604b31620e5d3707237e1960f18f6ba7ee05555698c21b081e6300cf493
7
+ data.tar.gz: 569d900685cc748edba60dfcdc426987343025c4ccae008a715b188ce41ce9d56abf29901debb39e1292d856ba34951394b85a57efb297ac9b5af0b67f706eb6
data/lib/pdfrb/cli.rb CHANGED
@@ -325,19 +325,26 @@ module Pdfrb
325
325
  )
326
326
  end
327
327
 
328
+ cf_entries = Pdfrb::Encryption.v4_crypt_filters(v)
328
329
  encrypt_dict = doc.add(
329
330
  { Filter: :Standard, V: v, R: r, Length: length, P: perms,
330
- O: o_entry, U: u_entry },
331
+ O: o_entry, U: u_entry, **cf_entries },
331
332
  type: Pdfrb::Model::Cos::Dictionary
332
333
  )
333
334
  doc.trailer[:Encrypt] = encrypt_dict.ref
334
335
 
335
336
  # Set up the handler so Writer can encrypt per-object.
336
337
  handler = Pdfrb::Encryption::StandardSecurityHandler.new(
337
- Encrypt: { V: v, R: r, Length: length, P: perms, O: o_entry, U: u_entry },
338
- ID: [id0.b, id0.b]
338
+ {
339
+ Encrypt: { V: v, R: r, Length: length, P: perms,
340
+ O: o_entry, U: u_entry, **cf_entries },
341
+ ID: [id0.b, id0.b],
342
+ }
339
343
  )
340
- handler.verify_user_password(user_pw)
344
+ unless handler.verify_user_password?(user_pw)
345
+ raise Pdfrb::EncryptionError, "failed to derive encryption key"
346
+ end
347
+
341
348
  doc.config["encryption.handler"] = handler
342
349
  doc.config["encryption.password"] = user_pw
343
350
  end
@@ -13,16 +13,17 @@ module Pdfrb
13
13
  autoload :TimestampHandler, "pdfrb/digital_signature/timestamp_handler"
14
14
  autoload :TimestampClient, "pdfrb/digital_signature/timestamp_client"
15
15
 
16
- HANDLERS = {
17
- "adbe.pkcs7.detached": CmsHandler,
18
- "adbe.pkcs7.sha1": CmsHandler,
19
- "adbe.revision": TimestampHandler,
20
- }.freeze
21
-
22
16
  module_function
23
17
 
18
+ # Resolve the handler class for +sub_filter+. Constant references
19
+ # live inside the case so the autoloads above defer loading the
20
+ # handlers (and their OpenSSL surface) until an actual
21
+ # sign/verify call needs them.
24
22
  def handler_for(sub_filter)
25
- HANDLERS[sub_filter.to_sym]
23
+ case sub_filter.to_sym
24
+ when :"adbe.pkcs7.detached", :"adbe.pkcs7.sha1" then CmsHandler
25
+ when :"adbe.revision" then TimestampHandler
26
+ end
26
27
  end
27
28
 
28
29
  def sign(document, cert:, key:, **)
@@ -65,6 +65,30 @@ module Pdfrb
65
65
  !trailer.nil? && !trailer[:Encrypt].nil?
66
66
  end
67
67
 
68
+ # Handler for reading an encrypted document: memoized, built
69
+ # from the trailer /Encrypt, verifying the configured password
70
+ # (config "encryption.password"). Returns nil for unencrypted
71
+ # documents. Raises EncryptionError when the password does not
72
+ # verify — the reader must never hand back ciphertext as
73
+ # plaintext.
74
+ def reader_handler
75
+ return nil unless encrypted?
76
+
77
+ @reader_handler ||= begin
78
+ trailer = document.trailer
79
+ encrypt_dict = document.resolve(trailer[:Encrypt])
80
+ handler = Pdfrb::Encryption::StandardSecurityHandler.new(
81
+ { Encrypt: encrypt_dict.value, ID: trailer[:ID] }
82
+ )
83
+ password = document.config["encryption.password"] || ""
84
+ unless handler.verify_user_password?(password)
85
+ raise Pdfrb::EncryptionError,
86
+ "document is encrypted and the password does not verify"
87
+ end
88
+ handler
89
+ end
90
+ end
91
+
68
92
  # Remove /Encrypt from the trailer (decrypt use case). Returns
69
93
  # self for chaining; use encrypted? to check the result.
70
94
  def decrypt!
@@ -78,6 +102,7 @@ module Pdfrb
78
102
  document.config["encryption.handler"] = nil
79
103
  document.config["encryption.password"] = nil
80
104
  end
105
+ @reader_handler = nil
81
106
  self
82
107
  end
83
108
 
@@ -123,8 +148,9 @@ module Pdfrb
123
148
  )
124
149
  end
125
150
 
151
+ cf_entries = Pdfrb::Encryption.v4_crypt_filters(v)
126
152
  register({ Filter: :Standard, V: v, R: r, Length: length,
127
- P: p_bits, O: o_entry, U: u_entry },
153
+ P: p_bits, O: o_entry, U: u_entry, **cf_entries },
128
154
  user_password: user_pw)
129
155
  end
130
156
 
@@ -193,7 +219,10 @@ module Pdfrb
193
219
  trailer[:ID]
194
220
  end }
195
221
  )
196
- h.verify_user_password?(user_password)
222
+ unless h.verify_user_password?(user_password)
223
+ raise Pdfrb::EncryptionError,
224
+ "derived key does not verify against the /U entry"
225
+ end
197
226
  h
198
227
  end
199
228
  document.config["encryption.handler"] = handler
@@ -7,6 +7,10 @@ module Pdfrb
7
7
 
8
8
  def encrypt(data, **_opts); data; end
9
9
  def decrypt(data, **_opts); data; end
10
+ def encrypt_string(data, *_); data; end
11
+ def encrypt_stream(data, *_); data; end
12
+ def decrypt_string(data, *_); data; end
13
+ def decrypt_stream(data, *_); data; end
10
14
  def key_length; 0; end
11
15
  end
12
16
  end
@@ -99,22 +99,26 @@ module Pdfrb
99
99
  rc4.process(pack_bytes(PADDING_BYTES))
100
100
  end
101
101
 
102
- # Algorithm 5 (R>=3): build the /U entry.
103
- def build_u_r3plus(file_key:, id0:, revision:)
102
+ # Algorithm 5 (R>=3): the /U entry. The 19 additional RC4
103
+ # rounds use the file key XORed with the round index, for
104
+ # i = 1..19 in ascending order.
105
+ def user_password_hash_r3plus(file_key:, id0:)
104
106
  digest = Digest::MD5.new
105
107
  digest.update(pack_bytes(PADDING_BYTES))
106
108
  digest.update(id0.b)
107
109
  hash = digest.digest
108
- rc4 = RC4.new(file_key)
109
- encrypted = rc4.process(hash)
110
- # 19 more rounds with key XORed by round index.
111
- 19.times do |i|
110
+ hash = RC4.new(file_key).process(hash)
111
+ (1..19).each do |i|
112
112
  key = file_key.bytes.map { |b| (b ^ i).chr }.join
113
- encrypted = RC4.new(key).process(encrypted)
113
+ hash = RC4.new(key).process(hash)
114
114
  end
115
- # Pad / truncate to 32 bytes.
116
- encrypted = encrypted + ("\x00" * (32 - encrypted.bytesize))
117
- encrypted.byteslice(0, 32)
115
+ hash
116
+ end
117
+
118
+ def build_u_r3plus(file_key:, id0:, revision:)
119
+ hash = user_password_hash_r3plus(file_key: file_key, id0: id0)
120
+ # Pad to 32 bytes.
121
+ hash + ("\x00" * (32 - hash.bytesize))
118
122
  end
119
123
 
120
124
  # Algorithm 6: verify a user password. Returns true if it
@@ -85,6 +85,22 @@ module Pdfrb
85
85
 
86
86
  # Per-object encryption: AES-256-CBC with IV prefix. The
87
87
  # per-object key is SHA-256(file_key ‖ oid ‖ gen).
88
+ def encrypt_string(data, oid, gen)
89
+ encrypt_data(data, oid, gen)
90
+ end
91
+
92
+ def encrypt_stream(data, oid, gen)
93
+ encrypt_data(data, oid, gen)
94
+ end
95
+
96
+ def decrypt_string(data, oid, gen)
97
+ decrypt_data(data, oid, gen)
98
+ end
99
+
100
+ def decrypt_stream(data, oid, gen)
101
+ decrypt_data(data, oid, gen)
102
+ end
103
+
88
104
  def encrypt_data(data, oid, gen)
89
105
  return data unless @file_key
90
106
 
@@ -173,11 +173,77 @@ module Pdfrb
173
173
  end
174
174
  end
175
175
 
176
+ # Direction-aware entry points (s7.6.3.2 Table 21): streams
177
+ # use the StmF crypt filter, strings use StrF. V5 is always
178
+ # AES-256; V2/V3 are always RC4; V4 resolves the cipher from
179
+ # the CF dictionary's CFM (defaulting to Identity, per spec).
180
+ def encrypt_string(data, oid, gen)
181
+ encrypt_with(str_crypt_method, data, oid, gen)
182
+ end
183
+
184
+ def encrypt_stream(data, oid, gen)
185
+ encrypt_with(stm_crypt_method, data, oid, gen)
186
+ end
187
+
188
+ def decrypt_string(data, oid, gen)
189
+ decrypt_with(str_crypt_method, data, oid, gen)
190
+ end
191
+
192
+ def decrypt_stream(data, oid, gen)
193
+ decrypt_with(stm_crypt_method, data, oid, gen)
194
+ end
195
+
176
196
  # Serializer-compatible alias for encrypt_data. The Serializer
177
197
  # calls encrypter.encrypt(payload, oid, gen) on string/stream
178
198
  # payloads during serialization.
179
199
  alias encrypt encrypt_data
180
200
 
201
+ def stm_crypt_method
202
+ crypt_method_for(@encrypt[:StmF])
203
+ end
204
+
205
+ def str_crypt_method
206
+ crypt_method_for(@encrypt[:StrF])
207
+ end
208
+
209
+ # @return [:aes256, :aes, :rc4, :identity]
210
+ def crypt_method_for(filter_name)
211
+ return :aes256 if @version >= 5
212
+ return :rc4 if @version <= 3
213
+
214
+ # V4: resolve CFM from the CF dictionary; absent filters
215
+ # default to Identity (s7.6.3.2 Table 21).
216
+ return :identity if filter_name.nil? || filter_name == :Identity
217
+
218
+ cf = @encrypt[:CF]
219
+ cfm = cf && cf[filter_name] ? cf[filter_name][:CFM] : nil
220
+ case cfm
221
+ when :AESV2 then :aes
222
+ when :V2 then :rc4
223
+ else :identity
224
+ end
225
+ end
226
+
227
+ def encrypt_with(method, data, oid, gen)
228
+ return data if method == :identity || @key.nil?
229
+
230
+ obj_key = compute_object_key(oid, gen)
231
+ case method
232
+ when :aes256, :aes then encrypt_aes_cbc(data, obj_key)
233
+ else encrypt_rc4(data, obj_key)
234
+ end
235
+ end
236
+
237
+ def decrypt_with(method, data, oid, gen)
238
+ return data if method == :identity || @key.nil?
239
+
240
+ obj_key = compute_object_key(oid, gen)
241
+ case method
242
+ when :aes256, :aes then decrypt_aes_cbc(data, obj_key)
243
+ else decrypt_rc4(data, obj_key)
244
+ end
245
+ end
246
+
181
247
  def decrypt_data(data, oid, gen)
182
248
  return data unless @key
183
249
 
@@ -194,7 +260,9 @@ module Pdfrb
194
260
 
195
261
  def pad_password(password)
196
262
  pw = password.to_s.encode(Encoding::BINARY)[0, 32]
197
- pw + PAD[pw.bytesize, 32 - pw.bytesize]
263
+ # Algorithm 3.2: append the FIRST (32 - n) bytes of the pad
264
+ # string — not a slice offset by the password length.
265
+ pw + PAD[0, 32 - pw.bytesize]
198
266
  end
199
267
 
200
268
  def id_bytes
@@ -205,10 +273,14 @@ module Pdfrb
205
273
  end
206
274
 
207
275
  def verify_user_password_hash?(key)
208
- return true if @revision < 3
209
-
210
- hash = compute_user_password_hash(key)
211
- stored = @encrypt[:U] || ""
276
+ stored = @encrypt[:U] || "".b
277
+ hash = if @revision < 3
278
+ # Algorithm 3.4/3.6 (R2): U = RC4(file_key, PAD).
279
+ RC4Impl.new(key).process(PAD)
280
+ else
281
+ Pdfrb::Encryption::PasswordVerification
282
+ .user_password_hash_r3plus(file_key: key, id0: id_bytes)
283
+ end
212
284
  hash[0, 16] == stored[0, 16]
213
285
  end
214
286
 
@@ -236,11 +308,15 @@ module Pdfrb
236
308
  RC4Impl.new(key).process(data)
237
309
  end
238
310
 
311
+ def aes_cipher_name(key)
312
+ "aes-#{key.bytesize * 8}-cbc"
313
+ end
314
+
239
315
  def encrypt_aes_cbc(data, key)
240
316
  iv = OpenSSL::Random.random_bytes(16)
241
- cipher = OpenSSL::Cipher.new("aes-128-cbc")
317
+ cipher = OpenSSL::Cipher.new(aes_cipher_name(key))
242
318
  cipher.encrypt
243
- cipher.key = key[0, 16]
319
+ cipher.key = key
244
320
  cipher.iv = iv
245
321
  encrypted = cipher.update(data) + cipher.final
246
322
  iv + encrypted
@@ -251,9 +327,9 @@ module Pdfrb
251
327
 
252
328
  iv = data[0, 16]
253
329
  ciphertext = data[16..]
254
- decipher = OpenSSL::Cipher.new("aes-128-cbc")
330
+ decipher = OpenSSL::Cipher.new(aes_cipher_name(key))
255
331
  decipher.decrypt
256
- decipher.key = key[0, 16]
332
+ decipher.key = key
257
333
  decipher.iv = iv
258
334
  decipher.update(ciphertext) + decipher.final
259
335
  rescue OpenSSL::Cipher::CipherError
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdfrb
4
+ module Encryption
5
+ # The COS value-tree string walk, shared by both directions of
6
+ # the encryption seam: the Serializer encrypts every literal
7
+ # string an indirect object carries (building transformed
8
+ # copies), and the ObjectReader decrypts them back (mutating the
9
+ # freshly-parsed containers in place). One walk, two adapters —
10
+ # keeps the string set and traversal rules from drifting.
11
+ module ValueStrings
12
+ module_function
13
+
14
+ # @return [Object] a transformed copy of +value+ with every
15
+ # literal string replaced by the block's result.
16
+ def encrypt(value, oid, gen, cipher)
17
+ case value
18
+ when ::Hash
19
+ value.each_with_object({}) { |(k, v), h| h[k] = encrypt(v, oid, gen, cipher) }
20
+ when ::Array
21
+ value.map { |v| encrypt(v, oid, gen, cipher) }
22
+ when Pdfrb::Model::PdfArray
23
+ Pdfrb::Model::PdfArray.new(value.map { |v| encrypt(v, oid, gen, cipher) })
24
+ when Pdfrb::Model::Object
25
+ Pdfrb::Model::Object.new(encrypt(value.value, oid, gen, cipher),
26
+ oid: value.oid, gen: value.gen)
27
+ when ::String
28
+ bytes = if value.encoding == Encoding::UTF_8
29
+ Pdfrb::Model::Cos::StringEncoding.encode_text(value)
30
+ else
31
+ value.dup.force_encoding(Encoding::BINARY)
32
+ end
33
+ cipher.encrypt_string(bytes, oid, gen)
34
+ else
35
+ value
36
+ end
37
+ end
38
+
39
+ # Replaces every literal string inside the freshly-parsed
40
+ # +value+ (a Hash/Array container tree) with its decrypted
41
+ # form, in place. Returns +value+.
42
+ def decrypt!(value, oid, gen, cipher)
43
+ case value
44
+ when ::Hash
45
+ value.each { |k, v| value[k] = decrypt!(v, oid, gen, cipher) }
46
+ when ::Array
47
+ value.map! { |v| decrypt!(v, oid, gen, cipher) }
48
+ when Pdfrb::Model::PdfArray
49
+ value.value.map! { |v| decrypt!(v, oid, gen, cipher) }
50
+ value
51
+ when ::String
52
+ cipher.decrypt_string(value, oid, gen)
53
+ else
54
+ value
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -17,6 +17,7 @@ module Pdfrb
17
17
  autoload :AES, "pdfrb/encryption/aes"
18
18
  autoload :PasswordVerification, "pdfrb/encryption/password_verification"
19
19
  autoload :Identity, "pdfrb/encryption/identity"
20
+ autoload :ValueStrings, "pdfrb/encryption/value_strings"
20
21
  autoload :PublicKeySecurityHandler, "pdfrb/encryption/public_key_security_handler"
21
22
  autoload :V5Writer, "pdfrb/encryption/v5_writer"
22
23
 
@@ -32,6 +33,29 @@ module Pdfrb
32
33
  def handler_for_document(document, **opts)
33
34
  SecurityHandler.for(document, decryption_opts: opts)
34
35
  end
36
+
37
+ # Crypt-filter entries a V4 /Encrypt dict must carry when the
38
+ # cipher is AES-128: absent StmF/StrF default to Identity
39
+ # (s7.6.3.2 Table 21), leaving the ciphertext unreadable.
40
+ V4_AESV2_FILTERS = {
41
+ CF: { StdCF: { CFM: :AESV2, Length: 16 } },
42
+ StmF: :StdCF,
43
+ StrF: :StdCF,
44
+ }.freeze
45
+
46
+ def v4_crypt_filters(v)
47
+ v == 4 ? V4_AESV2_FILTERS : {}
48
+ end
49
+
50
+ # Objects whose bytes are never encrypted (both write and read
51
+ # directions consult this): the /Encrypt dictionary itself
52
+ # (s7.6.3 — its values carry the password hashes) and
53
+ # cross-reference streams (always cleartext).
54
+ def exempt_object?(document, obj)
55
+ encrypt_oid = (document.trailer || {})[:Encrypt]&.oid
56
+ obj.oid == encrypt_oid ||
57
+ (obj.is_a?(Pdfrb::Model::Cos::Stream) && obj.value[:Type] == :XRef)
58
+ end
35
59
  end
36
60
  end
37
61
 
@@ -30,7 +30,7 @@ module Pdfrb
30
30
 
31
31
  def initialize(document)
32
32
  @document = document
33
- @serializer = Pdfrb::Serializer.new
33
+ @serializer = Pdfrb::Writer.serializer_for(document)
34
34
  end
35
35
 
36
36
  # @param io [IO] target output.
@@ -70,7 +70,8 @@ module Pdfrb
70
70
 
71
71
  ordered.each do |obj|
72
72
  offsets[obj.oid] = temp.pos
73
- temp << @serializer.serialize_indirect(obj)
73
+ temp << @serializer.serialize_indirect(obj,
74
+ skip_encryption: Pdfrb::Encryption.exempt_object?(@document, obj))
74
75
  end
75
76
  first_page_end = temp.pos if first_page_objects.any?
76
77
 
@@ -79,7 +80,6 @@ module Pdfrb
79
80
  hint_compressed = ::Zlib::Deflate.deflate(hint_data)
80
81
  hint_offset = temp.pos
81
82
  hint_dict = @serializer.serialize(
82
- Type: :XRef,
83
83
  S: HintStream::DEFAULT_ITEM_BITS,
84
84
  Filter: :FlateDecode,
85
85
  Length: hint_compressed.bytesize
@@ -132,7 +132,8 @@ module Pdfrb
132
132
  buf << lin_padded
133
133
 
134
134
  layout.ordered_objects.each do |obj|
135
- buf << @serializer.serialize_indirect(obj)
135
+ buf << @serializer.serialize_indirect(obj,
136
+ skip_encryption: Pdfrb::Encryption.exempt_object?(@document, obj))
136
137
  end
137
138
 
138
139
  buf << "#{layout.hint_oid} 0 obj\n"
@@ -243,7 +244,16 @@ module Pdfrb
243
244
  end
244
245
  size = [@document.next_oid || 1, 2].max
245
246
 
246
- trailer_str = @serializer.serialize(Size: size, Root: root_ref)
247
+ trailer_hash = {}
248
+ (@document.trailer || {}).each do |k, v|
249
+ next if Pdfrb::Writer::TRAILER_OVERRIDDEN_KEYS.include?(k)
250
+
251
+ trailer_hash[k] = v
252
+ end
253
+ trailer_hash[:Size] = size
254
+ trailer_hash[:Root] = root_ref if root_ref
255
+
256
+ trailer_str = @serializer.serialize(trailer_hash)
247
257
  "trailer\n#{trailer_str}\nstartxref\n#{xref_offset}\n%%EOF\n"
248
258
  end
249
259
 
@@ -50,21 +50,28 @@ module Pdfrb
50
50
  end
51
51
 
52
52
  # Serialise an indirect object's body, including `oid gen obj`
53
- # header and (for streams) the stream payload.
54
- def serialize_indirect(obj)
53
+ # header and (for streams) the stream payload. When an encrypter
54
+ # is set, all strings in the object's dictionaries and the stream
55
+ # payload are encrypted with the per-object key (s7.6.1) — pass
56
+ # +skip_encryption:+ for objects that must stay readable: the
57
+ # /Encrypt dictionary itself (s7.6.3) and cross-reference streams.
58
+ def serialize_indirect(obj, skip_encryption: false)
55
59
  raise ArgumentError, "not indirect: #{obj.inspect}" unless obj.indirect?
56
60
 
61
+ encrypt_strings = !skip_encryption && encrypter
57
62
  buffer = +""
58
63
  buffer << "#{obj.oid} #{obj.gen} obj\n"
59
64
  case obj
60
65
  when Pdfrb::Model::Cos::Stream
61
- dict, payload = emit_stream(obj)
66
+ dict_value = encrypt_strings ? Pdfrb::Encryption::ValueStrings.encrypt(obj.value, obj.oid, obj.gen, encrypter) : obj.value
67
+ dict, payload = emit_stream(obj, dict_value)
62
68
  buffer << dict
63
69
  buffer << "\nstream\n"
64
- buffer << (encrypter ? encrypter.encrypt(payload, obj.oid, obj.gen) : payload)
70
+ buffer << (encrypter && !skip_encryption ? encrypter.encrypt_stream(payload, obj.oid, obj.gen) : payload)
65
71
  buffer << "\nendstream\n"
66
72
  else
67
- buffer << serialize(obj)
73
+ value = encrypt_strings ? Pdfrb::Encryption::ValueStrings.encrypt(obj.value, obj.oid, obj.gen, encrypter) : obj.value
74
+ buffer << serialize(value)
68
75
  buffer << "\n"
69
76
  end
70
77
  buffer << "endobj\n"
@@ -76,9 +83,9 @@ module Pdfrb
76
83
  # Build the dict body + payload for a stream object, applying
77
84
  # FlateDecode compression when enabled and the stream is large
78
85
  # enough to benefit. Returns [dict_bytes, payload_bytes].
79
- def emit_stream(stream_obj)
86
+ def emit_stream(stream_obj, dict_value = stream_obj.value)
80
87
  payload = stream_obj.stream
81
- hash = stream_obj.value.dup
88
+ hash = dict_value.dup
82
89
  existing_filter = hash[:Filter]
83
90
 
84
91
  if should_compress?(stream_obj, payload, existing_filter)
@@ -29,13 +29,37 @@ module Pdfrb
29
29
  return nil if entry.nil? || entry.free?
30
30
 
31
31
  obj = case entry.type
32
- when :in_use then parse_at(entry.offset, oid, entry.gen)
32
+ when :in_use then decrypt_loaded(parse_at(entry.offset, oid, entry.gen), entry.gen)
33
33
  when :compressed then load_from_objstm(entry.obj_stm_oid, entry.index, oid)
34
34
  end
35
35
  @cache[oid] = obj
36
36
  obj
37
37
  end
38
38
 
39
+ # The single funnel for read-side decryption (s7.6.1): strings
40
+ # in the object's dictionaries and the stream payload decrypt
41
+ # with the per-object key as the object enters the Model.
42
+ # Objects inside object streams need no separate treatment —
43
+ # only the containing ObjStm stream is encrypted — and the
44
+ # /Encrypt dictionary is exempt (s7.6.3).
45
+ def decrypt_loaded(obj, gen)
46
+ return obj if obj.nil?
47
+ # The exemption check must precede handler construction:
48
+ # building the handler resolves the /Encrypt dict through
49
+ # this same funnel, which would recurse before the memo is
50
+ # set.
51
+ return obj if Pdfrb::Encryption.exempt_object?(document, obj)
52
+
53
+ handler = document.encryption.reader_handler
54
+ return obj if handler.nil?
55
+
56
+ Pdfrb::Encryption::ValueStrings.decrypt!(obj.value, obj.oid, gen, handler)
57
+ if obj.is_a?(Pdfrb::Model::Cos::Stream) && obj.stream
58
+ obj.stream = handler.decrypt_stream(obj.stream, obj.oid, gen)
59
+ end
60
+ obj
61
+ end
62
+
39
63
  def clear_cache
40
64
  @cache.clear
41
65
  @objstm_cache.clear
data/lib/pdfrb/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pdfrb
4
- VERSION = "0.8.6"
4
+ VERSION = "0.8.8"
5
5
  end
data/lib/pdfrb/writer.rb CHANGED
@@ -17,24 +17,37 @@ module Pdfrb
17
17
  class Writer
18
18
  DEFAULT_VERSION = "1.4"
19
19
 
20
+ # Trailer keys each writer recomputes itself; existing values
21
+ # for these are never carried through from the parsed trailer.
22
+ TRAILER_OVERRIDDEN_KEYS = %i[Size Root Prev XRefStm].freeze
23
+
20
24
  attr_reader :document, :io, :serializer
21
25
 
22
26
  def initialize(document, io)
23
27
  @document = document
24
28
  @io = io
25
- @serializer = Serializer.new(
29
+ @serializer = Writer.serializer_for(document)
30
+ @xref_offsets = {}
31
+ end
32
+
33
+ # Build a Serializer configured for +document+: stream compression
34
+ # from config, and an encrypter derived from the trailer /Encrypt.
35
+ # Raises EncryptionError when the document is encrypted but the
36
+ # configured password does not open it — writing must never
37
+ # silently fall back to plaintext.
38
+ def self.serializer_for(document)
39
+ Serializer.new(
26
40
  compress_streams: document.config["writer.compress_streams"],
27
41
  compress_min_size: document.config["writer.compress_min_size"],
28
- **serializer_encrypter_opts
42
+ **serializer_encrypter_opts_for(document)
29
43
  )
30
- @xref_offsets = {}
31
44
  end
32
45
 
33
46
  # Build the encrypter option for the Serializer from the document's
34
47
  # /Encrypt dict. If the document isn't encrypted, returns empty hash.
35
48
  # The SecurityHandler's encrypt_data method becomes the Serializer's
36
49
  # encrypter, which encrypts string/stream payloads per-object.
37
- def serializer_encrypter_opts
50
+ def self.serializer_encrypter_opts_for(document)
38
51
  # Use a pre-configured handler if the document provides one
39
52
  # (e.g., set by the encryption CLI).
40
53
  handler = document.config["encryption.handler"]
@@ -50,15 +63,15 @@ module Pdfrb
50
63
  return {} unless encrypt_dict
51
64
 
52
65
  handler = Pdfrb::Encryption::StandardSecurityHandler.new(
53
- Encrypt: encrypt_dict.value,
54
- ID: trailer[:ID]
66
+ { Encrypt: encrypt_dict.value, ID: trailer[:ID] }
55
67
  )
56
68
  password = document.config["encryption.password"] || ""
57
- handler.verify_user_password(password)
69
+ unless handler.verify_user_password?(password)
70
+ raise Pdfrb::EncryptionError,
71
+ "cannot write encrypted document: password does not verify"
72
+ end
58
73
 
59
74
  { encrypter: handler }
60
- rescue StandardError
61
- {}
62
75
  end
63
76
 
64
77
  def self.write(document, io)
@@ -84,7 +97,8 @@ module Pdfrb
84
97
  next if packed.key?(obj.oid)
85
98
 
86
99
  @xref_offsets[obj.oid] = @io.pos
87
- @io << @serializer.serialize_indirect(obj)
100
+ @io << @serializer.serialize_indirect(obj,
101
+ skip_encryption: Pdfrb::Encryption.exempt_object?(document, obj))
88
102
  end
89
103
 
90
104
  xref_pos = if use_stream
@@ -118,7 +132,8 @@ module Pdfrb
118
132
  next unless obj.indirect?
119
133
 
120
134
  @xref_offsets[obj.oid] = @io.pos
121
- @io << @serializer.serialize_indirect(obj)
135
+ @io << @serializer.serialize_indirect(obj,
136
+ skip_encryption: Pdfrb::Encryption.exempt_object?(document, obj))
122
137
  end
123
138
  xref_pos = write_xref(prev: previous_xref_offset)
124
139
  write_trailer(xref_pos, prev: previous_xref_offset)
@@ -293,7 +308,7 @@ module Pdfrb
293
308
  existing = document.trailer || {}
294
309
  trailer_hash = {}
295
310
  existing.each do |k, v|
296
- next if %i[Size Root Prev XRefStm].include?(k)
311
+ next if TRAILER_OVERRIDDEN_KEYS.include?(k)
297
312
 
298
313
  trailer_hash[k] = v
299
314
  end
data/lib/pdfrb.rb CHANGED
@@ -47,8 +47,6 @@ module Pdfrb
47
47
  autoload :Serializer, "pdfrb/serializer"
48
48
  autoload :Writer, "pdfrb/writer"
49
49
  autoload :Importer, "pdfrb/importer"
50
- autoload :Revision, "pdfrb/revision"
51
- autoload :Revisions, "pdfrb/revisions"
52
50
  autoload :XrefSection, "pdfrb/xref_section"
53
51
  autoload :Document, "pdfrb/document"
54
52
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pdfrb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.6
4
+ version: 0.8.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-30 00:00:00.000000000 Z
11
+ date: 2026-08-31 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: benchmark
@@ -891,6 +891,7 @@ files:
891
891
  - lib/pdfrb/encryption/security_handler.rb
892
892
  - lib/pdfrb/encryption/standard_security_handler.rb
893
893
  - lib/pdfrb/encryption/v5_writer.rb
894
+ - lib/pdfrb/encryption/value_strings.rb
894
895
  - lib/pdfrb/error.rb
895
896
  - lib/pdfrb/filter.rb
896
897
  - lib/pdfrb/filter/ascii_85_decode.rb