hexapdf 0.26.2 → 0.27.0

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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -0
  3. data/README.md +1 -1
  4. data/examples/013-text_layouter_shapes.rb +8 -8
  5. data/examples/016-frame_automatic_box_placement.rb +3 -3
  6. data/examples/017-frame_text_flow.rb +3 -3
  7. data/examples/020-column_box.rb +3 -3
  8. data/lib/hexapdf/cli/split.rb +7 -7
  9. data/lib/hexapdf/cli/watermark.rb +2 -2
  10. data/lib/hexapdf/configuration.rb +2 -0
  11. data/lib/hexapdf/dictionary.rb +3 -12
  12. data/lib/hexapdf/document/destinations.rb +42 -5
  13. data/lib/hexapdf/document/signatures.rb +265 -48
  14. data/lib/hexapdf/importer.rb +3 -0
  15. data/lib/hexapdf/parser.rb +1 -0
  16. data/lib/hexapdf/revisions.rb +3 -1
  17. data/lib/hexapdf/tokenizer.rb +2 -2
  18. data/lib/hexapdf/type/acro_form/form.rb +28 -1
  19. data/lib/hexapdf/type/catalog.rb +1 -1
  20. data/lib/hexapdf/type/outline.rb +18 -0
  21. data/lib/hexapdf/type/outline_item.rb +72 -14
  22. data/lib/hexapdf/type/page.rb +56 -35
  23. data/lib/hexapdf/type/resources.rb +13 -17
  24. data/lib/hexapdf/type/signature/adbe_pkcs7_detached.rb +16 -2
  25. data/lib/hexapdf/type/signature.rb +10 -0
  26. data/lib/hexapdf/version.rb +1 -1
  27. data/lib/hexapdf/writer.rb +3 -0
  28. data/test/hexapdf/document/test_destinations.rb +41 -0
  29. data/test/hexapdf/document/test_signatures.rb +139 -19
  30. data/test/hexapdf/test_importer.rb +14 -0
  31. data/test/hexapdf/test_parser.rb +2 -2
  32. data/test/hexapdf/test_revisions.rb +20 -12
  33. data/test/hexapdf/test_tokenizer.rb +11 -1
  34. data/test/hexapdf/test_writer.rb +11 -3
  35. data/test/hexapdf/type/acro_form/test_form.rb +47 -0
  36. data/test/hexapdf/type/signature/common.rb +52 -0
  37. data/test/hexapdf/type/signature/test_adbe_pkcs7_detached.rb +21 -0
  38. data/test/hexapdf/type/test_catalog.rb +5 -2
  39. data/test/hexapdf/type/test_outline.rb +1 -1
  40. data/test/hexapdf/type/test_outline_item.rb +62 -1
  41. data/test/hexapdf/type/test_page.rb +41 -20
  42. data/test/hexapdf/type/test_resources.rb +0 -5
  43. data/test/hexapdf/type/test_signature.rb +8 -0
  44. data/test/test_helper.rb +1 -1
  45. metadata +17 -3
@@ -126,6 +126,11 @@ module HexaPDF
126
126
  lister: "flags", getter: "flagged?", setter: "flag", unsetter: "unflag",
127
127
  value_getter: "self[:F]", value_setter: "self[:F]")
128
128
 
129
+ # Returns +true+ since outline items must always be indirect objects.
130
+ def must_be_indirect?
131
+ true
132
+ end
133
+
129
134
  # :call-seq:
130
135
  # item.title -> title
131
136
  # item.title(value) -> title
@@ -198,9 +203,46 @@ module HexaPDF
198
203
  end
199
204
  end
200
205
 
206
+ # Returns the outline level this item is one.
207
+ #
208
+ # The level of the items in the main outline dictionary, the root level, is 1.
209
+ #
210
+ # Here is an illustrated example of items contained in a document outline with their
211
+ # associated level:
212
+ #
213
+ # Outline dictionary 0
214
+ # Outline item 1 1
215
+ # |- Sub item 1 2
216
+ # |- Sub item 2 2
217
+ # |- Sub sub item 1 3
218
+ # |- Sub item 3 2
219
+ # Outline item 2 1
220
+ def level
221
+ count = 0
222
+ temp = self
223
+ count += 1 while (temp = temp[:Parent])
224
+ count
225
+ end
226
+
227
+ # Returns the destination page if there is any.
228
+ #
229
+ # * If a destination is set, the associated page is returned.
230
+ # * If an action is set and it is a GoTo action, the associated page is returned.
231
+ # * Otherwise +nil+ is returned.
232
+ def destination_page
233
+ dest = self[:Dest]
234
+ dest = action[:D] if !dest && (action = self[:A]) && action[:S] == :GoTo
235
+ document.destinations.resolve(dest)&.page
236
+ end
237
+
201
238
  # Adds, as child to this item, a new outline item with the given title that performs the
202
239
  # provided action on clicking. Returns the newly added item.
203
240
  #
241
+ # Alternatively, it is possible to provide an already initialized outline item instead of the
242
+ # title. If so, the only other argument that is used is +position+. Existing fields /Prev,
243
+ # /Next, /First, /Last, /Parent and /Count are deleted from the given item and set
244
+ # appropriately.
245
+ #
204
246
  # If neither :destination nor :action is specified, the outline item has no associated action.
205
247
  # This is only meaningful if the new item will have children as it then acts just as a
206
248
  # container.
@@ -251,16 +293,30 @@ module HexaPDF
251
293
  # end
252
294
  def add_item(title, destination: nil, action: nil, position: :last, open: true,
253
295
  text_color: nil, flags: nil) # :yield: item
254
- item = document.add({Parent: self}, type: :XXOutlineItem)
255
- item.title(title)
256
- if action
257
- item.action(action)
296
+ if title.kind_of?(HexaPDF::Object) && title.type == :XXOutlineItem
297
+ item = title
298
+ item.delete(:Prev)
299
+ item.delete(:Next)
300
+ item.delete(:First)
301
+ item.delete(:Last)
302
+ if item[:Count] && item[:Count] >= 0
303
+ item[:Count] = 0
304
+ else
305
+ item.delete(:Count)
306
+ end
307
+ item[:Parent] = self
258
308
  else
259
- item.destination(destination)
309
+ item = document.add({Parent: self}, type: :XXOutlineItem)
310
+ item.title(title)
311
+ if action
312
+ item.action(action)
313
+ else
314
+ item.destination(destination)
315
+ end
316
+ item.text_color(text_color) if text_color
317
+ item.flag(*flags) if flags
318
+ item[:Count] = 0 if open # Count=0 means open if items are later added
260
319
  end
261
- item.text_color(text_color) if text_color
262
- item.flag(*flags) if flags
263
- item[:Count] = 0 if open # Count=0 means open if items are later added
264
320
 
265
321
  unless position == :last || position == :first || position.kind_of?(Integer)
266
322
  raise ArgumentError, "position must be :first, :last, or an integer"
@@ -311,18 +367,20 @@ module HexaPDF
311
367
  end
312
368
 
313
369
  # :call-seq:
314
- # item.each_item {|descendant_item| block } -> item
315
- # item.each_item -> Enumerator
370
+ # item.each_item {|descendant_item, level| block } -> item
371
+ # item.each_item -> Enumerator
316
372
  #
317
373
  # Iterates over all descendant items of this one.
318
374
  #
319
- # The items are yielded in-order, yielding first the item itself and then its descendants.
375
+ # The descendant items are yielded in-order, yielding first the item itself and then its
376
+ # descendants.
320
377
  def each_item(&block)
321
378
  return to_enum(__method__) unless block_given?
379
+ return self unless (item = self[:First])
322
380
 
323
- item = self[:First]
381
+ level = self.level + 1
324
382
  while item
325
- yield(item)
383
+ yield(item, level)
326
384
  item.each_item(&block)
327
385
  item = item[:Next]
328
386
  end
@@ -341,7 +399,7 @@ module HexaPDF
341
399
  node, dir = first ? [first, :Next] : [last, :Prev]
342
400
  node = node[dir] while node.key?(dir)
343
401
  self[dir == :Next ? :Last : :First] = node
344
- elsif !first && !last && self[:Count] != 0
402
+ elsif !first && !last && self[:Count] && self[:Count] != 0
345
403
  yield('Outline item dictionary key /Count set but no descendants exist', true)
346
404
  delete(:Count)
347
405
  end
@@ -187,8 +187,8 @@ module HexaPDF
187
187
  end
188
188
 
189
189
  # :call-seq:
190
- # page.box(type = :media) -> box
191
- # page.box(type = :media, rectangle) -> rectangle
190
+ # page.box(type = :crop) -> box
191
+ # page.box(type = :crop, rectangle) -> rectangle
192
192
  #
193
193
  # If no +rectangle+ is given, returns the rectangle defining a certain kind of box for the
194
194
  # page. Otherwise sets the value for the given box type to +rectangle+ (an array with four
@@ -219,7 +219,7 @@ module HexaPDF
219
219
  # author. The default is the crop box.
220
220
  #
221
221
  # See: PDF1.7 s14.11.2
222
- def box(type = :media, rectangle = nil)
222
+ def box(type = :crop, rectangle = nil)
223
223
  if rectangle
224
224
  case type
225
225
  when :media, :crop, :bleed, :trim, :art
@@ -240,9 +240,10 @@ module HexaPDF
240
240
  end
241
241
  end
242
242
 
243
- # Returns the orientation of the media box, either :portrait or :landscape.
244
- def orientation
245
- box = self[:MediaBox]
243
+ # Returns the orientation of the specified box (default is the crop box), either :portrait or
244
+ # :landscape.
245
+ def orientation(type = :crop)
246
+ box = self.box(type)
246
247
  rotation = self[:Rotate]
247
248
  if (box.height > box.width && (rotation == 0 || rotation == 180)) ||
248
249
  (box.height < box.width && (rotation == 90 || rotation == 270))
@@ -272,21 +273,28 @@ module HexaPDF
272
273
  delete(:Rotate)
273
274
  return if cw_angle == 0
274
275
 
275
- matrix, llx, lly, urx, ury = \
276
- case cw_angle
277
- when 90
278
- [HexaPDF::Content::TransformationMatrix.new(0, -1, 1, 0),
279
- box.right, box.bottom, box.left, box.top]
280
- when 180
281
- [HexaPDF::Content::TransformationMatrix.new(-1, 0, 0, -1),
282
- box.right, box.top, box.left, box.bottom]
283
- when 270
284
- [HexaPDF::Content::TransformationMatrix.new(0, 1, -1, 0),
285
- box.left, box.top, box.right, box.bottom]
286
- end
287
- [:MediaBox, :CropBox, :BleedBox, :TrimBox, :ArtBox].each do |box|
288
- next unless key?(box)
289
- self[box].value = matrix.evaluate(llx, lly).concat(matrix.evaluate(urx, ury))
276
+ matrix = case cw_angle
277
+ when 90
278
+ HexaPDF::Content::TransformationMatrix.new(0, -1, 1, 0)
279
+ when 180
280
+ HexaPDF::Content::TransformationMatrix.new(-1, 0, 0, -1)
281
+ when 270
282
+ HexaPDF::Content::TransformationMatrix.new(0, 1, -1, 0)
283
+ end
284
+
285
+ [:MediaBox, :CropBox, :BleedBox, :TrimBox, :ArtBox].each do |box_name|
286
+ next unless key?(box_name)
287
+ box = self[box_name]
288
+ llx, lly, urx, ury = \
289
+ case cw_angle
290
+ when 90
291
+ [box.right, box.bottom, box.left, box.top]
292
+ when 180
293
+ [box.right, box.top, box.left, box.bottom]
294
+ when 270
295
+ [box.left, box.top, box.right, box.bottom]
296
+ end
297
+ self[box_name].value = matrix.evaluate(llx, lly).concat(matrix.evaluate(urx, ury))
290
298
  end
291
299
 
292
300
  before_contents = document.add({}, stream: " q #{matrix.to_a.join(' ')} cm ")
@@ -373,20 +381,33 @@ module HexaPDF
373
381
 
374
382
  # Returns the requested type of canvas for the page.
375
383
  #
376
- # The canvas object is cached once it is created so that its graphics state is correctly
377
- # retained without the need for parsing its contents.
378
- #
379
- # If the media box of the page doesn't have its origin at (0, 0), the canvas origin is
380
- # translated into the bottom left corner so that this detail doesn't matter when using the
381
- # canvas. This means that the canvas' origin is always at the bottom left corner of the media
382
- # box.
384
+ # There are potentially three different canvas objects, one for each of the types :underlay,
385
+ # :page, and :overlay. The canvas objects are cached once they are created so that their
386
+ # graphics states are correctly retained without the need for parsing the contents. This also
387
+ # means that on subsequent invocations the graphic states of the canvases might already be
388
+ # changed.
383
389
  #
384
390
  # type::
385
391
  # Can either be
386
392
  # * :page for getting the canvas for the page itself (only valid for initially empty pages)
387
393
  # * :overlay for getting the canvas for drawing over the page contents
388
394
  # * :underlay for getting the canvas for drawing unter the page contents
389
- def canvas(type: :page)
395
+ #
396
+ # translate_origin::
397
+ # Specifies whether the origin should automatically be translated into the lower-left
398
+ # corner of the crop box.
399
+ #
400
+ # Note that this argument is only used for the first invocation for every canvas type. So
401
+ # if a canvas was initially requested with this argument set to false and then with true,
402
+ # it won't have any effect as the cached canvas is returned.
403
+ #
404
+ # To check whether the origin has been translated or not, use
405
+ #
406
+ # canvas.graphics_state.ctm.evaluate(0, 0)
407
+ #
408
+ # and check whether the result is [0, 0]. If it is, then the origin has not been
409
+ # translated.
410
+ def canvas(type: :page, translate_origin: true)
390
411
  unless [:page, :overlay, :underlay].include?(type)
391
412
  raise ArgumentError, "Invalid value for 'type', expected: :page, :underlay or :overlay"
392
413
  end
@@ -399,9 +420,10 @@ module HexaPDF
399
420
 
400
421
  create_canvas = lambda do
401
422
  Content::Canvas.new(self).tap do |canvas|
402
- media_box = box(:media)
403
- if media_box.left != 0 || media_box.bottom != 0
404
- canvas.translate(media_box.left, media_box.bottom)
423
+ next unless translate_origin
424
+ crop_box = box(:crop)
425
+ if crop_box.left != 0 || crop_box.bottom != 0
426
+ canvas.translate(crop_box.left, crop_box.bottom)
405
427
  end
406
428
  end
407
429
  end
@@ -505,9 +527,8 @@ module HexaPDF
505
527
 
506
528
  canvas = self.canvas(type: :overlay)
507
529
  canvas.save_graphics_state
508
- media_box = box(:media)
509
- if media_box.left != 0 || media_box.bottom != 0
510
- canvas.translate(-media_box.left, -media_box.bottom) # revert initial translation of origin
530
+ if (pos = canvas.graphics_state.ctm.evaluate(0, 0)) != [0, 0]
531
+ canvas.translate(-pos[0], -pos[1])
511
532
  end
512
533
 
513
534
  to_delete = []
@@ -217,24 +217,20 @@ module HexaPDF
217
217
  # Ensures that a valid procedure set is available.
218
218
  def perform_validation
219
219
  super
220
- val = self[:ProcSet]
221
- if val
222
- if val.kind_of?(Symbol)
223
- yield("Procedure set is a single value instead of an Array", true)
224
- val = value[:ProcSet] = [val]
225
- end
226
- val.reject! do |name|
227
- case name
228
- when :PDF, :Text, :ImageB, :ImageC, :ImageI
229
- false
230
- else
231
- yield("Invalid page procedure set name /#{name}", true)
232
- true
233
- end
220
+ return unless (val = self[:ProcSet])
221
+
222
+ if val.kind_of?(Symbol)
223
+ yield("Procedure set is a single value instead of an Array", true)
224
+ val = value[:ProcSet] = [val]
225
+ end
226
+ val.reject! do |name|
227
+ case name
228
+ when :PDF, :Text, :ImageB, :ImageC, :ImageI
229
+ false
230
+ else
231
+ yield("Invalid page procedure set name /#{name}", true)
232
+ true
234
233
  end
235
- else
236
- yield("No procedure set specified", true)
237
- self[:ProcSet] = [:PDF, :Text, :ImageB, :ImageC, :ImageI]
238
234
  end
239
235
  end
240
236
 
@@ -104,8 +104,22 @@ module HexaPDF
104
104
  result.log(:error, "Certificate key usage is missing 'Digital Signature'")
105
105
  end
106
106
 
107
- if @pkcs7.verify(certificate_chain, store, signature_dict.signed_data,
108
- OpenSSL::PKCS7::DETACHED | OpenSSL::PKCS7::BINARY)
107
+ if signature_dict.signature_type == 'ETSI.RFC3161'
108
+ # Getting the needed values is not directly supported by Ruby OpenSSL
109
+ p7 = OpenSSL::ASN1.decode(signature_dict.contents.sub(/\x00*\z/, ''))
110
+ signed_data = p7.value[1].value[0]
111
+ content_info = signed_data.value[2]
112
+ content = OpenSSL::ASN1.decode(content_info.value[1].value[0].value)
113
+ digest_algorithm = content.value[2].value[0].value[0].value
114
+ original_hash = content.value[2].value[1].value
115
+ recomputed_hash = OpenSSL::Digest.digest(digest_algorithm, signature_dict.signed_data)
116
+ hash_valid = (original_hash == recomputed_hash)
117
+ else
118
+ data = signature_dict.signed_data
119
+ hash_valid = true # hash will be checked by @pkcs7.verify
120
+ end
121
+ if hash_valid && @pkcs7.verify(certificate_chain, store, data,
122
+ OpenSSL::PKCS7::DETACHED | OpenSSL::PKCS7::BINARY)
109
123
  result.log(:info, "Signature valid")
110
124
  else
111
125
  result.log(:error, "Signature verification failed")
@@ -230,6 +230,16 @@ module HexaPDF
230
230
  signature_handler.verify(store, allow_self_signed: allow_self_signed)
231
231
  end
232
232
 
233
+ private
234
+
235
+ def perform_validation #:nodoc:
236
+ if (self[:SubFilter] == :'ETSI.CAdES.detached' || self[:SubFilter] == :'ETSI.RFC3161') &&
237
+ document.version < '2.0'
238
+ yield("Signature handler needs at least PDF version 2.0", true)
239
+ document.version = '2.0'
240
+ end
241
+ end
242
+
233
243
  end
234
244
 
235
245
  end
@@ -37,6 +37,6 @@
37
37
  module HexaPDF
38
38
 
39
39
  # The version of HexaPDF.
40
- VERSION = '0.26.2'
40
+ VERSION = '0.27.0'
41
41
 
42
42
  end
@@ -110,6 +110,9 @@ module HexaPDF
110
110
 
111
111
  revision = Revision.new(@document.revisions.current.trailer)
112
112
  @document.trailer.info[:Producer] = "HexaPDF version #{HexaPDF::VERSION}"
113
+ if parser.file_header_version < @document.version
114
+ @document.catalog[:Version] = @document.version.to_sym
115
+ end
113
116
  @document.revisions.each do |rev|
114
117
  rev.each_modified_object {|obj| revision.send(:add_without_check, obj) }
115
118
  end
@@ -29,6 +29,12 @@ describe HexaPDF::Document::Destinations::Destination do
29
29
  end
30
30
  end
31
31
 
32
+ it "accepts an array or a dictionary containing a /D entry as value" do
33
+ assert(destination([5, :Fit]).valid?)
34
+ assert(destination({D: [5, :Fit]}).valid?)
35
+ assert(destination(HexaPDF::Dictionary.new({D: [5, :Fit]})).valid?)
36
+ end
37
+
32
38
  it "can be asked whether the referenced page is in a remote document" do
33
39
  assert(destination([5, :Fit]).remote?)
34
40
  refute(destination([HexaPDF::Dictionary.new({}), :Fit]).remote?)
@@ -42,6 +48,10 @@ describe HexaPDF::Document::Destinations::Destination do
42
48
  assert(destination([5, :Fit]).valid?)
43
49
  end
44
50
 
51
+ it "returns the destination array" do
52
+ assert_equal([5, :Fit], destination([5, :Fit]).value)
53
+ end
54
+
45
55
  describe "type :xyz" do
46
56
  before do
47
57
  @dest = destination([:page, :XYZ, :left, :top, :zoom])
@@ -396,6 +406,37 @@ describe HexaPDF::Document::Destinations do
396
406
  refute(@doc.destinations['abc'])
397
407
  end
398
408
 
409
+ describe "resolve" do
410
+ it "resolves the named destination" do
411
+ @doc.catalog.names.destinations.add_entry("arr", [@page, :Fit])
412
+ @doc.catalog.names.destinations.add_entry("dict", {D: [@page, :Fit]})
413
+ assert_equal([@page, :Fit], @doc.destinations.resolve("arr").value)
414
+ assert_equal([@page, :Fit], @doc.destinations.resolve("dict").value)
415
+ end
416
+
417
+ it "returns nil if the named destination is not found" do
418
+ assert_nil(@doc.destinations.resolve("arr"))
419
+ end
420
+
421
+ it "resolves the old-style named destination" do
422
+ @doc.catalog[:Dests] = {arr: [@page, :Fit]}
423
+ assert_equal([@page, :Fit], @doc.destinations.resolve(:arr).value)
424
+ end
425
+
426
+ it "returns nil if the old-style named destination is not found" do
427
+ assert_nil(@doc.destinations.resolve(:arr))
428
+ end
429
+
430
+ it "uses a PDFArray or array argument directly" do
431
+ assert_equal([@page, :Fit], @doc.destinations.resolve([@page, :Fit]).value)
432
+ assert_equal([@page, :Fit], @doc.destinations.resolve(HexaPDF::PDFArray.new([@page, :Fit])).value)
433
+ end
434
+
435
+ it "returns nil if the resolved destination is not valid" do
436
+ assert_nil(@doc.destinations.resolve([@page, :Fitd]))
437
+ end
438
+ end
439
+
399
440
  describe "each" do
400
441
  before do
401
442
  3.times {|i| @doc.destinations.add("abc#{i}", [:page, :Fit]) }
@@ -19,17 +19,37 @@ describe HexaPDF::Document::Signatures do
19
19
  )
20
20
  end
21
21
 
22
- describe "DefaultHandler" do
23
- it "returns the filter name" do
24
- assert_equal(:'Adobe.PPKLite', @handler.filter_name)
25
- end
26
-
27
- it "returns the sub filter algorithm name" do
28
- assert_equal(:'adbe.pkcs7.detached', @handler.sub_filter_name)
22
+ it "allows embedding an external signature value" do
23
+ doc = HexaPDF::Document.new(io: StringIO.new(MINIMAL_PDF))
24
+ io = StringIO.new(''.b)
25
+ doc.signatures.add(io, @handler)
26
+ doc = HexaPDF::Document.new(io: io)
27
+ io = StringIO.new(''.b)
28
+
29
+ byte_range = nil
30
+ @handler.signature_size = 5000
31
+ @handler.external_signing = proc {|_, br| byte_range = br; "" }
32
+ doc.signatures.add(io, @handler)
33
+
34
+ io.pos = byte_range[0]
35
+ data = io.read(byte_range[1])
36
+ io.pos = byte_range[2]
37
+ data << io.read(byte_range[3])
38
+ contents = OpenSSL::PKCS7.sign(@handler.certificate, @handler.key, data, @handler.certificate_chain,
39
+ OpenSSL::PKCS7::DETACHED | OpenSSL::PKCS7::BINARY).to_der
40
+ HexaPDF::Document::Signatures.embed_signature(io, contents)
41
+ doc = HexaPDF::Document.new(io: io)
42
+ assert_equal(2, doc.signatures.each.count)
43
+ doc.signatures.each do |signature|
44
+ assert(signature.verify(allow_self_signed: true).messages.find {|m| m.content == 'Signature valid' })
29
45
  end
46
+ end
30
47
 
48
+ describe "DefaultHandler" do
31
49
  it "returns the size of serialized signature" do
32
50
  assert_equal(1310, @handler.signature_size)
51
+ @handler.signature_size = 100
52
+ assert_equal(100, @handler.signature_size)
33
53
  end
34
54
 
35
55
  it "allows setting the DocMDP permissions" do
@@ -56,16 +76,23 @@ describe HexaPDF::Document::Signatures do
56
76
  assert_raises(ArgumentError) { @handler.doc_mdp_permissions = :other }
57
77
  end
58
78
 
59
- it "can sign the data using PKCS7" do
60
- data = "data"
61
- store = OpenSSL::X509::Store.new
62
- store.add_cert(CERTIFICATES.ca_certificate)
79
+ describe "sign" do
80
+ it "can sign the data using PKCS7" do
81
+ data = StringIO.new("data")
82
+ store = OpenSSL::X509::Store.new
83
+ store.add_cert(CERTIFICATES.ca_certificate)
84
+
85
+ pkcs7 = OpenSSL::PKCS7.new(@handler.sign(data, [0, 4, 0, 0]))
86
+ assert(pkcs7.detached?)
87
+ assert_equal([CERTIFICATES.signer_certificate, CERTIFICATES.ca_certificate],
88
+ pkcs7.certificates)
89
+ assert(pkcs7.verify([], store, data.string, OpenSSL::PKCS7::DETACHED | OpenSSL::PKCS7::BINARY))
90
+ end
63
91
 
64
- pkcs7 = OpenSSL::PKCS7.new(@handler.sign(data))
65
- assert(pkcs7.detached?)
66
- assert_equal([CERTIFICATES.signer_certificate, CERTIFICATES.ca_certificate],
67
- pkcs7.certificates)
68
- assert(pkcs7.verify([], store, data, OpenSSL::PKCS7::DETACHED | OpenSSL::PKCS7::BINARY))
92
+ it "can use external signing" do
93
+ @handler.external_signing = proc { "hallo" }
94
+ assert_equal("hallo", @handler.sign(StringIO.new, [0, 0, 0, 0]))
95
+ end
69
96
  end
70
97
 
71
98
  describe "finalize_objects" do
@@ -80,6 +107,12 @@ describe HexaPDF::Document::Signatures do
80
107
  assert(@obj.empty?)
81
108
  end
82
109
 
110
+ it "adjust the /SubFilter if signature type is etsi" do
111
+ @handler.signature_type = :etsi
112
+ @handler.finalize_objects(@field, @obj)
113
+ assert_equal(:'ETSI.CAdES.detached', @obj[:SubFilter])
114
+ end
115
+
83
116
  it "sets the reason, location and contact info fields" do
84
117
  @handler.reason = 'Reason'
85
118
  @handler.location = 'Location'
@@ -111,6 +144,87 @@ describe HexaPDF::Document::Signatures do
111
144
  end
112
145
  end
113
146
 
147
+ describe "TimestampHandler" do
148
+ before do
149
+ @handler = HexaPDF::Document::Signatures::TimestampHandler.new
150
+ end
151
+
152
+ it "allows setting the attributes in the constructor" do
153
+ handler = HexaPDF::Document::Signatures::TimestampHandler.new(
154
+ tsa_url: "url", tsa_hash_algorithm: "MD5", tsa_policy_id: "5",
155
+ reason: "Reason", location: "Location", contact_info: "Contact",
156
+ signature_size: 1_000
157
+ )
158
+ assert_equal("url", handler.tsa_url)
159
+ assert_equal("MD5", handler.tsa_hash_algorithm)
160
+ assert_equal("5", handler.tsa_policy_id)
161
+ assert_equal("Reason", handler.reason)
162
+ assert_equal("Location", handler.location)
163
+ assert_equal("Contact", handler.contact_info)
164
+ assert_equal(1_000, handler.signature_size)
165
+ end
166
+
167
+ it "finalizes the signature field and signature objects" do
168
+ @field = @doc.wrap({})
169
+ @sig = @doc.wrap({})
170
+ @handler.reason = 'Reason'
171
+ @handler.location = 'Location'
172
+ @handler.contact_info = 'Contact'
173
+
174
+ @handler.finalize_objects(@field, @sig)
175
+ assert_equal('2.0', @doc.version)
176
+ assert_equal(:DocTimeStamp, @sig[:Type])
177
+ assert_equal(:'ETSI.RFC3161', @sig[:SubFilter])
178
+ assert_equal('Reason', @sig[:Reason])
179
+ assert_equal('Location', @sig[:Location])
180
+ assert_equal('Contact', @sig[:ContactInfo])
181
+ end
182
+
183
+ it "returns the size of serialized signature" do
184
+ @handler.tsa_url = "http://127.0.0.1:34567"
185
+ CERTIFICATES.start_tsa_server
186
+ assert_equal(1420, @handler.signature_size)
187
+ end
188
+
189
+ describe "sign" do
190
+ before do
191
+ @data = StringIO.new("data")
192
+ @range = [0, 4, 0, 0]
193
+ @handler.tsa_url = "http://127.0.0.1:34567"
194
+ CERTIFICATES.start_tsa_server
195
+ end
196
+
197
+ it "respects the set hash algorithm and policy id" do
198
+ @handler.tsa_hash_algorithm = 'SHA256'
199
+ @handler.tsa_policy_id = '1.2.3.4.2'
200
+ token = OpenSSL::ASN1.decode(@handler.sign(@data, @range))
201
+ content = OpenSSL::ASN1.decode(token.value[1].value[0].value[2].value[1].value[0].value)
202
+ policy_id = content.value[1].value
203
+ digest_algorithm = content.value[2].value[0].value[0].value
204
+ assert_equal('SHA256', digest_algorithm)
205
+ assert_equal("1.2.3.4.2", policy_id)
206
+ end
207
+
208
+ it "returns the serialized timestamp token" do
209
+ token = OpenSSL::PKCS7.new(@handler.sign(@data, @range))
210
+ assert_equal(CERTIFICATES.ca_certificate.subject, token.signers[0].issuer)
211
+ assert_equal(CERTIFICATES.timestamp_certificate.serial, token.signers[0].serial)
212
+ end
213
+
214
+ it "fails if the timestamp token could not be created" do
215
+ @handler.tsa_hash_algorithm = 'SHA1'
216
+ msg = assert_raises(HexaPDF::Error) { @handler.sign(@data, @range) }
217
+ assert_match(/BAD_ALG/, msg.message)
218
+ end
219
+
220
+ it "fails if the timestamp server couldn't process the request" do
221
+ @handler.tsa_policy_id = '1.2.3.4.1'
222
+ msg = assert_raises(HexaPDF::Error) { @handler.sign(@data, @range) }
223
+ assert_match(/Invalid TSA server response/, msg.message)
224
+ end
225
+ end
226
+ end
227
+
114
228
  it "iterates over all signature dictionaries" do
115
229
  assert_equal([], @doc.signatures.to_a)
116
230
  @sig1.field_value = :sig1
@@ -164,7 +278,7 @@ describe HexaPDF::Document::Signatures do
164
278
  sig = @doc.signatures.first
165
279
  assert_equal(:'Adobe.PPKLite', sig[:Filter])
166
280
  assert_equal(:'adbe.pkcs7.detached', sig[:SubFilter])
167
- assert_equal([0, 968, 3590, 2517], sig[:ByteRange].value)
281
+ assert_equal([0, 996, 3618, 2501], sig[:ByteRange].value)
168
282
  assert_equal(:sig, sig[:key])
169
283
  assert_equal(:sig_field, @doc.acro_form.each_field.first[:key])
170
284
  assert(sig.key?(:Contents))
@@ -205,14 +319,14 @@ describe HexaPDF::Document::Signatures do
205
319
  it "handles different xref section types correctly when determing the offsets" do
206
320
  @doc.delete(7)
207
321
  sig = @doc.signatures.add(@io, @handler, write_options: {update_fields: false})
208
- assert_equal([0, 968, 3590, 2491], sig[:ByteRange].value)
322
+ assert_equal([0, 988, 3610, 2483], sig[:ByteRange].value)
209
323
  end
210
324
 
211
325
  it "works if the signature object is the last object of the xref section" do
212
326
  field = @doc.acro_form(create: true).create_signature_field('Signature2')
213
327
  field.create_widget(@doc.pages[0], Rect: [0, 0, 0, 0])
214
328
  sig = @doc.signatures.add(@io, @handler, signature: field, write_options: {update_fields: false})
215
- assert_equal([0, 3063, 5685, 400], sig[:ByteRange].value)
329
+ assert_equal([0, 3095, 5717, 380], sig[:ByteRange].value)
216
330
  end
217
331
 
218
332
  it "allows writing to a file in addition to writing to an IO" do
@@ -228,5 +342,11 @@ describe HexaPDF::Document::Signatures do
228
342
  signed_doc = HexaPDF::Document.new(io: @io)
229
343
  assert(signed_doc.signatures.first.verify)
230
344
  end
345
+
346
+ it "fails if the reserved signature space is too small" do
347
+ def @handler.signature_size; 200; end
348
+ msg = assert_raises(HexaPDF::Error) { @doc.signatures.add(@io, @handler) }
349
+ assert_match(/space.*too small.*200 vs/, msg.message)
350
+ end
231
351
  end
232
352
  end