pdfrb 0.7.1 → 0.7.11
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 +93 -0
- data/RELEASE.md +17 -11
- data/data/pdfrb/layout/hyphenation_en.txt +408 -0
- data/lib/pdfrb/color/default_profile.rb +213 -0
- data/lib/pdfrb/color/icc_validator.rb +69 -0
- data/lib/pdfrb/color.rb +2 -0
- data/lib/pdfrb/conformance/ltv.rb +112 -0
- data/lib/pdfrb/conformance/pades.rb +198 -0
- data/lib/pdfrb/conformance/pdf_2_af.rb +185 -0
- data/lib/pdfrb/conformance/pdf_a.rb +123 -0
- data/lib/pdfrb/conformance/pdf_a4_deep.rb +94 -0
- data/lib/pdfrb/conformance/pdf_ua2_deep.rb +93 -0
- data/lib/pdfrb/conformance/pdf_ua_tagging_deep.rb +125 -0
- data/lib/pdfrb/conformance/pdf_vt.rb +128 -0
- data/lib/pdfrb/conformance/pdf_x.rb +66 -1
- data/lib/pdfrb/conformance/tagged_pdf.rb +182 -0
- data/lib/pdfrb/conformance.rb +8 -0
- data/lib/pdfrb/content/parser.rb +82 -0
- data/lib/pdfrb/digital_signature/timestamp_client.rb +86 -0
- data/lib/pdfrb/digital_signature.rb +1 -0
- data/lib/pdfrb/document.rb +29 -0
- data/lib/pdfrb/encryption/public_key_security_handler.rb +146 -0
- data/lib/pdfrb/encryption/standard_security_handler.rb +98 -3
- data/lib/pdfrb/encryption/v5_writer.rb +108 -0
- data/lib/pdfrb/encryption.rb +3 -0
- data/lib/pdfrb/font_loader/type3.rb +65 -0
- data/lib/pdfrb/font_loader.rb +1 -0
- data/lib/pdfrb/image_loader/gif.rb +246 -0
- data/lib/pdfrb/image_loader/tiff.rb +257 -0
- data/lib/pdfrb/image_loader.rb +2 -0
- data/lib/pdfrb/layout/font_fallback.rb +203 -0
- data/lib/pdfrb/layout/hyphenation.rb +120 -0
- data/lib/pdfrb/layout/justification_kashidas.rb +72 -0
- data/lib/pdfrb/layout/multi_cell_text_layout.rb +65 -0
- data/lib/pdfrb/layout/multi_page_table_box.rb +155 -0
- data/lib/pdfrb/layout/polygon_frame.rb +106 -0
- data/lib/pdfrb/layout/table_box.rb +154 -23
- data/lib/pdfrb/layout/text_shaper.rb +129 -0
- data/lib/pdfrb/layout.rb +7 -0
- data/lib/pdfrb/source/linearization_reader.rb +76 -0
- data/lib/pdfrb/source/recovery.rb +65 -1
- data/lib/pdfrb/source/tokenizer.rb +21 -0
- data/lib/pdfrb/source.rb +1 -0
- data/lib/pdfrb/task/thumbnail.rb +133 -0
- data/lib/pdfrb/task.rb +1 -0
- data/lib/pdfrb/version.rb +1 -1
- metadata +28 -2
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Pdfrb
|
|
6
|
+
module Color
|
|
7
|
+
# Default sRGB ICC profile accessor. Builds a usable sRGB v4
|
|
8
|
+
# ICC profile (header + tag table + tagged elements) that real
|
|
9
|
+
# PDF readers can consume for ICCBased color spaces. The profile
|
|
10
|
+
# includes:
|
|
11
|
+
#
|
|
12
|
+
# * desc — profile description (sRGB by pdfrb)
|
|
13
|
+
# * wtpt — D50 media white point
|
|
14
|
+
# * rXYZ, gXYZ, bXYZ — sRGB colorant matrix (D50-adapted)
|
|
15
|
+
# * rTRC, gTRC, bTRC — gamma 2.2 tone reproduction curves
|
|
16
|
+
#
|
|
17
|
+
# Tagged-element types follow ICC.1:2022 §10 (multiLocalizedUnicodeType
|
|
18
|
+
# for desc, XYZType for colorants, curveType for TRCs).
|
|
19
|
+
module DefaultProfile
|
|
20
|
+
SRGB_SIGNATURE = "acsp".b
|
|
21
|
+
ICC_VERSION_V4 = [0x04, 0x40, 0x00, 0x00].freeze
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# Returns the sRGB ICC profile bytes (~ 600 bytes). Includes
|
|
26
|
+
# the structural header + tag table + tagged elements needed
|
|
27
|
+
# for a real ICCBased color space.
|
|
28
|
+
def srgb_bytes
|
|
29
|
+
tags = build_srgb_tags
|
|
30
|
+
tag_table = build_tag_table(tags)
|
|
31
|
+
header = build_srgb_header(tag_table.bytesize + tags_total_size(tags))
|
|
32
|
+
profile = header + tag_table
|
|
33
|
+
tags.each_value { |tag_bytes| profile << tag_bytes }
|
|
34
|
+
# Compute MD5 ProfileID over the on-disk profile (sans the
|
|
35
|
+
# existing ID slot, which we'll patch in). ICC.1:2022 §7.1.
|
|
36
|
+
profile = patch_profile_id(profile)
|
|
37
|
+
profile.force_encoding(Encoding::BINARY)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Build an ICCBased color space array referencing a new stream
|
|
41
|
+
# containing the sRGB profile bytes. Returns
|
|
42
|
+
# [:ICCBased, ref] suitable for /ColorSpace resources.
|
|
43
|
+
def srgb_color_space(document)
|
|
44
|
+
bytes = srgb_bytes
|
|
45
|
+
stream = document.add(
|
|
46
|
+
{ N: 3, Length: bytes.bytesize },
|
|
47
|
+
type: Pdfrb::Model::Cos::Stream
|
|
48
|
+
)
|
|
49
|
+
stream.stream = bytes
|
|
50
|
+
[:ICCBased, Pdfrb::Model::Reference.new(stream.oid, stream.gen)]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# ---- Header construction ----
|
|
54
|
+
|
|
55
|
+
def build_srgb_header(tag_section_size)
|
|
56
|
+
header = +""
|
|
57
|
+
header << ([0] * 4).pack("C*") # Profile size placeholder
|
|
58
|
+
header << ([0] * 4).pack("C*") # Preferred CMM
|
|
59
|
+
header << ICC_VERSION_V4.pack("C*") # Version 4.4
|
|
60
|
+
header << "mntr" # Device class
|
|
61
|
+
header << "RGB " # Color space
|
|
62
|
+
header << "XYZ " # PCS
|
|
63
|
+
header << date_bytes # Date
|
|
64
|
+
header << SRGB_SIGNATURE # 'acsp'
|
|
65
|
+
header << "APPL" # Primary platform
|
|
66
|
+
header << ([0] * 4).pack("C*") # Profile flags
|
|
67
|
+
header << ([0] * 4).pack("C*") # Device manufacturer
|
|
68
|
+
header << ([0] * 4).pack("C*") # Device model
|
|
69
|
+
header << ([0] * 8).pack("C*") # Device attributes
|
|
70
|
+
header << ([0] * 4).pack("C*") # Rendering intent (perceptual)
|
|
71
|
+
header << d50_illuminant_bytes # PCS illuminant (D50)
|
|
72
|
+
header << ([0] * 4).pack("C*") # Profile creator
|
|
73
|
+
header << ([0] * 16).pack("C*") # Profile ID (MD5, patched later)
|
|
74
|
+
header << ([0] * 28).pack("C*") # Reserved
|
|
75
|
+
|
|
76
|
+
total_size = header.bytesize + tag_section_size
|
|
77
|
+
header[0, 4] = [total_size].pack("N")
|
|
78
|
+
header
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def date_bytes
|
|
82
|
+
[0x07, 0xE8, 0x00, 0x01, 0x00, 0x01,
|
|
83
|
+
0x00, 0x00, 0x00, 0x00, 0x00, 0x00].pack("C*")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def d50_illuminant_bytes
|
|
87
|
+
# D50 X=0.9642, Y=1.0, Z=0.8249 in s15Fixed16.
|
|
88
|
+
[0x00, 0x00, 0xF6, 0xD6, 0x00, 0x01, 0x00, 0x00,
|
|
89
|
+
0x00, 0x00, 0xD3, 0x2D].pack("C*")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# ---- Tag table + tagged elements ----
|
|
93
|
+
|
|
94
|
+
# Returns a Hash of { tag_signature => tag_bytes } in the
|
|
95
|
+
# order they should appear after the tag table.
|
|
96
|
+
def build_srgb_tags
|
|
97
|
+
{
|
|
98
|
+
"desc" => desc_tag_bytes("sRGB by pdfrb"),
|
|
99
|
+
"wtpt" => xyz_tag_bytes(d50_xyz),
|
|
100
|
+
"rXYZ" => xyz_tag_bytes(srgb_r_xyz),
|
|
101
|
+
"gXYZ" => xyz_tag_bytes(srgb_g_xyz),
|
|
102
|
+
"bXYZ" => xyz_tag_bytes(srgb_b_xyz),
|
|
103
|
+
"rTRC" => curve_tag_bytes,
|
|
104
|
+
"gTRC" => curve_tag_bytes,
|
|
105
|
+
"bTRC" => curve_tag_bytes,
|
|
106
|
+
}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def build_tag_table(tags)
|
|
110
|
+
out = +""
|
|
111
|
+
count = tags.length
|
|
112
|
+
out << [count].pack("N")
|
|
113
|
+
|
|
114
|
+
# Compute offsets: tag table is 4 + count*12 bytes; each tag
|
|
115
|
+
# element is appended after the table in order.
|
|
116
|
+
offset = 4 + (count * 12)
|
|
117
|
+
tags.each do |sig, bytes|
|
|
118
|
+
out << sig
|
|
119
|
+
out << [offset].pack("N")
|
|
120
|
+
out << [bytes.bytesize].pack("N")
|
|
121
|
+
offset += bytes.bytesize
|
|
122
|
+
end
|
|
123
|
+
out
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def tags_total_size(tags)
|
|
127
|
+
tags.values.sum(&:bytesize)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# ---- Tag element builders ----
|
|
131
|
+
|
|
132
|
+
# multiLocalizedUnicodeType (mluc) for desc, per ICC.10.13.
|
|
133
|
+
def desc_tag_bytes(text)
|
|
134
|
+
utf16 = text.encode("UTF-16BE").bytes
|
|
135
|
+
body = +""
|
|
136
|
+
body << "mluc" # type signature
|
|
137
|
+
body << ([0] * 4).pack("C*") # reserved
|
|
138
|
+
body << [1].pack("N") # number of records
|
|
139
|
+
body << [12].pack("N") # record size in bytes
|
|
140
|
+
body << "enUS" # language + country
|
|
141
|
+
body << [utf16.length].pack("N") # string length in bytes
|
|
142
|
+
body << [28].pack("N") # string offset (from tag start)
|
|
143
|
+
body << utf16.pack("C*")
|
|
144
|
+
body
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# XYZType for colorants and white points, per ICC.10.22.
|
|
148
|
+
def xyz_tag_bytes(xyz_triplet)
|
|
149
|
+
body = +""
|
|
150
|
+
body << "XYZ " # type signature
|
|
151
|
+
body << ([0] * 4).pack("C*") # reserved
|
|
152
|
+
xyz_triplet.each { |v| body << s15fixed16(v) }
|
|
153
|
+
body
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# curveType with a single gamma value, per ICC.10.16.
|
|
157
|
+
# 2.2 is the canonical sRGB approximation (real sRGB uses a
|
|
158
|
+
# piecewise curve, but a single 2.2 gamma is acceptable for
|
|
159
|
+
# most rendering).
|
|
160
|
+
def curve_tag_bytes
|
|
161
|
+
body = +""
|
|
162
|
+
body << "curv" # type signature
|
|
163
|
+
body << ([0] * 4).pack("C*") # reserved
|
|
164
|
+
body << [0].pack("N") # count = 0 → gamma follows in u8Fixed8
|
|
165
|
+
body << u8fixed8(2.2)
|
|
166
|
+
body
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Encode a Float as ICC s15Fixed16 (signed 16.16).
|
|
170
|
+
def s15fixed16(value)
|
|
171
|
+
v = (value * 65536.0).round.to_i
|
|
172
|
+
[v].pack("N")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Encode a Float as ICC u8Fixed8 (unsigned 8.8).
|
|
176
|
+
def u8fixed8(value)
|
|
177
|
+
v = (value * 256.0).round.to_i
|
|
178
|
+
[v].pack("n")
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# ---- sRGB colorants (D50-adapted, per ICC.1:2022 Annex A) ----
|
|
182
|
+
|
|
183
|
+
def d50_xyz
|
|
184
|
+
[0.9642, 1.0000, 0.8249]
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def srgb_r_xyz
|
|
188
|
+
[0.4360, 0.2225, 0.0139]
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def srgb_g_xyz
|
|
192
|
+
[0.3851, 0.7169, 0.0971]
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def srgb_b_xyz
|
|
196
|
+
[0.1431, 0.0606, 0.7141]
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# ---- Profile ID (MD5) ----
|
|
200
|
+
|
|
201
|
+
# Patch the Profile ID (offset 84..99) with the MD5 of the
|
|
202
|
+
# profile bytes computed with the ID slot zeroed (per ICC.1
|
|
203
|
+
# §7.1.1).
|
|
204
|
+
def patch_profile_id(profile)
|
|
205
|
+
copy = profile.b
|
|
206
|
+
copy[84, 16] = ([0] * 16).pack("C*")
|
|
207
|
+
md5 = Digest::MD5.digest(copy)
|
|
208
|
+
copy[84, 16] = md5
|
|
209
|
+
copy
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pdfrb
|
|
4
|
+
module Color
|
|
5
|
+
# Validates an ICC profile binary against the structural rules in
|
|
6
|
+
# ICC.1:2022 §7. The validator checks:
|
|
7
|
+
# * Profile size >= 128 (header) + 4 (tag count)
|
|
8
|
+
# * 'acsp' signature at offset 36
|
|
9
|
+
# * Profile version is 2.x, 4.x, or 5.x
|
|
10
|
+
# * Device class is one of the known classes
|
|
11
|
+
# * Color space is one of the known spaces
|
|
12
|
+
# * Tag table offsets don't exceed the file size
|
|
13
|
+
#
|
|
14
|
+
# Returns an Array of Violation strings (empty if valid).
|
|
15
|
+
module ICCValidator
|
|
16
|
+
VALID_VERSIONS = [(2.0..2.99), (4.0..4.99), (5.0..5.99)].freeze
|
|
17
|
+
VALID_DEVICE_CLASSES = %w[
|
|
18
|
+
scnr mntr prtr link spac abst nmcl nkpr
|
|
19
|
+
].freeze
|
|
20
|
+
VALID_COLOR_SPACES = %w[
|
|
21
|
+
XYZ Lab Luv YCbCr Yxy RGB GRAY HSV HLS CMYK CMY 2CLR 3CLR
|
|
22
|
+
4CLR 5CLR 6CLR 7CLR 8CLR 9CLR ACLR BCLR CCLR DCLR ECLR FCLR
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
def validate(profile_bytes)
|
|
28
|
+
violations = []
|
|
29
|
+
unless profile_bytes.is_a?(::String) && profile_bytes.bytesize >= 132
|
|
30
|
+
violations << "ICC profile too small (need >= 132 bytes, got #{profile_bytes&.bytesize})"
|
|
31
|
+
return violations
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
unless profile_bytes.byteslice(36, 4) == "acsp"
|
|
35
|
+
violations << "ICC profile signature at offset 36 is not 'acsp'"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
version_hi = profile_bytes.getbyte(8)
|
|
39
|
+
version_lo = profile_bytes.getbyte(9)
|
|
40
|
+
version = (version_hi * 1.0) + (version_lo / 16.0 / 10.0)
|
|
41
|
+
unless VALID_VERSIONS.any? { |range| range.cover?(version) }
|
|
42
|
+
violations << "ICC profile version #{version} not in 2.x/4.x/5.x"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
device_class = profile_bytes.byteslice(12, 4).to_s.strip
|
|
46
|
+
unless VALID_DEVICE_CLASSES.include?(device_class)
|
|
47
|
+
violations << "ICC device class '#{device_class}' is not recognised"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
color_space = profile_bytes.byteslice(16, 4).to_s.strip
|
|
51
|
+
unless VALID_COLOR_SPACES.include?(color_space)
|
|
52
|
+
violations << "ICC color space '#{color_space}' is not recognised"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Tag table sanity check
|
|
56
|
+
tag_count = profile_bytes.unpack1("N", offset: 128)
|
|
57
|
+
if tag_count > 256
|
|
58
|
+
violations << "ICC tag count #{tag_count} looks bogus (max 256)"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
violations
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def valid?(profile_bytes)
|
|
65
|
+
validate(profile_bytes).empty?
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
data/lib/pdfrb/color.rb
CHANGED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Metrics/BlockLength
|
|
4
|
+
module Pdfrb
|
|
5
|
+
module Conformance
|
|
6
|
+
# Long-Term Validation (LTV) per ETSI EN 319 102-1 Annex B. LTV
|
|
7
|
+
# preserves the validity of a signature beyond the validity
|
|
8
|
+
# period of the signer's certificate by archiving revocation
|
|
9
|
+
# data and timestamping the archive over time.
|
|
10
|
+
#
|
|
11
|
+
# LTV requires:
|
|
12
|
+
# * At least one signature with /ByteRange and /Contents.
|
|
13
|
+
# * /DSS dictionary on Catalog with /Certs, /CRLs, /OCSPs as
|
|
14
|
+
# applicable.
|
|
15
|
+
# * For archival (LTA): a /DocumentTimeStamp on the catalog.
|
|
16
|
+
module Ltv
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
RULESET = RuleSet.new("LTV").tap do |rs|
|
|
20
|
+
rs.register(Rule.new(
|
|
21
|
+
id: "ltv-1",
|
|
22
|
+
description: "/DSS dictionary required for LTV",
|
|
23
|
+
severity: :error,
|
|
24
|
+
spec_clause: "ETSI EN 319 102-1 B.5",
|
|
25
|
+
check: ->(doc) {
|
|
26
|
+
next nil if doc.catalog[:DSS]
|
|
27
|
+
|
|
28
|
+
Violation.new(
|
|
29
|
+
rule_id: "ltv-1",
|
|
30
|
+
message: "LTV requires /DSS dictionary on Catalog",
|
|
31
|
+
object: "Catalog/DSS",
|
|
32
|
+
severity: :error,
|
|
33
|
+
spec_clause: "ETSI EN 319 102-1 B.5"
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
))
|
|
37
|
+
|
|
38
|
+
rs.register(Rule.new(
|
|
39
|
+
id: "ltv-2",
|
|
40
|
+
description: "/DSS must contain validation material",
|
|
41
|
+
severity: :warning,
|
|
42
|
+
spec_clause: "ETSI EN 319 102-1 B.5",
|
|
43
|
+
check: ->(doc) {
|
|
44
|
+
dss = doc.catalog[:DSS]
|
|
45
|
+
dss = doc.object(dss) if dss.is_a?(Pdfrb::Model::Reference)
|
|
46
|
+
next nil unless dss
|
|
47
|
+
|
|
48
|
+
has_material = dss[:Certs] || dss[:CRLs] || dss[:OCSPs] ||
|
|
49
|
+
dss[:VRI]
|
|
50
|
+
next nil if has_material
|
|
51
|
+
|
|
52
|
+
Violation.new(
|
|
53
|
+
rule_id: "ltv-2",
|
|
54
|
+
message: "/DSS is empty (no /Certs, /CRLs, /OCSPs, or /VRI)",
|
|
55
|
+
object: "Catalog/DSS",
|
|
56
|
+
severity: :warning,
|
|
57
|
+
spec_clause: "ETSI EN 319 102-1 B.5"
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
))
|
|
61
|
+
|
|
62
|
+
rs.register(Rule.new(
|
|
63
|
+
id: "ltv-3",
|
|
64
|
+
description: "At least one signature should be present",
|
|
65
|
+
severity: :warning,
|
|
66
|
+
spec_clause: "ETSI EN 319 102-1 B.4",
|
|
67
|
+
check: ->(doc) {
|
|
68
|
+
fields = Pdfrb::Conformance::Pades.signature_fields(doc)
|
|
69
|
+
next nil if fields.any?
|
|
70
|
+
|
|
71
|
+
Violation.new(
|
|
72
|
+
rule_id: "ltv-3",
|
|
73
|
+
message: "LTV document has no signatures to validate",
|
|
74
|
+
object: "AcroForm/Fields",
|
|
75
|
+
severity: :warning,
|
|
76
|
+
spec_clause: "ETSI EN 319 102-1 B.4"
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
))
|
|
80
|
+
|
|
81
|
+
rs.register(Rule.new(
|
|
82
|
+
id: "ltv-4",
|
|
83
|
+
description: "Document Time Stamp for archival (LTA)",
|
|
84
|
+
severity: :warning,
|
|
85
|
+
spec_clause: "ETSI EN 319 102-1 B.6",
|
|
86
|
+
check: ->(doc) {
|
|
87
|
+
fields = Pdfrb::Conformance::Pades.signature_fields(doc)
|
|
88
|
+
has_archival = fields.any? do |field|
|
|
89
|
+
v = field[:V]
|
|
90
|
+
v_obj = v.is_a?(Pdfrb::Model::Reference) ? doc.object(v) : v
|
|
91
|
+
v_obj && v_obj[:Type]&.to_sym == :DocTimeStamp
|
|
92
|
+
end
|
|
93
|
+
next nil if has_archival
|
|
94
|
+
|
|
95
|
+
Violation.new(
|
|
96
|
+
rule_id: "ltv-4",
|
|
97
|
+
message: "Long-Term Archival (LTA) recommends a Document Time Stamp",
|
|
98
|
+
object: "AcroForm/Fields",
|
|
99
|
+
severity: :warning,
|
|
100
|
+
spec_clause: "ETSI EN 319 102-1 B.6"
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def validate(document)
|
|
107
|
+
RULESET.validate(document)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
# rubocop:enable Metrics/BlockLength
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Metrics/BlockLength
|
|
4
|
+
module Pdfrb
|
|
5
|
+
module Conformance
|
|
6
|
+
# PAdES (ETSI EN 319 142-1) signature profile validation. Four
|
|
7
|
+
# baseline levels build on each other:
|
|
8
|
+
#
|
|
9
|
+
# B-B: Basic signatures — just the document hash + signer cert.
|
|
10
|
+
# B-T: B-B + trusted timestamp (RFC 3161) on the signature.
|
|
11
|
+
# B-LT: B-T + long-term validation material (DSS dictionary
|
|
12
|
+
# with revocation data, signer certs, TSA cert chain).
|
|
13
|
+
# B-LTA: B-LT + archival timestamp protecting the DSS itself.
|
|
14
|
+
#
|
|
15
|
+
# Each rule set checks signature dictionaries on the document's
|
|
16
|
+
# /AcroForm /Fields for the required components per level.
|
|
17
|
+
module Pades
|
|
18
|
+
ALLOWED_SIGNATURE_TYPES = %i[Sig DocTimeStamp].freeze
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
SHARED = RuleSet.new("PAdES-shared").tap do |rs|
|
|
23
|
+
rs.register(Rule.new(
|
|
24
|
+
id: "pades-1",
|
|
25
|
+
description: "At least one signature field must be present",
|
|
26
|
+
severity: :error,
|
|
27
|
+
spec_clause: "ETSI EN 319 142-1 5.2",
|
|
28
|
+
check: ->(doc) {
|
|
29
|
+
next nil if signature_fields(doc).any?
|
|
30
|
+
|
|
31
|
+
Violation.new(
|
|
32
|
+
rule_id: "pades-1",
|
|
33
|
+
message: "PAdES requires at least one signature field",
|
|
34
|
+
object: "AcroForm/Fields",
|
|
35
|
+
severity: :error,
|
|
36
|
+
spec_clause: "ETSI EN 319 142-1 5.2"
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
BB = RuleSet.new("PAdES-B-B").tap do |rs|
|
|
43
|
+
SHARED.rules.each { |r| rs.register(r) }
|
|
44
|
+
rs.register(Rule.new(
|
|
45
|
+
id: "bb-1",
|
|
46
|
+
description: "B-B: each signature must have /Type /Sig or /DocTimeStamp",
|
|
47
|
+
severity: :error,
|
|
48
|
+
spec_clause: "ETSI EN 319 142-1 5.3",
|
|
49
|
+
check: ->(doc) {
|
|
50
|
+
vs = []
|
|
51
|
+
signature_fields(doc).each do |field|
|
|
52
|
+
v = field[:V]
|
|
53
|
+
next unless v
|
|
54
|
+
|
|
55
|
+
v_obj = v.is_a?(Pdfrb::Model::Reference) ? doc.object(v) : v
|
|
56
|
+
next if v_obj && ALLOWED_SIGNATURE_TYPES.include?(v_obj[:Type]&.to_sym)
|
|
57
|
+
|
|
58
|
+
vs << Violation.new(
|
|
59
|
+
rule_id: "bb-1",
|
|
60
|
+
message: "Signature V must be /Type /Sig or /DocTimeStamp",
|
|
61
|
+
object: "Field/V",
|
|
62
|
+
severity: :error,
|
|
63
|
+
spec_clause: "ETSI EN 319 142-1 5.3"
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
vs.empty? ? nil : vs
|
|
67
|
+
}
|
|
68
|
+
))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
BT = RuleSet.new("PAdES-B-T").tap do |rs|
|
|
72
|
+
BB.rules.each { |r| rs.register(r) }
|
|
73
|
+
rs.register(Rule.new(
|
|
74
|
+
id: "bt-1",
|
|
75
|
+
description: "B-T: signature must include a trusted timestamp",
|
|
76
|
+
severity: :error,
|
|
77
|
+
spec_clause: "ETSI EN 319 142-1 5.4",
|
|
78
|
+
check: ->(doc) {
|
|
79
|
+
vs = []
|
|
80
|
+
signature_fields(doc).each do |field|
|
|
81
|
+
v = field[:V]
|
|
82
|
+
next unless v
|
|
83
|
+
|
|
84
|
+
v_obj = v.is_a?(Pdfrb::Model::Reference) ? doc.object(v) : v
|
|
85
|
+
has_timestamp = v_obj && (
|
|
86
|
+
v_obj[:Type]&.to_sym == :DocTimeStamp ||
|
|
87
|
+
(v_obj[:ByteRange] && timestamp_in_signature?(v_obj))
|
|
88
|
+
)
|
|
89
|
+
next if has_timestamp
|
|
90
|
+
|
|
91
|
+
vs << Violation.new(
|
|
92
|
+
rule_id: "bt-1",
|
|
93
|
+
message: "B-T requires a trusted timestamp (RFC 3161)",
|
|
94
|
+
object: "Field/V",
|
|
95
|
+
severity: :error,
|
|
96
|
+
spec_clause: "ETSI EN 319 142-1 5.4"
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
vs.empty? ? nil : vs
|
|
100
|
+
}
|
|
101
|
+
))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
BLT = RuleSet.new("PAdES-B-LT").tap do |rs|
|
|
105
|
+
BT.rules.each { |r| rs.register(r) }
|
|
106
|
+
rs.register(Rule.new(
|
|
107
|
+
id: "blt-1",
|
|
108
|
+
description: "B-LT: DSS dictionary required for LTV material",
|
|
109
|
+
severity: :error,
|
|
110
|
+
spec_clause: "ETSI EN 319 142-1 5.5",
|
|
111
|
+
check: ->(doc) {
|
|
112
|
+
next nil if doc.catalog[:DSS]
|
|
113
|
+
|
|
114
|
+
Violation.new(
|
|
115
|
+
rule_id: "blt-1",
|
|
116
|
+
message: "B-LT requires /DSS dictionary with validation material",
|
|
117
|
+
object: "Catalog/DSS",
|
|
118
|
+
severity: :error,
|
|
119
|
+
spec_clause: "ETSI EN 319 142-1 5.5"
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
BLTA = RuleSet.new("PAdES-B-LTA").tap do |rs|
|
|
126
|
+
BLT.rules.each { |r| rs.register(r) }
|
|
127
|
+
rs.register(Rule.new(
|
|
128
|
+
id: "blta-1",
|
|
129
|
+
description: "B-LTA: archival Document Time Stamp required",
|
|
130
|
+
severity: :error,
|
|
131
|
+
spec_clause: "ETSI EN 319 142-1 5.6",
|
|
132
|
+
check: ->(doc) {
|
|
133
|
+
# B-LTA needs a DocTimeStamp as the last signature,
|
|
134
|
+
# protecting the DSS.
|
|
135
|
+
has_archival = signature_fields(doc).any? do |field|
|
|
136
|
+
v = field[:V]
|
|
137
|
+
v_obj = v.is_a?(Pdfrb::Model::Reference) ? doc.object(v) : v
|
|
138
|
+
v_obj && v_obj[:Type]&.to_sym == :DocTimeStamp
|
|
139
|
+
end
|
|
140
|
+
next nil if has_archival
|
|
141
|
+
|
|
142
|
+
Violation.new(
|
|
143
|
+
rule_id: "blta-1",
|
|
144
|
+
message: "B-LTA requires an archival Document Time Stamp",
|
|
145
|
+
object: "AcroForm/Fields",
|
|
146
|
+
severity: :error,
|
|
147
|
+
spec_clause: "ETSI EN 319 142-1 5.6"
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
))
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
LEVEL_RULESETS = {
|
|
154
|
+
"B-B": BB, "B-T": BT, "B-LT": BLT, "B-LTA": BLTA
|
|
155
|
+
}.freeze
|
|
156
|
+
|
|
157
|
+
def profiles
|
|
158
|
+
LEVEL_RULESETS.dup
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def validate(document, level: :"B-B")
|
|
162
|
+
rs = LEVEL_RULESETS[level] || SHARED
|
|
163
|
+
rs.validate(document)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def signature_fields(document)
|
|
167
|
+
acroform = document.catalog[:AcroForm]
|
|
168
|
+
acroform = document.object(acroform) if acroform.is_a?(Pdfrb::Model::Reference)
|
|
169
|
+
return [] unless acroform
|
|
170
|
+
|
|
171
|
+
fields = acroform[:Fields]
|
|
172
|
+
fields = fields.value if fields.is_a?(Pdfrb::Model::PdfArray)
|
|
173
|
+
return [] unless fields.is_a?(::Array)
|
|
174
|
+
|
|
175
|
+
fields.filter_map do |ref|
|
|
176
|
+
obj = ref.is_a?(Pdfrb::Model::Reference) ? document.object(ref) : ref
|
|
177
|
+
next nil unless obj
|
|
178
|
+
|
|
179
|
+
obj[:FT]&.to_sym == :Sig ? obj : nil
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Heuristic: look for the standard ASN.1 timestamp OID
|
|
184
|
+
# (1.2.840.113549.1.7.2 signedData, with a TSTInfo content
|
|
185
|
+
# type). Real validation requires parsing the PKCS#7 — out of
|
|
186
|
+
# scope for this rule set, which only checks the structural
|
|
187
|
+
# presence of a ByteRange + Contents long enough to hold a
|
|
188
|
+
# timestamp token.
|
|
189
|
+
def timestamp_in_signature?(signature)
|
|
190
|
+
contents = signature[:Contents]
|
|
191
|
+
return false unless contents.is_a?(::String)
|
|
192
|
+
|
|
193
|
+
contents.bytesize > 1024
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
# rubocop:enable Metrics/BlockLength
|