hanko_xml 0.1.0-aarch64-linux → 0.2.0-aarch64-linux
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 +66 -0
- data/README.md +2 -1
- data/lib/hanko_xml/3.1/hanko_xml.so +0 -0
- data/lib/hanko_xml/3.2/hanko_xml.so +0 -0
- data/lib/hanko_xml/3.3/hanko_xml.so +0 -0
- data/lib/hanko_xml/3.4/hanko_xml.so +0 -0
- data/lib/hanko_xml/signer.rb +4 -1
- data/lib/hanko_xml/verifier.rb +10 -3
- data/lib/hanko_xml/version.rb +1 -1
- data/lib/hanko_xml/xades/augmenter.rb +248 -0
- data/lib/hanko_xml/xades/locator.rb +67 -0
- data/lib/hanko_xml/xades/long_term.rb +252 -0
- data/lib/hanko_xml/xades/timestamp.rb +386 -0
- data/lib/hanko_xml/xades/validation_data.rb +115 -0
- data/lib/hanko_xml/xades/verifier.rb +311 -37
- data/lib/hanko_xml/xades.rb +93 -6
- data/sig/hanko_xml/native.rbs +2 -0
- data/sig/hanko_xml/xades.rbs +91 -1
- metadata +7 -2
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module HankoXml
|
|
6
|
+
module Xades
|
|
7
|
+
# The revocation evidence one certificate of the chain passed with (#168),
|
|
8
|
+
# on LongTermValidation#revocation.
|
|
9
|
+
# +source+ is :ocsp or :crl; +issued_at+ its thisUpdate; +status+ :good, or
|
|
10
|
+
# :revoked_later when it was revoked after the validation time.
|
|
11
|
+
RevocationStatus = Struct.new(:certificate, :source, :issued_at, :status, keyword_init: true) do
|
|
12
|
+
def initialize(**)
|
|
13
|
+
super
|
|
14
|
+
freeze
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# A passed XAdES-LT validation (#168), on
|
|
19
|
+
# Xades::Verification#long_term_validation: the validation +time+ (the
|
|
20
|
+
# earliest verified SignatureTimeStamp), the signing +certificate_chain+
|
|
21
|
+
# (leaf first, ending at the caller's trust anchor), and the +revocation+
|
|
22
|
+
# evidence for each certificate below the anchor.
|
|
23
|
+
LongTermValidation = Struct.new(:time, :certificate_chain, :revocation, keyword_init: true) do
|
|
24
|
+
def initialize(**)
|
|
25
|
+
super
|
|
26
|
+
freeze
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The embedded <xades:CertificateValues> and <xades:RevocationValues> of
|
|
31
|
+
# one signature, parsed. Attacker-supplied until LongTermCheck uses them
|
|
32
|
+
# (S-22, S-23).
|
|
33
|
+
#
|
|
34
|
+
# @api private
|
|
35
|
+
class LongTermValues
|
|
36
|
+
attr_reader :certificates, :crls, :ocsp_responses
|
|
37
|
+
|
|
38
|
+
PATHS = {
|
|
39
|
+
certificates: "xades:CertificateValues/xades:EncapsulatedX509Certificate",
|
|
40
|
+
crls: "xades:RevocationValues/xades:CRLValues/xades:EncapsulatedCRLValue",
|
|
41
|
+
ocsp_responses: "xades:RevocationValues/xades:OCSPValues/xades:EncapsulatedOCSPValue"
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
# The values in +properties+ (an UnsignedSignatureProperties), or nil
|
|
45
|
+
# when it holds no CertificateValues or RevocationValues.
|
|
46
|
+
def self.read(properties, ns)
|
|
47
|
+
return nil unless properties
|
|
48
|
+
return nil if properties.xpath("xades:CertificateValues | xades:RevocationValues", ns).empty?
|
|
49
|
+
|
|
50
|
+
Locator.fail!("XAdES-LT values are verified only in the XAdES 1.3.2 namespace") unless ns["xades"] == NS
|
|
51
|
+
new(**PATHS.to_h { |kind, path| [kind, properties.xpath(path, ns).map { |node| parse(kind, node.text) }] })
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.parse(kind, base64)
|
|
55
|
+
der = base64.gsub(/\s/, "").unpack1("m0")
|
|
56
|
+
case kind
|
|
57
|
+
when :certificates then OpenSSL::X509::Certificate.new(der)
|
|
58
|
+
when :crls then OpenSSL::X509::CRL.new(der)
|
|
59
|
+
else
|
|
60
|
+
OpenSSL::OCSP::Response.new(der).tap do |response|
|
|
61
|
+
unless response.status == OpenSSL::OCSP::RESPONSE_STATUS_SUCCESSFUL && response.basic
|
|
62
|
+
Locator.fail!("an <xades:EncapsulatedOCSPValue> is not a successful OCSP response")
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
rescue ArgumentError, OpenSSL::X509::CertificateError, OpenSSL::X509::CRLError, OpenSSL::OCSP::OCSPError
|
|
67
|
+
Locator.fail!("an embedded XAdES-LT value is not Base64 DER of its type (#{kind})")
|
|
68
|
+
end
|
|
69
|
+
private_class_method :parse
|
|
70
|
+
|
|
71
|
+
def initialize(certificates:, crls:, ocsp_responses:)
|
|
72
|
+
@certificates = certificates.freeze
|
|
73
|
+
@crls = crls.freeze
|
|
74
|
+
@ocsp_responses = ocsp_responses.freeze
|
|
75
|
+
freeze
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Validates the signing certificate chain at the time of the earliest
|
|
80
|
+
# verified SignatureTimeStamp, with the embedded revocation data (#168).
|
|
81
|
+
#
|
|
82
|
+
# The chain itself has already been built and verified at that time by
|
|
83
|
+
# {HankoXml::Verifier} (store:, StoreContext with time=). Here, each
|
|
84
|
+
# certificate below the trust anchor needs revocation evidence that:
|
|
85
|
+
#
|
|
86
|
+
# - verifies: an OCSP response through OpenSSL::OCSP::BasicResponse#verify
|
|
87
|
+
# (the responder's signature, its chain to the same anchor, and its
|
|
88
|
+
# authorization for that certificate), with the responder's chain then
|
|
89
|
+
# checked at the response's producedAt; or a CRL signed by the
|
|
90
|
+
# certificate's issuer (CRL#verify), an issuer allowed to sign CRLs;
|
|
91
|
+
# - is fresh: issued (thisUpdate) no earlier than the validation time
|
|
92
|
+
# minus +grace+, and not in the future;
|
|
93
|
+
# - does not show the certificate revoked at or before the validation
|
|
94
|
+
# time. Revoked later is accepted: the timestamp proves the signature
|
|
95
|
+
# existed before.
|
|
96
|
+
#
|
|
97
|
+
# Evidence that does not verify or is stale is not used; a certificate
|
|
98
|
+
# left without usable evidence fails, and so does any usable evidence of
|
|
99
|
+
# an earlier revocation (fail closed, S-23).
|
|
100
|
+
#
|
|
101
|
+
# @api private
|
|
102
|
+
class LongTermCheck
|
|
103
|
+
CRL_SIGN_BIT = 6
|
|
104
|
+
|
|
105
|
+
def initialize(grace:, clock_skew:)
|
|
106
|
+
@grace = grace
|
|
107
|
+
@clock_skew = clock_skew
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# @return [LongTermValidation]
|
|
111
|
+
# @raise [XadesError]
|
|
112
|
+
def verify(time:, chain:, values:)
|
|
113
|
+
@time = time
|
|
114
|
+
@anchor = chain.last
|
|
115
|
+
@pool = (chain + values.certificates).uniq(&:to_der)
|
|
116
|
+
@values = values
|
|
117
|
+
revocation = chain.each_cons(2).with_index.map { |(certificate, issuer), i| status(certificate, issuer, i) }
|
|
118
|
+
LongTermValidation.new(time: time, certificate_chain: chain.freeze, revocation: revocation.freeze)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
# +position+ names the certificate in messages: its subject comes from
|
|
124
|
+
# the document, so it goes to #xmlsec_errors, not the message (S-13).
|
|
125
|
+
def status(certificate, issuer, position)
|
|
126
|
+
evidence = ocsp_evidence(certificate, issuer) + crl_evidence(certificate, issuer)
|
|
127
|
+
revoked = evidence.find { |item| item.status == :revoked }
|
|
128
|
+
if revoked
|
|
129
|
+
fail!("the embedded #{revoked.source.upcase} shows #{name(position)} revoked at or before the " \
|
|
130
|
+
"validation time", certificate)
|
|
131
|
+
end
|
|
132
|
+
found = evidence.min_by { |item| item.status == :good ? 0 : 1 }
|
|
133
|
+
fail!("no embedded revocation data verifies and is fresh for #{name(position)}", certificate) unless found
|
|
134
|
+
|
|
135
|
+
found
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def name(position)
|
|
139
|
+
position.zero? ? "the signing certificate" : "chain certificate #{position}"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def ocsp_evidence(certificate, issuer)
|
|
143
|
+
@values.ocsp_responses.flat_map do |response|
|
|
144
|
+
basic = response.basic
|
|
145
|
+
singles = basic.responses.select { |single| about?(single.certid, certificate, issuer) }
|
|
146
|
+
next [] if singles.empty? || !responder_verified?(basic)
|
|
147
|
+
|
|
148
|
+
singles.filter_map { |single| ocsp_status(certificate, single) }
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def about?(certid, certificate, issuer)
|
|
153
|
+
return false unless certid.serial == certificate.serial
|
|
154
|
+
|
|
155
|
+
OpenSSL::OCSP::CertificateId.new(certificate, issuer, OpenSSL::Digest.new(certid.hash_algorithm)).cmp(certid)
|
|
156
|
+
rescue OpenSSL::OCSP::OCSPError, RuntimeError
|
|
157
|
+
false
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def ocsp_status(certificate, single)
|
|
161
|
+
return nil unless fresh?(single.this_update)
|
|
162
|
+
|
|
163
|
+
status = case single.cert_status
|
|
164
|
+
when OpenSSL::OCSP::V_CERTSTATUS_GOOD then :good
|
|
165
|
+
when OpenSSL::OCSP::V_CERTSTATUS_REVOKED then revoked_status(single.revocation_time)
|
|
166
|
+
end
|
|
167
|
+
status && RevocationStatus.new(certificate: certificate, source: :ocsp, issued_at: single.this_update,
|
|
168
|
+
status: status)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def revoked_status(revoked_at)
|
|
172
|
+
revoked_at > @time ? :revoked_later : :revoked
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# OpenSSL checks the signature, the responder's chain to the anchor and
|
|
176
|
+
# its authorization; it cannot take a verification time, so it runs
|
|
177
|
+
# without time checks and the responder's chain is then verified at
|
|
178
|
+
# producedAt.
|
|
179
|
+
def responder_verified?(basic)
|
|
180
|
+
response_certificates, responder_id, produced_at = ocsp_fields(basic)
|
|
181
|
+
pool = (@pool + response_certificates).uniq(&:to_der)
|
|
182
|
+
return false unless basic.verify(pool, anchor_store(OpenSSL::X509::V_FLAG_NO_CHECK_TIME))
|
|
183
|
+
|
|
184
|
+
signer = pool.find { |candidate| responder?(candidate, responder_id) }
|
|
185
|
+
return false unless signer
|
|
186
|
+
|
|
187
|
+
context = OpenSSL::X509::StoreContext.new(anchor_store, signer, pool)
|
|
188
|
+
context.time = produced_at
|
|
189
|
+
context.verify
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# BasicOCSPResponse (RFC 6960 §4.2.1): its certs, ResponderID and
|
|
193
|
+
# producedAt, which OpenSSL::OCSP::BasicResponse does not expose.
|
|
194
|
+
def ocsp_fields(basic)
|
|
195
|
+
sequence = OpenSSL::ASN1.decode(basic.to_der).value
|
|
196
|
+
fields = sequence[0].value
|
|
197
|
+
fields = fields.drop(1) if fields.first.tag_class == :CONTEXT_SPECIFIC && fields.first.tag.zero?
|
|
198
|
+
certificates = sequence[3]&.value&.first&.value.to_a.map { |cert| OpenSSL::X509::Certificate.new(cert.to_der) }
|
|
199
|
+
[certificates, fields[0], fields[1].value]
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# ResponderID: byName [1] or byKey [2] (SHA-1 of the public key bits).
|
|
203
|
+
def responder?(certificate, responder_id)
|
|
204
|
+
value = responder_id.value.first
|
|
205
|
+
if responder_id.tag == 1
|
|
206
|
+
certificate.subject.to_der == value.to_der
|
|
207
|
+
else
|
|
208
|
+
key_bits = OpenSSL::ASN1.decode(certificate.public_key.to_der).value[1].value
|
|
209
|
+
OpenSSL::Digest.digest("SHA1", key_bits) == value.value
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def crl_evidence(certificate, issuer)
|
|
214
|
+
return [] unless crl_signer?(issuer)
|
|
215
|
+
|
|
216
|
+
@values.crls.filter_map do |crl|
|
|
217
|
+
next unless crl.issuer.to_der == issuer.subject.to_der && crl.verify(issuer.public_key)
|
|
218
|
+
next unless fresh?(crl.last_update)
|
|
219
|
+
|
|
220
|
+
entry = crl.revoked.find { |revoked| revoked.serial == certificate.serial }
|
|
221
|
+
status = entry ? revoked_status(entry.time) : :good
|
|
222
|
+
RevocationStatus.new(certificate: certificate, source: :crl, issued_at: crl.last_update, status: status)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# No KeyUsage means no restriction (RFC 5280); otherwise cRLSign.
|
|
227
|
+
def crl_signer?(issuer)
|
|
228
|
+
extension = issuer.extensions.find { |ext| ext.oid == "keyUsage" }
|
|
229
|
+
return true if extension.nil?
|
|
230
|
+
|
|
231
|
+
OpenSSL::ASN1.decode(extension.value_der).value.to_s.unpack1("B*").to_s[CRL_SIGN_BIT] == "1"
|
|
232
|
+
rescue OpenSSL::ASN1::ASN1Error
|
|
233
|
+
false
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def fresh?(issued_at)
|
|
237
|
+
issued_at >= @time - @grace && issued_at <= Time.now + @clock_skew
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def anchor_store(flags = nil)
|
|
241
|
+
OpenSSL::X509::Store.new.tap do |store|
|
|
242
|
+
store.add_cert(@anchor)
|
|
243
|
+
store.flags = flags if flags
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def fail!(message, certificate)
|
|
248
|
+
raise HankoXml::XadesError.new(message, xmlsec_errors: ["subject: #{certificate.subject}"])
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
@@ -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
|