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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +251 -37
- data/lib/stationery/canvas.rb +3 -1
- data/lib/stationery/{svg → css}/selector.rb +1 -1
- data/lib/stationery/{svg → css}/stylesheet.rb +20 -6
- data/lib/stationery/css.rb +20 -0
- data/lib/stationery/document.rb +35 -7
- data/lib/stationery/elements/forms.rb +11 -6
- data/lib/stationery/elements/rich.rb +21 -6
- data/lib/stationery/elements.rb +20 -7
- data/lib/stationery/errors.rb +4 -0
- data/lib/stationery/fonts/fallback.rb +1 -1
- data/lib/stationery/fonts/font.rb +28 -18
- data/lib/stationery/forms/acro_form.rb +95 -25
- data/lib/stationery/forms/appearance.rb +92 -30
- data/lib/stationery/forms/field.rb +20 -11
- data/lib/stationery/forms/metrics.rb +3 -17
- data/lib/stationery/forms/typeface.rb +113 -0
- data/lib/stationery/html/css.rb +276 -0
- data/lib/stationery/html/document.rb +10 -2
- data/lib/stationery/html/tokenizer.rb +15 -4
- data/lib/stationery/html/tree_builder/blocks.rb +90 -40
- data/lib/stationery/html/tree_builder/collector.rb +9 -3
- data/lib/stationery/html/tree_builder.rb +44 -4
- data/lib/stationery/layout/flow.rb +29 -0
- data/lib/stationery/layout/image.rb +21 -7
- data/lib/stationery/layout/mark.rb +2 -0
- data/lib/stationery/layout/node.rb +4 -0
- data/lib/stationery/layout/table/cell.rb +14 -4
- data/lib/stationery/layout/table.rb +72 -14
- data/lib/stationery/layout/text.rb +47 -10
- data/lib/stationery/markdown/block_parser/containers.rb +21 -0
- data/lib/stationery/markdown/block_parser.rb +9 -3
- data/lib/stationery/markdown/document.rb +8 -2
- data/lib/stationery/markdown/inline_parser/emphasis.rb +17 -7
- data/lib/stationery/markdown/inline_parser/links.rb +17 -12
- data/lib/stationery/markdown/inline_parser/nodes.rb +24 -10
- data/lib/stationery/markdown/inline_parser.rb +9 -3
- data/lib/stationery/minitest.rb +4 -0
- data/lib/stationery/pdf/assembler.rb +31 -11
- data/lib/stationery/pdf/conformance.rb +7 -5
- data/lib/stationery/pdf/serializer.rb +3 -1
- data/lib/stationery/pdf/signature/cms.rb +95 -0
- data/lib/stationery/pdf/signature/timestamp.rb +120 -0
- data/lib/stationery/pdf/signature.rb +174 -0
- data/lib/stationery/pdf/types.rb +4 -0
- data/lib/stationery/pdf/writer.rb +73 -19
- data/lib/stationery/rich/nesting.rb +30 -0
- data/lib/stationery/rich/nodes.rb +37 -11
- data/lib/stationery/rich/renderer/boxes.rb +109 -0
- data/lib/stationery/rich/renderer/indents.rb +43 -0
- data/lib/stationery/rich/renderer/inlines.rb +9 -2
- data/lib/stationery/rich/renderer.rb +49 -16
- data/lib/stationery/svg/document.rb +8 -4
- data/lib/stationery/svg/parser.rb +51 -11
- data/lib/stationery/svg/style.rb +4 -6
- data/lib/stationery/svg/walker.rb +2 -0
- data/lib/stationery/tagging/element.rb +1 -1
- data/lib/stationery/testing/inspector.rb +98 -0
- data/lib/stationery/testing/matchers.rb +25 -0
- data/lib/stationery/text/breaks.rb +123 -0
- data/lib/stationery/text/paragraph.rb +8 -0
- data/lib/stationery/text/runs_builder.rb +4 -0
- data/lib/stationery/text/wrapper.rb +69 -11
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +13 -0
- data/lib/stationery.rb +6 -2
- 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
|
-
|
|
30
|
-
next index += 1 unless opener
|
|
32
|
+
next index += 1 unless closer?(closer)
|
|
31
33
|
|
|
32
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
82
|
+
before = previous_character
|
|
83
83
|
run = @scanner.scan(/\*+|_+|~+/)
|
|
84
|
-
|
|
85
|
-
|
|
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
|
data/lib/stationery/minitest.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
114
|
-
#
|
|
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
|
-
|
|
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
|