stationery 0.8.0 → 0.10.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +251 -37
  4. data/lib/stationery/canvas.rb +3 -1
  5. data/lib/stationery/{svg → css}/selector.rb +1 -1
  6. data/lib/stationery/{svg → css}/stylesheet.rb +20 -6
  7. data/lib/stationery/css.rb +20 -0
  8. data/lib/stationery/document.rb +35 -7
  9. data/lib/stationery/elements/forms.rb +11 -6
  10. data/lib/stationery/elements/rich.rb +21 -6
  11. data/lib/stationery/elements.rb +20 -7
  12. data/lib/stationery/errors.rb +4 -0
  13. data/lib/stationery/fonts/fallback.rb +1 -1
  14. data/lib/stationery/fonts/font.rb +28 -18
  15. data/lib/stationery/forms/acro_form.rb +95 -25
  16. data/lib/stationery/forms/appearance.rb +92 -30
  17. data/lib/stationery/forms/field.rb +20 -11
  18. data/lib/stationery/forms/metrics.rb +3 -17
  19. data/lib/stationery/forms/typeface.rb +113 -0
  20. data/lib/stationery/html/css.rb +276 -0
  21. data/lib/stationery/html/document.rb +10 -2
  22. data/lib/stationery/html/tokenizer.rb +15 -4
  23. data/lib/stationery/html/tree_builder/blocks.rb +90 -40
  24. data/lib/stationery/html/tree_builder/collector.rb +9 -3
  25. data/lib/stationery/html/tree_builder.rb +44 -4
  26. data/lib/stationery/layout/flow.rb +29 -0
  27. data/lib/stationery/layout/image.rb +21 -7
  28. data/lib/stationery/layout/mark.rb +2 -0
  29. data/lib/stationery/layout/node.rb +4 -0
  30. data/lib/stationery/layout/table/cell.rb +14 -4
  31. data/lib/stationery/layout/table.rb +72 -14
  32. data/lib/stationery/layout/text.rb +47 -10
  33. data/lib/stationery/markdown/block_parser/containers.rb +21 -0
  34. data/lib/stationery/markdown/block_parser.rb +9 -3
  35. data/lib/stationery/markdown/document.rb +8 -2
  36. data/lib/stationery/markdown/inline_parser/emphasis.rb +17 -7
  37. data/lib/stationery/markdown/inline_parser/links.rb +17 -12
  38. data/lib/stationery/markdown/inline_parser/nodes.rb +24 -10
  39. data/lib/stationery/markdown/inline_parser.rb +9 -3
  40. data/lib/stationery/minitest.rb +4 -0
  41. data/lib/stationery/pdf/assembler.rb +31 -11
  42. data/lib/stationery/pdf/conformance.rb +7 -5
  43. data/lib/stationery/pdf/serializer.rb +3 -1
  44. data/lib/stationery/pdf/signature/cms.rb +95 -0
  45. data/lib/stationery/pdf/signature/timestamp.rb +120 -0
  46. data/lib/stationery/pdf/signature.rb +174 -0
  47. data/lib/stationery/pdf/types.rb +4 -0
  48. data/lib/stationery/pdf/writer.rb +73 -19
  49. data/lib/stationery/rich/nesting.rb +30 -0
  50. data/lib/stationery/rich/nodes.rb +37 -11
  51. data/lib/stationery/rich/renderer/boxes.rb +109 -0
  52. data/lib/stationery/rich/renderer/indents.rb +43 -0
  53. data/lib/stationery/rich/renderer/inlines.rb +9 -2
  54. data/lib/stationery/rich/renderer.rb +49 -16
  55. data/lib/stationery/svg/document.rb +8 -4
  56. data/lib/stationery/svg/parser.rb +51 -11
  57. data/lib/stationery/svg/style.rb +4 -6
  58. data/lib/stationery/svg/walker.rb +2 -0
  59. data/lib/stationery/tagging/element.rb +1 -1
  60. data/lib/stationery/testing/inspector.rb +98 -0
  61. data/lib/stationery/testing/matchers.rb +25 -0
  62. data/lib/stationery/text/breaks.rb +123 -0
  63. data/lib/stationery/text/paragraph.rb +8 -0
  64. data/lib/stationery/text/runs_builder.rb +4 -0
  65. data/lib/stationery/text/wrapper.rb +69 -11
  66. data/lib/stationery/version.rb +1 -1
  67. data/lib/stationery/warnings.rb +13 -0
  68. data/lib/stationery.rb +6 -2
  69. metadata +13 -3
@@ -21,23 +21,30 @@ module Stationery
21
21
  end
22
22
  end
23
23
 
24
- # Wraps matched delimiter pairs (and what lies between them) in place.
24
+ # Wraps matched delimiter pairs (and what lies between them) in place. `bottoms` remembers, per
25
+ # kind of closer, the index below which no opener is left for it, so a run of closers that
26
+ # nothing opens is not searched for again and again (CommonMark's openers_bottom).
25
27
  def process(nodes)
28
+ bottoms = Hash.new(0)
26
29
  index = 0
27
30
  while index < nodes.size
28
31
  closer = nodes[index]
29
- opener = closer?(closer) && opener_for(nodes, index)
30
- next index += 1 unless opener
32
+ next index += 1 unless closer?(closer)
31
33
 
32
- index = wrap(nodes, opener, index)
34
+ opening = opener_for(nodes, index, bottoms[kind(closer)])
35
+ next index = wrap(nodes, opening, index, bottoms) if opening
36
+
37
+ bottoms[kind(closer)] = index
38
+ index += 1
33
39
  end
34
40
  end
35
41
 
36
- def wrap(nodes, opening, index)
42
+ def wrap(nodes, opening, index, bottoms)
37
43
  opener = nodes[opening]
38
44
  closer = nodes[index]
39
45
  used = used(opener, closer)
40
46
  nodes[(opening + 1)...index] = [Nodes::Wrap.new(marks(closer.char, used), nodes[(opening + 1)...index])]
47
+ bottoms.each { |kind, bottom| bottoms[kind] = opening + 1 if bottom > opening + 1 }
41
48
  opener.remaining -= used
42
49
  closer.remaining -= used
43
50
  index = opening + 2
@@ -49,9 +56,9 @@ module Stationery
49
56
  index
50
57
  end
51
58
 
52
- def opener_for(nodes, index)
59
+ def opener_for(nodes, index, bottom)
53
60
  closer = nodes[index]
54
- (index - 1).downto(0).find do |i|
61
+ (index - 1).downto(bottom).find do |i|
55
62
  node = nodes[i]
56
63
  node.is_a?(Nodes::Delimiter) && node.opener && node.char == closer.char && node.remaining.positive? &&
57
64
  (closer.char != "~" || node.remaining == closer.remaining)
@@ -60,6 +67,9 @@ module Stationery
60
67
 
61
68
  def closer?(node) = node.is_a?(Nodes::Delimiter) && node.closer && node.remaining.positive?
62
69
 
70
+ # What decides which openers suit a closer: its character, and for `~` its length too.
71
+ def kind(closer) = closer.char == "~" ? [closer.char, closer.remaining] : closer.char
72
+
63
73
  def used(opener, closer)
64
74
  return closer.remaining if closer.char == "~"
65
75
 
@@ -3,9 +3,14 @@
3
3
  module Stationery
4
4
  module Markdown
5
5
  class InlineParser
6
- # Brackets, inline and reference links, images and autolinks.
6
+ # Brackets, inline and reference links, images and autolinks. At most OPEN brackets wait for
7
+ # their `]` at once (the oldest gives way), and a link label is at most LABEL characters as
8
+ # CommonMark has it, so neither nesting nor the work per `]` grows with the input. A bracket's
9
+ # `position` is in bytes, which the scanner knows without counting characters.
7
10
  module Links
8
11
  Bracket = Struct.new(:index, :image, :active, :position)
12
+ OPEN = 64
13
+ LABEL = 999
9
14
 
10
15
  INLINE = /
11
16
  \(\s*
@@ -23,7 +28,8 @@ module Stationery
23
28
  private
24
29
 
25
30
  def open_bracket(image: false)
26
- @brackets << Bracket.new(@nodes.size, image, true, @scanner.charpos)
31
+ @brackets.shift if @brackets.size >= OPEN
32
+ @brackets << Bracket.new(@nodes.size, image, true, @scanner.pos)
27
33
  add(image ? "![" : "[")
28
34
  end
29
35
 
@@ -31,7 +37,7 @@ module Stationery
31
37
  bracket = @brackets.pop
32
38
  return add("]") unless bracket&.active
33
39
 
34
- href = inline_destination || reference(@source[bracket.position...(@scanner.charpos - 1)])
40
+ href = inline_destination || reference(bracket.position...(@scanner.pos - 1))
35
41
  return add("]") unless href
36
42
 
37
43
  children = @nodes.slice!(bracket.index..).drop(1)
@@ -48,18 +54,17 @@ module Stationery
48
54
  InlineParser.destination(@scanner[1] || @scanner[2])
49
55
  end
50
56
 
51
- def reference(label)
57
+ # The target of `[text][label]`, `[text][]` or `[text]`, `span` being where the text lies.
58
+ def reference(span)
52
59
  position = @scanner.pos
53
- if @scanner.scan(REFERENCE)
54
- label = @scanner[1] unless @scanner[1].empty?
55
- href = @refs[InlineParser.label(label)]
56
- @scanner.pos = position unless href
57
- href
58
- else
59
- @refs[InlineParser.label(label)]
60
- end
60
+ label = @scanner[1] if @scanner.scan(REFERENCE) && !@scanner[1].empty?
61
+ href = defined_as(label || (@source.byteslice(span) if span.size <= LABEL * 4))
62
+ @scanner.pos = position unless href
63
+ href
61
64
  end
62
65
 
66
+ def defined_as(label) = label && label.length <= LABEL && @refs[InlineParser.label(label)]
67
+
63
68
  def autolink
64
69
  url = @scanner[1] || "mailto:#{@scanner[2]}"
65
70
  @nodes << Nodes::Wrap.new({ link: url }, [Nodes::Text.new(@scanner[1] || @scanner[2])])
@@ -14,20 +14,34 @@ module Stationery
14
14
 
15
15
  module_function
16
16
 
17
- def flatten(nodes, marks = {}, out = [])
18
- nodes.each do |node|
19
- case node
20
- when Text then out << Rich::Inline.new(text: node.text, marks:)
21
- when Delimiter then out << Rich::Inline.new(text: node.char * node.remaining, marks:)
22
- when Code then out << Rich::Inline.new(text: node.text, marks: marks.merge(code: true))
23
- when Break then out << Rich::Inline.break
24
- when Wrap then flatten(node.children, marks.merge(node.marks), out)
25
- when Picture then out << picture(node)
26
- end
17
+ # The tree as a flat list of inlines, each with the marks of the wraps around it. Walked with
18
+ # a stack of its own, not by recursion: emphasis nests as deep as its author types asterisks.
19
+ def flatten(nodes, marks = {})
20
+ out = []
21
+ pending = [[nodes, 0, marks]]
22
+ until pending.empty?
23
+ level = pending.last
24
+ node = level[0][level[1]]
25
+ next pending.pop unless node
26
+
27
+ level[1] += 1
28
+ next pending << [node.children, 0, level[2].merge(node.marks)] if node.is_a?(Wrap)
29
+
30
+ out << inline(node, level[2])
27
31
  end
28
32
  out
29
33
  end
30
34
 
35
+ def inline(node, marks)
36
+ case node
37
+ when Text then Rich::Inline.new(text: node.text, marks:)
38
+ when Delimiter then Rich::Inline.new(text: node.char * node.remaining, marks:)
39
+ when Code then Rich::Inline.new(text: node.text, marks: marks.merge(code: true))
40
+ when Break then Rich::Inline.break
41
+ when Picture then picture(node)
42
+ end
43
+ end
44
+
31
45
  def picture(node) = Rich::Image.new(src: node.src, alt: plain(node.children), width: nil, height: nil)
32
46
 
33
47
  def plain(nodes)
@@ -79,10 +79,16 @@ module Stationery
79
79
  end
80
80
 
81
81
  def delimiter
82
- position = @scanner.charpos
82
+ before = previous_character
83
83
  run = @scanner.scan(/\*+|_+|~+/)
84
- before = position.zero? ? " " : @source[position - 1]
85
- @nodes << Emphasis.delimiter(run, before, @source[@scanner.charpos] || " ")
84
+ @nodes << Emphasis.delimiter(run, before, @scanner.check(/./m) || " ")
85
+ end
86
+
87
+ # The character before the scanner, read from the few bytes before it: a character position
88
+ # is counted from the start of the source, which a long paragraph would pay for at every run.
89
+ def previous_character
90
+ position = @scanner.pos
91
+ @source.byteslice([position - 4, 0].max...position).scrub("")[-1] || " "
86
92
  end
87
93
  end
88
94
  end
@@ -34,6 +34,10 @@ module Stationery
34
34
  assert_pdf(Matchers::HaveFacturX.new(profile:), subject, msg)
35
35
  end
36
36
 
37
+ def assert_pdf_signature(subject, msg = nil, name: nil, valid: true)
38
+ assert_pdf(Matchers::HaveSignature.new(name:, valid:), subject, msg)
39
+ end
40
+
37
41
  def assert_no_pdf_warnings(subject, msg = nil) = assert_pdf(Matchers::HaveNoWarnings.new, subject, msg)
38
42
  def assert_pdf_structure(subject, tree, msg = nil) = assert_pdf(Matchers::HaveStructure.new(tree), subject, msg)
39
43
  def assert_tagged_content(subject, msg = nil) = assert_pdf(Matchers::HaveTaggedContent.new, subject, msg)
@@ -10,12 +10,20 @@ module Stationery
10
10
  # PDF::Attachment files to embed; `xmp:` (true) writes the XMP packet,
11
11
  # with `xmp_extensions:` as further schemas and `xmp_schemas:` describing
12
12
  # them to PDF/A (see XMP); `conformance:` (a PDF::Conformance) adds what
13
- # PDF/A and PDF/UA ask of the file.
13
+ # PDF/A and PDF/UA ask of the file; `signature:` (a PDF::Signature)
14
+ # signs it. `sink:` (anything answering `call(bytes)`) receives the file
15
+ # in pieces as each page is written, in the order the objects are
16
+ # flushed, instead of one String at the end; a signature needs the
17
+ # finished file, so it cannot be streamed.
14
18
  def initialize(pages:, resources:, info: {}, outline: [], encryption: nil, tagging: nil, lang: nil,
15
19
  page_labels: nil, attachments: [], xmp: true, xmp_extensions: {}, xmp_schemas: [],
16
- conformance: nil)
20
+ conformance: nil, signature: nil, sink: nil)
21
+ raise ArgumentError, "a signed document cannot be streamed: sign needs the whole file" if signature && sink
22
+
23
+ @sink = sink
17
24
  @xmp_schemas = xmp_schemas
18
25
  @conformance = conformance
26
+ @signature = signature
19
27
  @tagging = tagging
20
28
  @lang = lang
21
29
  @page_labels = page_labels
@@ -29,14 +37,19 @@ module Stationery
29
37
  @encryption = encryption
30
38
  end
31
39
 
40
+ # The file as a String, or the number of bytes streamed to the sink.
32
41
  def render
33
- writer = Writer.new(encryption: @encryption)
34
- @form = Forms::AcroForm.new(writer)
42
+ writer = Writer.new(encryption: @encryption, sink: @sink)
35
43
  tree = writer.reserve
36
44
  refs = @resources.build(writer)
45
+ @form = Forms::AcroForm.new(writer, fonts: refs.fetch(:Font), signature: @signature,
46
+ need_appearances: @conformance.nil? && @signature.nil?)
37
47
  kids = @kids = @pages.map { writer.reserve }
38
48
  @structure = @tagging && Tagging::Writer.new(@tagging, pages: @pages, refs: kids)
39
- @pages.each_with_index { |page, index| write_page(writer, page, kids[index], tree, refs) }
49
+ @pages.each_with_index do |page, index|
50
+ write_page(writer, page, kids[index], tree, refs)
51
+ writer.flush
52
+ end
40
53
  writer.set(tree, { Type: :Pages, Kids: kids, Count: kids.size })
41
54
  outlines = OutlineWriter.new(writer, @outline, kids).write
42
55
  now = Time.now
@@ -44,7 +57,8 @@ module Stationery
44
57
  entries = catalog(tree, outlines, @form.write)
45
58
  .merge(accessibility(writer), metadata(writer, info, now), Attachments.write(writer, @attachments))
46
59
  entries.merge!(@conformance.catalog_entries(writer)) if @conformance
47
- writer.render(root: writer.add(entries), info: writer.add(info))
60
+ pdf = writer.render(root: writer.add(entries), info: writer.add(info))
61
+ @signature ? @signature.apply(pdf) : pdf
48
62
  end
49
63
 
50
64
  private
@@ -69,14 +83,20 @@ module Stationery
69
83
  Type: :Page, Parent: tree, MediaBox: [0, 0, *page.size],
70
84
  Contents: writer.add(Stream.new(page.content)), Resources: page_resources(page, refs)
71
85
  }
72
- if page.annotations.any?
73
- dictionary[:Annots] = page.annotations.map { |annot| annotation_ref(writer, annot, ref) }
74
- end
86
+ annotations = page.annotations.map { |annot| annotation_ref(writer, annot, ref) }
87
+ annotations << @form.sign(ref, taken: field_names) if @signature&.invisible? && ref == @kids.first
88
+ dictionary[:Annots] = annotations if annotations.any?
75
89
  dictionary.merge!(@structure.page_entries(page)) if @structure
76
- dictionary.merge!(@conformance.page_entries(page)) if @conformance
90
+ dictionary.merge!(@conformance.page_entries(page, annotated: annotations.any?)) if @conformance
77
91
  writer.set(ref, dictionary)
78
92
  end
79
93
 
94
+ # Every field name of the document and the group each one starts with.
95
+ def field_names
96
+ names = @pages.flat_map(&:annotations).filter_map { |annotation| annotation[:widget]&.name }
97
+ names.flat_map { |name| [name, name.split(".").first] }.uniq
98
+ end
99
+
80
100
  def page_resources(page, refs)
81
101
  page.resource_names.to_h do |category, names|
82
102
  [category, names.to_h { |name| [name, refs.fetch(category).fetch(name)] }]
@@ -85,7 +105,7 @@ module Stationery
85
105
 
86
106
  def annotation_ref(writer, annotation, page)
87
107
  if annotation[:widget]
88
- return @form.add(annotation[:widget], annotation[:rect], page) do |widget_ref|
108
+ return @form.add(annotation, page) do |widget_ref|
89
109
  @structure ? @structure.annotation(annotation, widget_ref) : {}
90
110
  end
91
111
  end
@@ -92,7 +92,8 @@ module Stationery
92
92
  end
93
93
 
94
94
  # A page's entries: PDF/UA tabs through annotations in structure order.
95
- def page_entries(page) = pdf_ua? && page.annotations.any? ? { Tabs: :S } : {}
95
+ # `annotated` is whether the page has any, its own or a signature's.
96
+ def page_entries(page, annotated: page.annotations.any?) = pdf_ua? && annotated ? { Tabs: :S } : {}
96
97
 
97
98
  # A link annotation's entries: printable, and described for PDF/UA.
98
99
  def annotation_entries(link)
@@ -110,11 +111,12 @@ module Stationery
110
111
  link[:url] || "Page #{link[:dest].page + 1}"
111
112
  end
112
113
 
113
- # Form fields draw with the standard Helvetica and ZapfDingbats, which
114
- # are not embedded, and ask the viewer to regenerate appearances:
115
- # neither PDF/A nor PDF/UA accepts that.
114
+ # A form field draws with the document's embedded fonts; one made
115
+ # without a font book draws with the standard Helvetica, which is not
116
+ # embedded: neither PDF/A nor PDF/UA accepts that.
116
117
  def fields(pages)
117
- names = pages.flat_map(&:annotations).filter_map { |annotation| annotation[:widget]&.name }.uniq
118
+ widgets = pages.flat_map(&:annotations).select { |annotation| annotation[:widget] }
119
+ names = widgets.reject { |annotation| annotation[:appearance].embedded? }.map { |a| a[:widget].name }.uniq
118
120
  names.map { |name| %(form field "#{name}" draws with a font that is not embedded) }
119
121
  end
120
122
 
@@ -9,7 +9,8 @@ module Stationery
9
9
  # TextString for human-readable text that may need UTF-16.
10
10
  #
11
11
  # `crypt`, when given, receives every string's bytes (the writer binds it
12
- # to the object being written) and the result is written as hex.
12
+ # to the object being written) and the result is written as hex. A
13
+ # Verbatim is written as it is.
13
14
  module Serializer
14
15
  NAME_ESCAPE = %r{[^\x21-\x7E]|[#%()/<>\[\]{}]}n
15
16
  LITERAL_ESCAPE = /[\\()\r]/n
@@ -25,6 +26,7 @@ module Stationery
25
26
  when Array then "[#{value.map { |v| dump(v, crypt) }.join(" ")}]"
26
27
  when HexString then hex(value.bytes, crypt)
27
28
  when TextString then text(value.value, crypt)
29
+ when Verbatim then value.source
28
30
  when String then crypt ? hex(value, crypt) : literal(value)
29
31
  when Integer, true, false then value.to_s
30
32
  when Float then number(value)
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module PDF
5
+ class Signature
6
+ # The signature value: a detached CMS SignedData (RFC 5652) over the
7
+ # signed bytes, as CAdES and PAdES baseline ask for it. The signed
8
+ # attributes are the content type, the SHA-256 message digest and the
9
+ # ESS signing-certificate-v2 (RFC 5035) that binds the signature to the
10
+ # signer's certificate; there is no signing-time attribute, a PDF keeps
11
+ # that in the signature dictionary's /M.
12
+ #
13
+ # A timestamp token over the signature value (see Timestamp) goes into
14
+ # the unsigned attributes, which makes the signature PAdES baseline B-T.
15
+ #
16
+ # Ruby's OpenSSL::PKCS7 cannot add signed attributes, so the structure
17
+ # is built with OpenSSL::ASN1. RSA keys sign with PKCS #1 v1.5, EC keys
18
+ # with ECDSA, both over SHA-256.
19
+ class CMS
20
+ OIDS = {
21
+ data: "1.2.840.113549.1.7.1", signed_data: "1.2.840.113549.1.7.2",
22
+ content_type: "1.2.840.113549.1.9.3", message_digest: "1.2.840.113549.1.9.4",
23
+ signing_certificate_v2: "1.2.840.113549.1.9.16.2.47", sha256: "2.16.840.1.101.3.4.2.1",
24
+ signature_timestamp_token: "1.2.840.113549.1.9.16.2.14",
25
+ rsa: "1.2.840.113549.1.1.1", ecdsa_sha256: "1.2.840.10045.4.3.2"
26
+ }.freeze
27
+
28
+ # `chain` are the certificates between the signer's and its root.
29
+ def initialize(certificate, key, chain = [])
30
+ @certificate = certificate
31
+ @key = key
32
+ @chain = chain
33
+ end
34
+
35
+ # The DER of the SignedData signing `data`. `timestamp:` is a callable
36
+ # given the signature value and answering a timestamp token (DER).
37
+ def sign(data, timestamp: nil)
38
+ attributes = attributes(OpenSSL::Digest::SHA256.digest(data))
39
+ signature = @key.sign("SHA256", asn1::Set.new(attributes).to_der)
40
+ unsigned = timestamp && [attribute(:signature_timestamp_token, asn1.decode(timestamp.call(signature)))]
41
+ content = asn1::Sequence.new([
42
+ asn1::Integer.new(1),
43
+ asn1::Set.new([algorithm(:sha256)]),
44
+ asn1::Sequence.new([oid(:data)]),
45
+ implicit(certificates),
46
+ asn1::Set.new([signer(attributes, signature, unsigned)])
47
+ ])
48
+ asn1::Sequence.new([oid(:signed_data), asn1::ASN1Data.new([content], 0, :CONTEXT_SPECIFIC)]).to_der
49
+ end
50
+
51
+ private
52
+
53
+ def asn1 = OpenSSL::ASN1
54
+ def oid(name) = asn1::ObjectId.new(OIDS.fetch(name))
55
+ def implicit(items, tag = 0) = asn1::Set.new(items, tag, :IMPLICIT, :CONTEXT_SPECIFIC)
56
+ def certificates = [@certificate, *@chain].map { |certificate| asn1.decode(certificate.to_der) }
57
+
58
+ # RSA and the digests carry an explicit NULL parameter, ECDSA none.
59
+ def algorithm(name, null: true) = asn1::Sequence.new(null ? [oid(name), asn1::Null.new(nil)] : [oid(name)])
60
+
61
+ def attribute(name, value) = asn1::Sequence.new([oid(name), asn1::Set.new([value])])
62
+
63
+ # DER orders the members of a SET OF by their encoding.
64
+ def attributes(digest)
65
+ [
66
+ attribute(:content_type, oid(:data)),
67
+ attribute(:message_digest, asn1::OctetString.new(digest)),
68
+ attribute(:signing_certificate_v2, signing_certificate)
69
+ ].sort_by(&:to_der)
70
+ end
71
+
72
+ # SigningCertificateV2 with one ESSCertIDv2: the certificate's SHA-256
73
+ # (the default hash algorithm, so it is left out).
74
+ def signing_certificate
75
+ hash = asn1::OctetString.new(OpenSSL::Digest::SHA256.digest(@certificate.to_der))
76
+ asn1::Sequence.new([asn1::Sequence.new([asn1::Sequence.new([hash])])])
77
+ end
78
+
79
+ def signer(attributes, signature, unsigned)
80
+ issuer = asn1.decode(@certificate.issuer.to_der)
81
+ info = [
82
+ asn1::Integer.new(1),
83
+ asn1::Sequence.new([issuer, asn1::Integer.new(@certificate.serial)]),
84
+ algorithm(:sha256),
85
+ implicit(attributes),
86
+ @key.is_a?(OpenSSL::PKey::EC) ? algorithm(:ecdsa_sha256, null: false) : algorithm(:rsa),
87
+ asn1::OctetString.new(signature)
88
+ ]
89
+ info << implicit(unsigned, 1) if unsigned
90
+ asn1::Sequence.new(info)
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module PDF
5
+ class Signature
6
+ # A signature timestamp from an RFC 3161 time-stamping authority (TSA):
7
+ # the TSA signs the digest of the signature value and the time it saw
8
+ # it, and the token goes into the signature's unsigned attributes, which
9
+ # makes it PAdES baseline B-T. A reader can then trust the signing time
10
+ # even after the signer's certificate expires.
11
+ #
12
+ # `sign timestamp: "https://tsa.example/tsr"`, or a Hash with `url:`,
13
+ # `username:`/`password:` for a TSA that wants HTTP basic auth, `hash:`
14
+ # (:sha256, the default, :sha384 or :sha512) and `client:`, a callable
15
+ # given the request DER and answering the response DER, in place of
16
+ # the built-in HTTP client. Anything short of a token over exactly what
17
+ # was asked raises SignatureError: a document is never quietly written
18
+ # without the timestamp it was asked for.
19
+ class Timestamp
20
+ OPTIONS = %i[url username password hash client].freeze
21
+ DIGESTS = { sha256: "SHA256", sha384: "SHA384", sha512: "SHA512" }.freeze
22
+ # Status values a TSA answers with when it did grant the token
23
+ # (granted, grantedWithMods).
24
+ GRANTED = [0, 1].freeze
25
+ REQUEST_TYPE = "application/timestamp-query"
26
+ REPLY_TYPE = "application/timestamp-reply"
27
+
28
+ attr_reader :url, :hash
29
+
30
+ # The timestamp for `spec`: a URL String, a Hash of OPTIONS, or nil.
31
+ def self.for(spec)
32
+ return unless spec
33
+
34
+ spec = { url: spec.to_s } unless spec.is_a?(Hash)
35
+ spec = spec.transform_keys(&:to_sym)
36
+ unknown = spec.keys - OPTIONS
37
+ unless unknown.empty?
38
+ raise ArgumentError, "unknown timestamp option: #{unknown.join(", ")} (use #{OPTIONS.join(", ")})"
39
+ end
40
+
41
+ new(**spec)
42
+ end
43
+
44
+ def initialize(url: nil, username: nil, password: nil, hash: :sha256, client: nil)
45
+ raise ArgumentError, "sign timestamp: needs the TSA's url:" if url.to_s.empty? && client.nil?
46
+
47
+ @url = url&.to_s
48
+ @username = username
49
+ @password = password
50
+ @hash = hash.to_sym
51
+ @client = client
52
+ return if DIGESTS[@hash]
53
+
54
+ raise ArgumentError,
55
+ "timestamp hash: is #{DIGESTS.keys.join(", ")}, got #{hash.inspect}"
56
+ end
57
+
58
+ # The timestamp token (a ContentInfo, DER) the TSA issued over
59
+ # `signature`, checked against what was asked.
60
+ def token(signature)
61
+ request = request_for(OpenSSL::Digest.digest(DIGESTS.fetch(@hash), signature))
62
+ response = parse((@client || method(:post)).call(request.to_der))
63
+ check!(response, request)
64
+ response.token.to_der
65
+ end
66
+
67
+ private
68
+
69
+ def request_for(digest)
70
+ request = OpenSSL::Timestamp::Request.new
71
+ request.algorithm = DIGESTS.fetch(@hash)
72
+ request.message_imprint = digest
73
+ request.nonce = OpenSSL::BN.rand(64)
74
+ request.cert_requested = true
75
+ request
76
+ end
77
+
78
+ def parse(der)
79
+ OpenSSL::Timestamp::Response.new(der.to_s)
80
+ rescue OpenSSL::Timestamp::TimestampError, TypeError => e
81
+ raise SignatureError, "#{tsa} did not answer with a timestamp response: #{e.message}"
82
+ end
83
+
84
+ def check!(response, request)
85
+ status = response.status.to_i
86
+ unless GRANTED.include?(status) && response.token
87
+ text = [*response.status_text, response.failure_info].compact.map(&:to_s).reject(&:empty?)
88
+ raise SignatureError,
89
+ "#{tsa} refused the timestamp (status #{status}#{": #{text.join(", ")}" unless text.empty?})"
90
+ end
91
+
92
+ info = response.token_info
93
+ raise SignatureError, "#{tsa} answered another request (nonce differs)" unless info.nonce == request.nonce
94
+ return if info.message_imprint == request.message_imprint
95
+
96
+ raise SignatureError, "#{tsa} timestamped something else (message imprint differs)"
97
+ end
98
+
99
+ def tsa = "timestamp: #{@url || "the TSA"}"
100
+
101
+ # POSTs the request as RFC 3161 over HTTP asks for it.
102
+ def post(body)
103
+ require "net/http"
104
+ uri = URI.parse(@url)
105
+ request = Net::HTTP::Post.new(uri)
106
+ request["Content-Type"] = REQUEST_TYPE
107
+ request["Accept"] = REPLY_TYPE
108
+ request.basic_auth(@username, @password.to_s) if @username
109
+ request.body = body
110
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
111
+ raise SignatureError, "#{tsa} answered HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
112
+
113
+ response.body
114
+ rescue SystemCallError, IOError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError => e
115
+ raise SignatureError, "#{tsa} could not be reached: #{e.message}"
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end