stationery 0.9.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 +13 -0
- data/README.md +143 -34
- 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 +21 -9
- data/lib/stationery/elements/rich.rb +10 -2
- data/lib/stationery/elements.rb +9 -4
- data/lib/stationery/errors.rb +4 -0
- data/lib/stationery/fonts/fallback.rb +1 -1
- data/lib/stationery/html/css.rb +276 -0
- data/lib/stationery/html/document.rb +5 -3
- 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 +11 -1
- data/lib/stationery/layout/flow.rb +29 -0
- data/lib/stationery/layout/image.rb +12 -6
- data/lib/stationery/layout/mark.rb +2 -0
- data/lib/stationery/layout/node.rb +4 -0
- data/lib/stationery/layout/text.rb +47 -10
- data/lib/stationery/pdf/assembler.rb +14 -4
- data/lib/stationery/pdf/signature/cms.rb +21 -13
- data/lib/stationery/pdf/signature/timestamp.rb +120 -0
- data/lib/stationery/pdf/signature.rb +16 -9
- data/lib/stationery/pdf/writer.rb +73 -19
- data/lib/stationery/rich/nodes.rb +37 -11
- data/lib/stationery/rich/renderer/boxes.rb +109 -0
- data/lib/stationery/rich/renderer/indents.rb +1 -0
- data/lib/stationery/rich/renderer/inlines.rb +9 -2
- data/lib/stationery/rich/renderer.rb +32 -15
- data/lib/stationery/svg/style.rb +4 -6
- data/lib/stationery/testing/inspector.rb +49 -6
- 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 +57 -8
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +9 -0
- data/lib/stationery.rb +3 -2
- metadata +8 -3
|
@@ -4,7 +4,8 @@ module Stationery
|
|
|
4
4
|
module Layout
|
|
5
5
|
# An image sized by width, height, both, or a box to fit into (aspect
|
|
6
6
|
# preserved), never wider than the space it is given. One pixel is one
|
|
7
|
-
# point when no size is given
|
|
7
|
+
# point when no size is given; a Float width up to 1 is a fraction of the
|
|
8
|
+
# space (`width: 0.5`), as for boxes and columns. `fit: :cover` fills `width:` × `height:`
|
|
8
9
|
# instead, scaling up and clipping the excess around the centre.
|
|
9
10
|
# `radius:` clips to rounded corners and `rotate:` (degrees, clockwise)
|
|
10
11
|
# turns the painted image around the centre of its rectangle; neither
|
|
@@ -37,14 +38,16 @@ module Stationery
|
|
|
37
38
|
end
|
|
38
39
|
|
|
39
40
|
def size(available)
|
|
40
|
-
width, height = requested
|
|
41
|
+
width, height = requested(available)
|
|
41
42
|
return [width, height] if width <= available
|
|
42
43
|
|
|
43
44
|
[available, height * available / width]
|
|
44
45
|
end
|
|
45
46
|
|
|
46
47
|
def measure(width) = size(width)[1]
|
|
47
|
-
|
|
48
|
+
# A fractional width is a share of the space offered here; measuring and
|
|
49
|
+
# painting are then given the width that came out of it.
|
|
50
|
+
def fixed_width(available) = fraction? ? tidy(available * @width) : size(available)[0]
|
|
48
51
|
def natural_width = requested[0]
|
|
49
52
|
def min_width = 0
|
|
50
53
|
|
|
@@ -100,19 +103,22 @@ module Stationery
|
|
|
100
103
|
@image
|
|
101
104
|
end
|
|
102
105
|
|
|
103
|
-
def requested
|
|
106
|
+
def requested(available = nil)
|
|
104
107
|
iw = @image.width.to_f
|
|
105
108
|
ih = @image.height.to_f
|
|
109
|
+
width = fraction? ? (available || tidy(iw * @width)) : @width
|
|
106
110
|
if @fit.is_a?(Array)
|
|
107
111
|
scale = [@fit[0] / iw, @fit[1] / ih].min
|
|
108
112
|
[iw * scale, ih * scale].map { |v| tidy(v) }
|
|
109
|
-
elsif
|
|
110
|
-
elsif
|
|
113
|
+
elsif width && @height then [width, @height]
|
|
114
|
+
elsif width then [width, tidy(ih * width / iw)]
|
|
111
115
|
elsif @height then [tidy(iw * @height / ih), @height]
|
|
112
116
|
else [@image.width, @image.height]
|
|
113
117
|
end
|
|
114
118
|
end
|
|
115
119
|
|
|
120
|
+
def fraction? = @width.is_a?(Float) && @width <= 1
|
|
121
|
+
|
|
116
122
|
def tidy(value) = value.round(6)
|
|
117
123
|
end
|
|
118
124
|
end
|
|
@@ -31,6 +31,8 @@ module Stationery
|
|
|
31
31
|
def fixed_width(available) = @child.fixed_width(available)
|
|
32
32
|
def splittable? = @child.splittable?
|
|
33
33
|
def page_break? = @child.page_break?
|
|
34
|
+
def breaks? = @child.breaks?
|
|
35
|
+
def leading_break? = @child.leading_break?
|
|
34
36
|
def avoid_break? = @child.avoid_break?
|
|
35
37
|
def keep_with_next = @child.keep_with_next
|
|
36
38
|
|
|
@@ -42,6 +42,10 @@ module Stationery
|
|
|
42
42
|
# The width this node is laid out at inside a parent of `available`.
|
|
43
43
|
def width_in(available) = fixed_width(available) || available
|
|
44
44
|
def page_break? = false
|
|
45
|
+
# Whether a page break lies somewhere inside (a flow holding one).
|
|
46
|
+
def breaks? = false
|
|
47
|
+
# Whether it starts with a page break (a flow whose first node is one).
|
|
48
|
+
def leading_break? = false
|
|
45
49
|
|
|
46
50
|
def split(width, height, **)
|
|
47
51
|
measure(width) <= height + EPSILON ? [self, nil] : [nil, self]
|
|
@@ -2,21 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
module Stationery
|
|
4
4
|
module Layout
|
|
5
|
-
# A paragraph of styled runs. Splits between lines
|
|
5
|
+
# A paragraph of styled runs. Splits between lines; `orphans:` is the
|
|
6
|
+
# fewest lines a split leaves at the foot of a page and `widows:` the
|
|
7
|
+
# fewest it carries to the next (1 and 1: any line). A paragraph that
|
|
8
|
+
# cannot meet them moves to the next page whole, unless it is already
|
|
9
|
+
# first on a fresh page, where it splits as best it can.
|
|
6
10
|
class Text < Node
|
|
7
11
|
attr_reader :runs
|
|
8
12
|
|
|
9
|
-
def initialize(runs, context:, align: :left, leading: 0,
|
|
13
|
+
def initialize(runs, context:, align: :left, leading: 0, orphans: 1, widows: 1, paragraph: nil,
|
|
14
|
+
tag: Tagging::Element.new(:P))
|
|
10
15
|
super()
|
|
11
16
|
@tag = tag
|
|
12
17
|
@runs = context.book.fallback(runs)
|
|
13
18
|
@context = context
|
|
14
19
|
@align = align
|
|
15
20
|
@leading = leading
|
|
21
|
+
@orphans = self.class.lines_option(:orphans, orphans)
|
|
22
|
+
@widows = self.class.lines_option(:widows, widows)
|
|
16
23
|
@paragraph = paragraph
|
|
17
24
|
@paragraphs = {}
|
|
18
25
|
end
|
|
19
26
|
|
|
27
|
+
def self.lines_option(name, value)
|
|
28
|
+
return value if value.is_a?(Integer) && value >= 1
|
|
29
|
+
|
|
30
|
+
raise ArgumentError, "#{name}: must be an Integer of at least 1, got #{value.inspect}"
|
|
31
|
+
end
|
|
32
|
+
|
|
20
33
|
def splittable? = true
|
|
21
34
|
|
|
22
35
|
def measure(width) = paragraph(width).height
|
|
@@ -25,9 +38,11 @@ module Stationery
|
|
|
25
38
|
paragraph(width).draw(canvas, x, y, tag: @tag)
|
|
26
39
|
end
|
|
27
40
|
|
|
28
|
-
def split(width, height, **)
|
|
41
|
+
def split(width, height, fresh: false, **)
|
|
29
42
|
head, tail = paragraph(width).split(height)
|
|
30
|
-
[head && from(head), tail && from(tail)]
|
|
43
|
+
return [head && from(head), tail && from(tail)] if (@orphans == 1 && @widows == 1) || !(head && tail)
|
|
44
|
+
|
|
45
|
+
keep_lines(paragraph(width), head.lines.size, fresh:)
|
|
31
46
|
end
|
|
32
47
|
|
|
33
48
|
def fit(width, height, overflow:)
|
|
@@ -38,19 +53,40 @@ module Stationery
|
|
|
38
53
|
@natural_width ||= paragraph(Float::INFINITY).lines.map(&:width).max || 0
|
|
39
54
|
end
|
|
40
55
|
|
|
56
|
+
# The widest piece that cannot be broken: a word, or one break unit of a
|
|
57
|
+
# word with CJK characters.
|
|
41
58
|
def min_width
|
|
42
59
|
@min_width ||= @runs.flat_map do |run|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
60
|
+
font = @context.book.resolve(run.style).first
|
|
61
|
+
run.text.delete(::Stationery::Text::Wrapper::SOFT_HYPHEN).split(/[ \t\n\u200B]+/).map do |word|
|
|
62
|
+
next piece_width(font, run.style, word) unless ::Stationery::Text::Breaks.cjk?(word)
|
|
63
|
+
|
|
64
|
+
::Stationery::Text::Breaks.units(word).map { |unit, _| piece_width(font, run.style, unit) }.max
|
|
48
65
|
end
|
|
49
66
|
end.max || 0
|
|
50
67
|
end
|
|
51
68
|
|
|
52
69
|
private
|
|
53
70
|
|
|
71
|
+
# Applies orphans and widows to a split after `fitting` lines: carries
|
|
72
|
+
# more lines over for the widows, then moves the paragraph whole when
|
|
73
|
+
# too few would stay. First on a fresh page nothing can move, so the
|
|
74
|
+
# widows are honoured if a line can still stay and the orphans are not.
|
|
75
|
+
def keep_lines(paragraph, fitting, fresh:)
|
|
76
|
+
total = paragraph.lines.size
|
|
77
|
+
kept = [fitting, total - @widows].min
|
|
78
|
+
kept = fitting if fresh && kept < 1
|
|
79
|
+
return [nil, self] if !fresh && (kept < @orphans || total < @orphans + @widows)
|
|
80
|
+
|
|
81
|
+
head, tail = paragraph.split_at(kept)
|
|
82
|
+
[head && from(head), tail && from(tail)]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def piece_width(font, style, text)
|
|
86
|
+
font.width_of(text, style.render_size, letter_spacing: style.letter_spacing, kerning: style.kerning,
|
|
87
|
+
ligatures: style.ligatures, features: style.features)
|
|
88
|
+
end
|
|
89
|
+
|
|
54
90
|
def paragraph(width)
|
|
55
91
|
@paragraph || (@paragraphs[width] ||= ::Stationery::Text::Paragraph.new(
|
|
56
92
|
@runs, book: @context.book, width:, align: @align, leading: @leading, fallback_style: @context.style
|
|
@@ -58,7 +94,8 @@ module Stationery
|
|
|
58
94
|
end
|
|
59
95
|
|
|
60
96
|
def from(paragraph)
|
|
61
|
-
self.class.new(@runs, context: @context, align: @align, leading: @leading,
|
|
97
|
+
self.class.new(@runs, context: @context, align: @align, leading: @leading, orphans: @orphans, widows: @widows,
|
|
98
|
+
paragraph:, tag: @tag)
|
|
62
99
|
end
|
|
63
100
|
end
|
|
64
101
|
end
|
|
@@ -11,10 +11,16 @@ module Stationery
|
|
|
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
13
|
# PDF/A and PDF/UA ask of the file; `signature:` (a PDF::Signature)
|
|
14
|
-
# signs it.
|
|
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.
|
|
15
18
|
def initialize(pages:, resources:, info: {}, outline: [], encryption: nil, tagging: nil, lang: nil,
|
|
16
19
|
page_labels: nil, attachments: [], xmp: true, xmp_extensions: {}, xmp_schemas: [],
|
|
17
|
-
conformance: nil, signature: 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
|
|
18
24
|
@xmp_schemas = xmp_schemas
|
|
19
25
|
@conformance = conformance
|
|
20
26
|
@signature = signature
|
|
@@ -31,15 +37,19 @@ module Stationery
|
|
|
31
37
|
@encryption = encryption
|
|
32
38
|
end
|
|
33
39
|
|
|
40
|
+
# The file as a String, or the number of bytes streamed to the sink.
|
|
34
41
|
def render
|
|
35
|
-
writer = Writer.new(encryption: @encryption)
|
|
42
|
+
writer = Writer.new(encryption: @encryption, sink: @sink)
|
|
36
43
|
tree = writer.reserve
|
|
37
44
|
refs = @resources.build(writer)
|
|
38
45
|
@form = Forms::AcroForm.new(writer, fonts: refs.fetch(:Font), signature: @signature,
|
|
39
46
|
need_appearances: @conformance.nil? && @signature.nil?)
|
|
40
47
|
kids = @kids = @pages.map { writer.reserve }
|
|
41
48
|
@structure = @tagging && Tagging::Writer.new(@tagging, pages: @pages, refs: kids)
|
|
42
|
-
@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
|
|
43
53
|
writer.set(tree, { Type: :Pages, Kids: kids, Count: kids.size })
|
|
44
54
|
outlines = OutlineWriter.new(writer, @outline, kids).write
|
|
45
55
|
now = Time.now
|
|
@@ -10,6 +10,9 @@ module Stationery
|
|
|
10
10
|
# signer's certificate; there is no signing-time attribute, a PDF keeps
|
|
11
11
|
# that in the signature dictionary's /M.
|
|
12
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
|
+
#
|
|
13
16
|
# Ruby's OpenSSL::PKCS7 cannot add signed attributes, so the structure
|
|
14
17
|
# is built with OpenSSL::ASN1. RSA keys sign with PKCS #1 v1.5, EC keys
|
|
15
18
|
# with ECDSA, both over SHA-256.
|
|
@@ -18,6 +21,7 @@ module Stationery
|
|
|
18
21
|
data: "1.2.840.113549.1.7.1", signed_data: "1.2.840.113549.1.7.2",
|
|
19
22
|
content_type: "1.2.840.113549.1.9.3", message_digest: "1.2.840.113549.1.9.4",
|
|
20
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",
|
|
21
25
|
rsa: "1.2.840.113549.1.1.1", ecdsa_sha256: "1.2.840.10045.4.3.2"
|
|
22
26
|
}.freeze
|
|
23
27
|
|
|
@@ -28,16 +32,18 @@ module Stationery
|
|
|
28
32
|
@chain = chain
|
|
29
33
|
end
|
|
30
34
|
|
|
31
|
-
# The DER of the SignedData signing `data`.
|
|
32
|
-
|
|
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)
|
|
33
38
|
attributes = attributes(OpenSSL::Digest::SHA256.digest(data))
|
|
34
39
|
signature = @key.sign("SHA256", asn1::Set.new(attributes).to_der)
|
|
40
|
+
unsigned = timestamp && [attribute(:signature_timestamp_token, asn1.decode(timestamp.call(signature)))]
|
|
35
41
|
content = asn1::Sequence.new([
|
|
36
42
|
asn1::Integer.new(1),
|
|
37
43
|
asn1::Set.new([algorithm(:sha256)]),
|
|
38
44
|
asn1::Sequence.new([oid(:data)]),
|
|
39
45
|
implicit(certificates),
|
|
40
|
-
asn1::Set.new([signer(attributes, signature)])
|
|
46
|
+
asn1::Set.new([signer(attributes, signature, unsigned)])
|
|
41
47
|
])
|
|
42
48
|
asn1::Sequence.new([oid(:signed_data), asn1::ASN1Data.new([content], 0, :CONTEXT_SPECIFIC)]).to_der
|
|
43
49
|
end
|
|
@@ -46,7 +52,7 @@ module Stationery
|
|
|
46
52
|
|
|
47
53
|
def asn1 = OpenSSL::ASN1
|
|
48
54
|
def oid(name) = asn1::ObjectId.new(OIDS.fetch(name))
|
|
49
|
-
def implicit(items) = asn1::Set.new(items,
|
|
55
|
+
def implicit(items, tag = 0) = asn1::Set.new(items, tag, :IMPLICIT, :CONTEXT_SPECIFIC)
|
|
50
56
|
def certificates = [@certificate, *@chain].map { |certificate| asn1.decode(certificate.to_der) }
|
|
51
57
|
|
|
52
58
|
# RSA and the digests carry an explicit NULL parameter, ECDSA none.
|
|
@@ -70,16 +76,18 @@ module Stationery
|
|
|
70
76
|
asn1::Sequence.new([asn1::Sequence.new([asn1::Sequence.new([hash])])])
|
|
71
77
|
end
|
|
72
78
|
|
|
73
|
-
def signer(attributes, signature)
|
|
79
|
+
def signer(attributes, signature, unsigned)
|
|
74
80
|
issuer = asn1.decode(@certificate.issuer.to_der)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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)
|
|
83
91
|
end
|
|
84
92
|
end
|
|
85
93
|
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
|
|
@@ -8,22 +8,26 @@ module Stationery
|
|
|
8
8
|
#
|
|
9
9
|
# The file is written once with room for the signature (`contents_size:`
|
|
10
10
|
# bytes) and a placeholder /ByteRange; #apply then fills both in place, so
|
|
11
|
-
# no offset moves.
|
|
12
|
-
#
|
|
13
|
-
#
|
|
11
|
+
# no offset moves. `timestamp:` adds a time-stamping authority's token to
|
|
12
|
+
# the signature (PAdES baseline B-T, see Timestamp). Long-term validation
|
|
13
|
+
# data and a second signature need incremental updates, which Stationery
|
|
14
|
+
# does not write.
|
|
14
15
|
#
|
|
15
16
|
# OpenSSL is loaded when the first signature is made, never before.
|
|
16
17
|
class Signature
|
|
17
18
|
SUBFILTER = :"ETSI.CAdES.detached"
|
|
18
19
|
CONTENTS_SIZE = 8192
|
|
20
|
+
# A timestamp token brings the TSA's certificate chain along.
|
|
21
|
+
TIMESTAMPED_CONTENTS_SIZE = 16_384
|
|
19
22
|
BYTE_RANGE = "[0 0000000000 0000000000 0000000000]"
|
|
20
|
-
OPTIONS = %i[certificate key chain passphrase reason location contact name field at contents_size
|
|
23
|
+
OPTIONS = %i[certificate key chain passphrase reason location contact name field at contents_size
|
|
24
|
+
timestamp].freeze
|
|
21
25
|
# Options a Symbol names a document method for; elsewhere it is a value.
|
|
22
26
|
SECRETS = %i[certificate key chain passphrase].freeze
|
|
23
27
|
# Print and Locked: the widget of a signature that fills no field.
|
|
24
28
|
INVISIBLE = 132
|
|
25
29
|
|
|
26
|
-
attr_reader :certificate, :key, :chain, :field, :at, :contents_size
|
|
30
|
+
attr_reader :certificate, :key, :chain, :field, :at, :contents_size, :timestamp
|
|
27
31
|
|
|
28
32
|
class << self
|
|
29
33
|
# The signature for `spec` (the options of `sign` and `to_pdf(sign:)`,
|
|
@@ -73,14 +77,15 @@ module Stationery
|
|
|
73
77
|
end
|
|
74
78
|
|
|
75
79
|
def initialize(certificate: nil, key: nil, chain: [], passphrase: nil, field: nil, at: Time.now,
|
|
76
|
-
contents_size:
|
|
80
|
+
contents_size: nil, timestamp: nil, **details)
|
|
77
81
|
require "openssl"
|
|
78
82
|
@certificate = certificate_for(certificate, "certificate:")
|
|
79
83
|
@key = key_for(key, passphrase)
|
|
80
84
|
@chain = Array(chain).map { |link| certificate_for(link, "chain:") }
|
|
81
85
|
@field = field&.to_s
|
|
82
86
|
@at = at
|
|
83
|
-
@
|
|
87
|
+
@timestamp = Timestamp.for(timestamp)
|
|
88
|
+
@contents_size = contents_size || (@timestamp ? TIMESTAMPED_CONTENTS_SIZE : CONTENTS_SIZE)
|
|
84
89
|
@details = self.class.check(details)
|
|
85
90
|
match!
|
|
86
91
|
end
|
|
@@ -110,7 +115,8 @@ module Stationery
|
|
|
110
115
|
range, first, last = gap(pdf)
|
|
111
116
|
pdf.bytesplice(range, BYTE_RANGE.bytesize,
|
|
112
117
|
"[0 #{first} #{last} #{pdf.bytesize - last}]".ljust(BYTE_RANGE.bytesize))
|
|
113
|
-
|
|
118
|
+
signed = pdf.byteslice(0, first) + pdf.byteslice(last..)
|
|
119
|
+
signature = CMS.new(@certificate, @key, @chain).sign(signed, timestamp: @timestamp&.method(:token))
|
|
114
120
|
raise ArgumentError, too_large(signature) if signature.bytesize > @contents_size
|
|
115
121
|
|
|
116
122
|
pdf.bytesplice(first + 1, @contents_size * 2, signature.unpack1("H*").upcase.ljust(@contents_size * 2, "0"))
|
|
@@ -133,7 +139,8 @@ module Stationery
|
|
|
133
139
|
end
|
|
134
140
|
|
|
135
141
|
def too_large(signature)
|
|
136
|
-
"the signature
|
|
142
|
+
what = @timestamp ? "the signature and its timestamp take" : "the signature takes"
|
|
143
|
+
"#{what} #{signature.bytesize} bytes, more than contents_size: #{@contents_size} leaves for it; " \
|
|
137
144
|
"pass a larger contents_size:"
|
|
138
145
|
end
|
|
139
146
|
|
|
@@ -5,15 +5,26 @@ require "digest/md5"
|
|
|
5
5
|
module Stationery
|
|
6
6
|
module PDF
|
|
7
7
|
# Collects numbered objects and writes them as a PDF file with a classic
|
|
8
|
-
# cross-reference table.
|
|
8
|
+
# cross-reference table. `render` returns the file as a String with the
|
|
9
|
+
# objects in numbered order. With a `sink:` (anything answering
|
|
10
|
+
# `call(bytes)`) the file goes there in pieces instead: `flush` writes every
|
|
11
|
+
# object set so far and drops it, so objects leave in the order they were
|
|
12
|
+
# flushed and the first bytes leave before the last page is assembled.
|
|
9
13
|
class Writer
|
|
10
14
|
HEADER = "%PDF-1.7\n%\xE2\xE3\xCF\xD3\n".b
|
|
15
|
+
WRITTEN = Object.new.freeze
|
|
11
16
|
|
|
12
17
|
# `encryption` (an Encryption::StandardSecurity) encrypts every string
|
|
13
18
|
# and stream except those of its own /Encrypt dictionary.
|
|
14
|
-
def initialize(encryption: nil)
|
|
19
|
+
def initialize(encryption: nil, sink: nil)
|
|
15
20
|
@objects = []
|
|
21
|
+
@offsets = []
|
|
16
22
|
@encryption = encryption
|
|
23
|
+
@sink = sink
|
|
24
|
+
@buffer = HEADER.dup unless sink
|
|
25
|
+
@size = HEADER.bytesize
|
|
26
|
+
@digest = Digest::MD5.new if sink && !encryption
|
|
27
|
+
announce(HEADER) if sink
|
|
17
28
|
end
|
|
18
29
|
|
|
19
30
|
# Hands out a reference now and lets the object be set later, so a page
|
|
@@ -24,6 +35,8 @@ module Stationery
|
|
|
24
35
|
end
|
|
25
36
|
|
|
26
37
|
def set(ref, value)
|
|
38
|
+
raise Error, "object #{ref.id} is already written" if @objects[ref.id - 1].equal?(WRITTEN)
|
|
39
|
+
|
|
27
40
|
@objects[ref.id - 1] = value
|
|
28
41
|
ref
|
|
29
42
|
end
|
|
@@ -32,43 +45,84 @@ module Stationery
|
|
|
32
45
|
set(reserve, value)
|
|
33
46
|
end
|
|
34
47
|
|
|
48
|
+
# With a sink: writes every object set and not yet written, lowest number
|
|
49
|
+
# first, and drops it. References to objects still unset stay valid, as
|
|
50
|
+
# the cross-reference table is written last. Without a sink it does
|
|
51
|
+
# nothing: the String keeps its objects in numbered order.
|
|
52
|
+
def flush
|
|
53
|
+
return unless @sink
|
|
54
|
+
|
|
55
|
+
@encrypt ||= reserve if @encryption
|
|
56
|
+
write_pending
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Finishes the file: the remaining objects, the cross-reference table
|
|
60
|
+
# and the trailer. Returns the whole file as a String when there is no
|
|
61
|
+
# sink, else the number of bytes handed to it.
|
|
35
62
|
def render(root:, info:)
|
|
63
|
+
set(@encrypt ||= reserve, @encryption.dictionary) if @encryption
|
|
36
64
|
if (missing = @objects.index(nil))
|
|
37
65
|
raise Error, "object #{missing + 1} reserved but never set"
|
|
38
66
|
end
|
|
39
67
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
xref = out.bytesize
|
|
44
|
-
id = HexString.new(@encryption ? @encryption.file_id : Digest::MD5.digest(out))
|
|
68
|
+
write_pending
|
|
69
|
+
xref = @size
|
|
70
|
+
id = HexString.new(file_id)
|
|
45
71
|
|
|
46
|
-
|
|
47
|
-
offsets.each { |offset|
|
|
48
|
-
|
|
49
|
-
|
|
72
|
+
emit("xref\n0 #{@objects.size + 1}\n0000000000 65535 f \n")
|
|
73
|
+
@offsets.each { |offset| emit(format("%010d 00000 n \n", offset)) }
|
|
74
|
+
emit("trailer\n#{Serializer.dump(trailer(root, info, id))}")
|
|
75
|
+
emit("\nstartxref\n#{xref}\n%%EOF\n")
|
|
76
|
+
@buffer || @size
|
|
50
77
|
end
|
|
51
78
|
|
|
52
79
|
private
|
|
53
80
|
|
|
81
|
+
def write_pending
|
|
82
|
+
@objects.each_with_index do |object, index|
|
|
83
|
+
next if object.nil? || object.equal?(WRITTEN)
|
|
84
|
+
|
|
85
|
+
write_object(index + 1, object)
|
|
86
|
+
@objects[index] = WRITTEN
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
54
90
|
def trailer(root, info, id)
|
|
55
91
|
trailer = { Size: @objects.size + 1, Root: root, Info: info, ID: [id, id] }
|
|
56
92
|
@encrypt ? trailer.merge(Encrypt: @encrypt) : trailer
|
|
57
93
|
end
|
|
58
94
|
|
|
59
|
-
def write_object(
|
|
60
|
-
|
|
95
|
+
def write_object(number, object)
|
|
96
|
+
@offsets[number - 1] = @size
|
|
61
97
|
crypt = crypt_for(number)
|
|
62
|
-
|
|
98
|
+
emit("#{number} 0 obj\n")
|
|
63
99
|
if object.is_a?(Stream)
|
|
64
100
|
data = crypt ? crypt.call(object.data) : object.data
|
|
65
|
-
|
|
66
|
-
|
|
101
|
+
emit(Serializer.dump(object.dictionary.merge(Length: data.bytesize), crypt))
|
|
102
|
+
emit("\nstream\n")
|
|
103
|
+
emit(data)
|
|
104
|
+
emit("\nendstream")
|
|
67
105
|
else
|
|
68
|
-
|
|
106
|
+
emit(Serializer.dump(object, crypt))
|
|
69
107
|
end
|
|
70
|
-
|
|
71
|
-
|
|
108
|
+
emit("\nendobj\n")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def emit(bytes)
|
|
112
|
+
@size += bytes.bytesize
|
|
113
|
+
@sink ? announce(bytes) : @buffer << bytes
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def announce(bytes)
|
|
117
|
+
@digest&.update(bytes)
|
|
118
|
+
@sink.call(bytes)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The encryption's own identifier, else a digest of the file so far.
|
|
122
|
+
def file_id
|
|
123
|
+
return @encryption.file_id if @encryption
|
|
124
|
+
|
|
125
|
+
@digest ? @digest.digest : Digest::MD5.digest(@buffer)
|
|
72
126
|
end
|
|
73
127
|
|
|
74
128
|
# The Serializer hook: encrypts a string or stream with this object's key.
|