hanko_xml 0.1.0 → 0.2.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.
@@ -0,0 +1,386 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module HankoXml
6
+ module Xades
7
+ # Obtains the RFC 3161 token for a XAdES-T <xades:SignatureTimeStamp>
8
+ # (#162). The imprint is a digest of the signature's <ds:SignatureValue>,
9
+ # canonicalized by xmlsec (#161) with the CanonicalizationMethod the
10
+ # SignatureTimeStamp skeleton already declares. OpenSSL::Timestamp builds
11
+ # the request and parses the response; the Signer's timestamper: performs
12
+ # the exchange.
13
+ #
14
+ # The response is checked against the request before it is used: status
15
+ # granted, the same imprint and digest, the same nonce. Its TSA signature
16
+ # is not checked here: that needs the TSA's trust anchors, which the
17
+ # verifier holds (#163).
18
+ #
19
+ # @api private
20
+ class SignatureTimeStamper
21
+ # The skeleton's CanonicalizationMethod and EncapsulatedTimeStamp, from
22
+ # the <ds:Signature>.
23
+ TIMESTAMP_PATH = [
24
+ [HankoXml::NS["ds"], "Object", nil], [NS, "QualifyingProperties", nil], [NS, "UnsignedProperties", nil],
25
+ [NS, "UnsignedSignatureProperties", nil], [NS, "SignatureTimeStamp", nil]
26
+ ].freeze
27
+ METHOD_PATH = [*TIMESTAMP_PATH, [HankoXml::NS["ds"], "CanonicalizationMethod", nil]].freeze
28
+ ENCAPSULATED_XPATH = "./ds:Object/xades:QualifyingProperties/xades:UnsignedProperties/" \
29
+ "xades:UnsignedSignatureProperties/xades:SignatureTimeStamp/xades:EncapsulatedTimeStamp"
30
+
31
+ # PKIStatus granted and grantedWithMods (RFC 3161 §2.4.2).
32
+ GRANTED = [0, 1].freeze
33
+
34
+ def initialize(signer)
35
+ @signer = signer
36
+ @digest = HankoXml::Algorithms.name_for(
37
+ :digest, HankoXml::Algorithms.uri_for(:digest, signer.timestamp_digest_method)
38
+ ).to_s.tr("_", "-")
39
+ end
40
+
41
+ def self.check_timestamper!(timestamper)
42
+ return if timestamper.nil? || timestamper.respond_to?(:call)
43
+
44
+ raise HankoXml::ConfigurationError, "timestamper: must respond to #call (TimeStampReq DER -> TimeStampResp DER)"
45
+ end
46
+
47
+ # The imprint algorithms go through the registry and +policy+, like
48
+ # every algorithm the signature itself uses.
49
+ def self.check_algorithms!(policy, digest:, canonicalization:)
50
+ { digest: digest, canonicalization: canonicalization }.each do |slot, name|
51
+ unless HankoXml::Algorithms.uri_for(slot, name)
52
+ raise HankoXml::ConfigurationError,
53
+ "timestamp_#{slot}_method: #{name.inspect} is not a registered #{slot} algorithm"
54
+ end
55
+
56
+ policy.enforce_algorithm!(slot, name)
57
+ end
58
+ end
59
+
60
+ # The DER TimeStampToken for the nth signature in +signed_bytes+.
61
+ # +method_path+ leads from the <ds:Signature> to the skeleton's
62
+ # CanonicalizationMethod.
63
+ def token(signed_bytes, index, method_path = METHOD_PATH)
64
+ token_for(Native.canonicalize(signed_bytes, index, [[HankoXml::NS["ds"], "SignatureValue", nil]],
65
+ method_path))
66
+ end
67
+
68
+ # The DER TimeStampToken over the digest of +input+ (an ArchiveTimeStamp's
69
+ # input, #169).
70
+ def token_for(input)
71
+ request = build_request(OpenSSL::Digest.digest(@digest, input))
72
+ response = exchange(request)
73
+ check_response!(request, response)
74
+ response.token.to_der
75
+ end
76
+
77
+ private
78
+
79
+ def build_request(imprint)
80
+ OpenSSL::Timestamp::Request.new.tap do |request|
81
+ request.algorithm = @digest
82
+ request.message_imprint = imprint
83
+ request.nonce = OpenSSL::BN.rand(64)
84
+ request.cert_requested = true
85
+ end
86
+ end
87
+
88
+ def exchange(request)
89
+ der = @signer.timestamper.call(request.to_der)
90
+ unless der.is_a?(String)
91
+ raise HankoXml::SigningError, "timestamper: returned #{der.class}, not a DER TimeStampResp"
92
+ end
93
+
94
+ OpenSSL::Timestamp::Response.new(der)
95
+ rescue OpenSSL::Timestamp::TimestampError
96
+ raise HankoXml::SigningError, "timestamper: returned bytes that are not a DER TimeStampResp"
97
+ end
98
+
99
+ def check_response!(request, response)
100
+ unless GRANTED.include?(response.status.to_i) && response.token
101
+ raise HankoXml::SigningError,
102
+ "the TSA did not grant the timestamp (status #{response.status.to_i}#{status_text(response)})"
103
+ end
104
+
105
+ info = response.token_info
106
+ unless info.message_imprint == request.message_imprint && digest_name(info.algorithm) == @digest
107
+ raise HankoXml::SigningError, "the TSA's token does not cover this signature's SignatureValue imprint"
108
+ end
109
+ return if info.nonce == request.nonce
110
+
111
+ raise HankoXml::SigningError, "the TSA's token does not carry the request's nonce"
112
+ end
113
+
114
+ def digest_name(algorithm)
115
+ OpenSSL::Digest.new(algorithm).name.downcase
116
+ rescue RuntimeError, OpenSSL::Digest::DigestError
117
+ nil
118
+ end
119
+
120
+ def status_text(response)
121
+ text = Array(response.status_text).join("; ")
122
+ text.empty? ? "" : ": #{text}"
123
+ end
124
+ end
125
+ end
126
+ end
127
+
128
+ module HankoXml
129
+ module Xades
130
+ # A verified <xades:SignatureTimeStamp> (#163), on
131
+ # Xades::Verification#signature_timestamps, or <xades141:ArchiveTimeStamp>
132
+ # (#169), on Xades::Verification#archive_timestamps.
133
+ SignatureTimeStamp = Struct.new(:id, :time, :serial_number, :tsa_certificate, :digest_method, :policy_id,
134
+ keyword_init: true) do
135
+ def initialize(**)
136
+ super
137
+ freeze
138
+ end
139
+ end
140
+
141
+ # Verifies one <xades:SignatureTimeStamp> of a signature whose XML-DSig
142
+ # and signed properties already verified (#163).
143
+ #
144
+ # OpenSSL does the RFC 3161 work: OpenSSL::Timestamp::Response#verify
145
+ # checks the TSA's signature, its chain to the caller's TSA trust and the
146
+ # timeStamping purpose, and that the token's imprint is the digest of the
147
+ # bytes this class hands it. Those bytes are the <ds:SignatureValue>,
148
+ # canonicalized by xmlsec (#161) with the CanonicalizationMethod the
149
+ # SignatureTimeStamp declares. Both algorithms go through the policy.
150
+ #
151
+ # The element is attacker-controlled until this passes: it lies outside
152
+ # everything the signature covers.
153
+ #
154
+ # @api private
155
+ class SignatureTimeStampCheck
156
+ SIGNATURE_VALUE_PATH = [[HankoXml::NS["ds"], "SignatureValue", nil]].freeze
157
+ # The children a SignatureTimeStamp may have. Include and ReferenceInfo
158
+ # belong to other timestamp kinds, XMLTimeStamp has no defined format.
159
+ ALLOWED_CHILDREN = [[HankoXml::NS["ds"], "CanonicalizationMethod"], [:xades, "EncapsulatedTimeStamp"]].freeze
160
+
161
+ # +path+ is the native path from the <ds:Signature> to this
162
+ # SignatureTimeStamp.
163
+ def initialize(node, path, ns:, policy:, tsa_store:)
164
+ @node = node
165
+ @path = path
166
+ @ns = ns
167
+ @policy = policy
168
+ @tsa_store = tsa_store
169
+ end
170
+
171
+ # @return [SignatureTimeStamp]
172
+ # @raise [XadesError, PolicyError]
173
+ def verify(xml_bytes, signature_index)
174
+ check_children!
175
+ check_canonicalization_method!
176
+ canonical = imprint_input(xml_bytes, signature_index)
177
+ response = wrap(token_der)
178
+ digest = digest_method(response.token_info.algorithm)
179
+ verify_token!(response, digest, canonical)
180
+ result(response, digest)
181
+ rescue HankoXml::NativeError, HankoXml::ParseError
182
+ fail!("the #{covered} could not be canonicalized for the #{kind}")
183
+ end
184
+
185
+ private
186
+
187
+ def kind
188
+ "SignatureTimeStamp"
189
+ end
190
+
191
+ def element
192
+ "xades:SignatureTimeStamp"
193
+ end
194
+
195
+ def covered
196
+ "SignatureValue"
197
+ end
198
+
199
+ def method_path
200
+ [*@path, [HankoXml::NS["ds"], "CanonicalizationMethod", nil]]
201
+ end
202
+
203
+ # The bytes the token's imprint must be the digest of.
204
+ def imprint_input(xml_bytes, signature_index)
205
+ Native.canonicalize(xml_bytes, signature_index, SIGNATURE_VALUE_PATH, method_path)
206
+ end
207
+
208
+ def check_children!
209
+ children = @node.element_children
210
+ unless children.all? { |child| allowed_child?(child) }
211
+ fail!("a <#{element}> holds only CanonicalizationMethod and EncapsulatedTimeStamp")
212
+ end
213
+ return if children.count { |child| child.name == "EncapsulatedTimeStamp" } == 1
214
+
215
+ fail!("a <#{element}> needs exactly one <xades:EncapsulatedTimeStamp>")
216
+ end
217
+
218
+ def allowed_child?(child)
219
+ ALLOWED_CHILDREN.any? do |ns, name|
220
+ child.name == name && child.namespace&.href == (ns == :xades ? @ns["xades"] : ns)
221
+ end
222
+ end
223
+
224
+ def check_canonicalization_method!
225
+ methods = @node.xpath("ds:CanonicalizationMethod", @ns)
226
+ unless methods.size == 1 && methods.first["Algorithm"]
227
+ fail!("a <#{element}> without a CanonicalizationMethod is not supported")
228
+ end
229
+
230
+ @policy.enforce_algorithm!(:canonicalization, methods.first["Algorithm"])
231
+ end
232
+
233
+ def token_der
234
+ @node.at_xpath("xades:EncapsulatedTimeStamp", @ns).text.gsub(/\s/, "").unpack1("m0")
235
+ rescue ArgumentError
236
+ fail!("<xades:EncapsulatedTimeStamp> is not Base64")
237
+ end
238
+
239
+ # A TimeStampToken is a ContentInfo; OpenSSL verifies it as the token of
240
+ # a granted TimeStampResp, so it is wrapped in one.
241
+ def wrap(token)
242
+ status = OpenSSL::ASN1::Sequence([OpenSSL::ASN1::Integer(0)])
243
+ OpenSSL::Timestamp::Response.new(OpenSSL::ASN1::Sequence([status, OpenSSL::ASN1.decode(token)]).to_der)
244
+ rescue OpenSSL::ASN1::ASN1Error, OpenSSL::Timestamp::TimestampError
245
+ fail!("<xades:EncapsulatedTimeStamp> is not an RFC 3161 TimeStampToken OpenSSL can read")
246
+ end
247
+
248
+ # The token's imprint digest, as a registry URI the policy allows.
249
+ def digest_method(openssl_name)
250
+ name = OpenSSL::Digest.new(openssl_name.to_s).name.downcase.tr("-", "_").to_sym
251
+ uri = HankoXml::Algorithms.uri_for(:digest, name)
252
+ fail!("the #{kind} imprint digest is not a registered digest") unless uri
253
+
254
+ @policy.enforce_algorithm!(:digest, uri)
255
+ [name, uri]
256
+ rescue RuntimeError, OpenSSL::Digest::DigestError
257
+ fail!("the #{kind} imprint digest is not a registered digest")
258
+ end
259
+
260
+ def verify_token!(response, digest, canonical)
261
+ request = OpenSSL::Timestamp::Request.new
262
+ request.algorithm = digest.first.to_s.tr("_", "-")
263
+ request.message_imprint = OpenSSL::Digest.digest(request.algorithm, canonical)
264
+ response.verify(request, @tsa_store)
265
+ rescue OpenSSL::Timestamp::TimestampError
266
+ fail!("the #{kind} does not verify: its TSA is not trusted, its signature is invalid, " \
267
+ "or it does not cover this signature's #{covered}")
268
+ end
269
+
270
+ def result(response, digest)
271
+ info = response.token_info
272
+ SignatureTimeStamp.new(
273
+ id: @node["Id"]&.dup&.freeze,
274
+ time: info.gen_time.utc.freeze,
275
+ serial_number: info.serial_number.to_i,
276
+ tsa_certificate: response.tsa_certificate,
277
+ digest_method: digest.last,
278
+ policy_id: info.policy_id
279
+ )
280
+ end
281
+
282
+ def fail!(message)
283
+ raise HankoXml::XadesError, message
284
+ end
285
+ end
286
+
287
+ # Verifies one <xades141:ArchiveTimeStamp> (XAdES-LTA, #169) as
288
+ # SignatureTimeStampCheck does a SignatureTimeStamp, over the input EN 319
289
+ # 132-1 §5.5.2.2 defines, concatenated in this order:
290
+ #
291
+ # 1. each SignedInfo Reference's data, as xmlsec digested it
292
+ # (Native.reference_data);
293
+ # 2. ds:SignedInfo, ds:SignatureValue and ds:KeyInfo (if present);
294
+ # 3. every UnsignedSignatureProperties child before this ArchiveTimeStamp;
295
+ # 4. every ds:Object except the one holding QualifyingProperties;
296
+ #
297
+ # 2 to 4 each canonicalized by xmlsec (#161) with the ArchiveTimeStamp's
298
+ # CanonicalizationMethod. A Reference whose data is a node set was
299
+ # canonicalized by xmlsec with inclusive c14n, so it is only accepted when
300
+ # that is the ArchiveTimeStamp's method too.
301
+ #
302
+ # @api private
303
+ class ArchiveTimeStampCheck < SignatureTimeStampCheck
304
+ INCLUSIVE_C14N = "http://www.w3.org/TR/2001/REC-xml-c14n-20010315"
305
+ BASE64_TRANSFORM = "http://www.w3.org/2000/09/xmldsig#base64"
306
+
307
+ # Whether any SignedInfo Reference leaves a node set: its transforms do
308
+ # not end in a canonicalization or base64, so xmlsec canonicalized it
309
+ # with inclusive c14n before digesting.
310
+ def self.node_set_references?(signature_info)
311
+ signature_info.references.any? do |reference|
312
+ last = reference.transforms.last
313
+ last.nil? || !(HankoXml::Algorithms.name_for(:canonicalization, last) || last == BASE64_TRANSFORM)
314
+ end
315
+ end
316
+
317
+ # The native paths of EN 319 132-1 §5.5.2.2 steps 2 to 4 for the
318
+ # ArchiveTimeStamp +node+ in +properties+ (an UnsignedSignatureProperties
319
+ # at native +path+).
320
+ def self.element_paths(signature, qualifying, properties, node, path)
321
+ ds = HankoXml::NS["ds"]
322
+ paths = [[[ds, "SignedInfo", nil]], [[ds, "SignatureValue", nil]]]
323
+ paths << [[ds, "KeyInfo", nil]] if signature.at_xpath("ds:KeyInfo", "ds" => ds)
324
+ properties.element_children.take_while { |child| !child.equal?(node) }.each do |child|
325
+ paths << [*path, sibling_step(child)]
326
+ end
327
+ signature.xpath("ds:Object", "ds" => ds).each_with_index do |object, i|
328
+ paths << [[ds, "Object", i]] unless object.equal?(qualifying.parent)
329
+ end
330
+ paths
331
+ end
332
+
333
+ def self.sibling_step(child)
334
+ href = child.namespace&.href
335
+ Locator.fail!("an unsigned signature property has no namespace") unless href
336
+
337
+ same = child.parent.element_children.select do |other|
338
+ other.name == child.name && other.namespace&.href == href
339
+ end
340
+ [href, child.name, same.index(child)]
341
+ end
342
+ private_class_method :sibling_step
343
+
344
+ # The concatenated input: the References' data, then each element at
345
+ # +element_paths+ canonicalized with the method at +method_path+.
346
+ def self.input(xml_bytes, signature_index, references, element_paths, method_path)
347
+ element_paths.inject(references.map(&:b).join.b) do |bytes, path|
348
+ bytes << Native.canonicalize(xml_bytes, signature_index, path, method_path).b
349
+ end
350
+ end
351
+
352
+ # +references+: the octets of step 1; +node_set_references+: whether any
353
+ # of them was a node set; +element_paths+: the native paths of 2 to 4.
354
+ def initialize(node, path, references:, node_set_references:, element_paths:, **options)
355
+ super(node, path, **options)
356
+ @references = references
357
+ @node_set_references = node_set_references
358
+ @element_paths = element_paths
359
+ end
360
+
361
+ private
362
+
363
+ def kind
364
+ "ArchiveTimeStamp"
365
+ end
366
+
367
+ def element
368
+ "xades141:ArchiveTimeStamp"
369
+ end
370
+
371
+ def covered
372
+ "signed data, signature and validation data"
373
+ end
374
+
375
+ def imprint_input(xml_bytes, signature_index)
376
+ method = @node.at_xpath("ds:CanonicalizationMethod", @ns)["Algorithm"]
377
+ if @node_set_references && method != INCLUSIVE_C14N
378
+ fail!("an ArchiveTimeStamp over a Reference whose data is a node set needs inclusive c14n " \
379
+ "as its CanonicalizationMethod")
380
+ end
381
+
382
+ self.class.input(xml_bytes, signature_index, @references, @element_paths, method_path)
383
+ end
384
+ end
385
+ end
386
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module HankoXml
6
+ module Xades
7
+ # The XAdES-LT validation material (#167): certificates for
8
+ # <xades:CertificateValues>, CRLs and OCSP responses for
9
+ # <xades:RevocationValues>. The caller collects it; the library never
10
+ # fetches. Nothing here is trusted: a verifier treats it as
11
+ # attacker-supplied until it chains to its own anchors (S-22, #168).
12
+ #
13
+ # Each entry is checked for its type only: X.509 certificates, X.509
14
+ # CRLs, and OCSPResponses whose responseStatus is successful.
15
+ ValidationData = Struct.new(:certificates, :crls, :ocsp_responses, keyword_init: true) do
16
+ # @param certificates [Array<OpenSSL::X509::Certificate, String, Key>] Anything Key.coerce reads.
17
+ # @param crls [Array<OpenSSL::X509::CRL, String>] PEM or DER.
18
+ # @param ocsp_responses [Array<OpenSSL::OCSP::Response, String>] DER OCSPResponse.
19
+ def initialize(certificates: [], crls: [], ocsp_responses: [])
20
+ super(
21
+ certificates: Array(certificates).map { |value| ValidationData.certificate(value) }.freeze,
22
+ crls: Array(crls).map { |value| ValidationData.crl(value) }.freeze,
23
+ ocsp_responses: Array(ocsp_responses).map { |value| ValidationData.ocsp_response(value) }.freeze
24
+ )
25
+ if to_h.values.all?(&:empty?)
26
+ raise HankoXml::ConfigurationError, "validation_data: needs certificates:, crls: or ocsp_responses:"
27
+ end
28
+
29
+ freeze
30
+ end
31
+
32
+ def self.coerce(value)
33
+ case value
34
+ when nil, ValidationData then value
35
+ when Hash then new(**value)
36
+ else
37
+ raise HankoXml::ConfigurationError,
38
+ "validation_data: must be a Xades::ValidationData, a Hash of its keywords or nil"
39
+ end
40
+ end
41
+
42
+ def self.certificate(value)
43
+ key = HankoXml::Key.coerce(value)
44
+ return key.to_openssl if key.certificate?
45
+
46
+ raise HankoXml::ConfigurationError, "validation_data certificates: must be X.509 certificates"
47
+ rescue HankoXml::KeyLoadError, ::ArgumentError
48
+ raise HankoXml::ConfigurationError, "validation_data certificates: must be X.509 certificates"
49
+ end
50
+
51
+ def self.crl(value)
52
+ return value if value.is_a?(OpenSSL::X509::CRL)
53
+ raise HankoXml::ConfigurationError, "validation_data crls: must be X.509 CRLs" unless value.is_a?(String)
54
+
55
+ OpenSSL::X509::CRL.new(value)
56
+ rescue OpenSSL::X509::CRLError
57
+ raise HankoXml::ConfigurationError, "validation_data crls: must be X.509 CRLs (PEM or DER)"
58
+ end
59
+
60
+ def self.ocsp_response(value)
61
+ response = value.is_a?(OpenSSL::OCSP::Response) ? value : parse_ocsp_response(value)
62
+ unless response.status == OpenSSL::OCSP::RESPONSE_STATUS_SUCCESSFUL && response.basic
63
+ raise HankoXml::ConfigurationError,
64
+ "validation_data ocsp_responses: must be successful OCSP responses " \
65
+ "(responseStatus #{response.status})"
66
+ end
67
+
68
+ response
69
+ end
70
+
71
+ def self.parse_ocsp_response(value)
72
+ unless value.is_a?(String)
73
+ raise HankoXml::ConfigurationError, "validation_data ocsp_responses: must be DER OCSPResponses"
74
+ end
75
+
76
+ OpenSSL::OCSP::Response.new(value)
77
+ rescue OpenSSL::OCSP::OCSPError
78
+ raise HankoXml::ConfigurationError, "validation_data ocsp_responses: must be DER OCSPResponses"
79
+ end
80
+ private_class_method :parse_ocsp_response
81
+
82
+ # Appends <xades:CertificateValues> and <xades:RevocationValues> (each
83
+ # only when it has entries) to +parent+, an UnsignedSignatureProperties
84
+ # in +namespace+.
85
+ def append_to(parent, namespace)
86
+ add = lambda do |node, name|
87
+ child = parent.document.create_element(name)
88
+ node.add_child(child)
89
+ child.namespace = namespace
90
+ child
91
+ end
92
+ encapsulate = ->(node, name, der) { add.call(node, name).content = [der].pack("m0") }
93
+
94
+ unless certificates.empty?
95
+ values = add.call(parent, "CertificateValues")
96
+ certificates.each do |certificate|
97
+ encapsulate.call(values, "EncapsulatedX509Certificate", certificate.to_der)
98
+ end
99
+ end
100
+ return if crls.empty? && ocsp_responses.empty?
101
+
102
+ # RevocationValuesType is a sequence: CRLValues, then OCSPValues.
103
+ revocation = add.call(parent, "RevocationValues")
104
+ unless crls.empty?
105
+ values = add.call(revocation, "CRLValues")
106
+ crls.each { |crl| encapsulate.call(values, "EncapsulatedCRLValue", crl.to_der) }
107
+ end
108
+ return if ocsp_responses.empty?
109
+
110
+ values = add.call(revocation, "OCSPValues")
111
+ ocsp_responses.each { |response| encapsulate.call(values, "EncapsulatedOCSPValue", response.to_der) }
112
+ end
113
+ end
114
+ end
115
+ end