hanko_xml 0.1.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 +7 -0
- data/CHANGELOG.md +44 -0
- data/LICENSE.txt +21 -0
- data/README.md +222 -0
- data/THIRD_PARTY_LICENSES.md +392 -0
- data/dependencies.yml +87 -0
- 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/algorithms.rb +228 -0
- data/lib/hanko_xml/dom.rb +137 -0
- data/lib/hanko_xml/errors.rb +132 -0
- data/lib/hanko_xml/exc-c14n.xsd +26 -0
- data/lib/hanko_xml/input.rb +50 -0
- data/lib/hanko_xml/key.rb +213 -0
- data/lib/hanko_xml/key_info.rb +67 -0
- data/lib/hanko_xml/native_version.rb +75 -0
- data/lib/hanko_xml/nokogiri_ext.rb +34 -0
- data/lib/hanko_xml/placement.rb +127 -0
- data/lib/hanko_xml/policy.rb +502 -0
- data/lib/hanko_xml/reference.rb +49 -0
- data/lib/hanko_xml/signature_info.rb +134 -0
- data/lib/hanko_xml/signed_document.rb +155 -0
- data/lib/hanko_xml/signer.rb +572 -0
- data/lib/hanko_xml/template.rb +150 -0
- data/lib/hanko_xml/transform.rb +87 -0
- data/lib/hanko_xml/verification.rb +377 -0
- data/lib/hanko_xml/verifier.rb +694 -0
- data/lib/hanko_xml/version.rb +5 -0
- data/lib/hanko_xml/xades/verifier.rb +319 -0
- data/lib/hanko_xml/xades.rb +287 -0
- data/lib/hanko_xml/xml.rb +93 -0
- data/lib/hanko_xml/xmldsig-core-schema.xsd +332 -0
- data/lib/hanko_xml.rb +122 -0
- data/sig/hanko_xml/key.rbs +48 -0
- data/sig/hanko_xml/native.rbs +12 -0
- data/sig/hanko_xml/native_version.rbs +13 -0
- data/sig/hanko_xml/nokogiri_ext.rbs +23 -0
- data/sig/hanko_xml/placement.rbs +27 -0
- data/sig/hanko_xml/policy.rbs +83 -0
- data/sig/hanko_xml/reference.rbs +36 -0
- data/sig/hanko_xml/signed_document.rbs +66 -0
- data/sig/hanko_xml/signer.rbs +50 -0
- data/sig/hanko_xml/template.rbs +41 -0
- data/sig/hanko_xml/verification.rbs +100 -0
- data/sig/hanko_xml/verifier.rbs +45 -0
- data/sig/hanko_xml/xades.rbs +70 -0
- data/sig/hanko_xml.rbs +62 -0
- metadata +104 -0
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HankoXml
|
|
4
|
+
# Orchestrates XML Digital Signature verification.
|
|
5
|
+
# Handles fetching signatures, validating against a schema, verifying cryptographic integrity,
|
|
6
|
+
# and enforcing security policies.
|
|
7
|
+
class Verifier # rubocop:disable Metrics/ClassLength
|
|
8
|
+
attr_reader :policy, :id_attributes, :namespaces, :signature, :verification_time, :schema, :referenced_documents,
|
|
9
|
+
:resolver
|
|
10
|
+
|
|
11
|
+
# Distinguishes "caller did not pass schema:" from "caller explicitly
|
|
12
|
+
# passed schema: nil" so schema: can default to policy.schema (S-06)
|
|
13
|
+
# without losing the ability to explicitly opt out.
|
|
14
|
+
UNSET_SCHEMA = Object.new.freeze
|
|
15
|
+
private_constant :UNSET_SCHEMA
|
|
16
|
+
|
|
17
|
+
# Creates a new Verifier with the given trust anchors and configuration.
|
|
18
|
+
# Exactly one trust source must be specified.
|
|
19
|
+
#
|
|
20
|
+
# @param certificate [Key, OpenSSL::X509::Certificate, String, Pathname] A single trusted certificate.
|
|
21
|
+
# @param certificates [Array<Key, OpenSSL::X509::Certificate, String, Pathname>] A list of trusted certificates.
|
|
22
|
+
# @param store [OpenSSL::X509::Store] An OpenSSL store of trusted root certificates.
|
|
23
|
+
# @param key [Key, OpenSSL::PKey::PKey, String, Pathname] A trusted public key.
|
|
24
|
+
# @param keys [Hash<String, Key>] A mapping of `<KeyName>` to trusted keys.
|
|
25
|
+
# @param policy [Policy] The security policy to enforce (defaults to {Policy.strict}).
|
|
26
|
+
# @param id_attributes [Array<String>] Attributes to consider as IDs for `#foo` references.
|
|
27
|
+
# @param resolver [Proc, nil] Optional callable `(doc, uri_fragment) -> node` for fragment resolution in
|
|
28
|
+
# `Verification#references` / `#signed?`.
|
|
29
|
+
# @param namespaces [Hash<String, String>] Namespaces for XPath signature selection.
|
|
30
|
+
# @param signature [Symbol, Integer, String, nil] Which signature to verify:
|
|
31
|
+
# `:first` (explicitly take the first signature even if more exist), `:only`
|
|
32
|
+
# (require exactly one, raising {MultipleSignatures} otherwise), an
|
|
33
|
+
# integer index (document order, matching {Signer#sign!}'s `signature:`), or
|
|
34
|
+
# an XPath. Defaults to `nil`, which behaves like `:only`: with a single
|
|
35
|
+
# `<Signature>` it is verified, but with more than one {MultipleSignatures}
|
|
36
|
+
# is raised rather than silently verifying just the first (S-11) --
|
|
37
|
+
# pass `:first` to opt into the old unsafe default explicitly.
|
|
38
|
+
# @param verification_time [Time, nil] The time to use for checking certificate expiration bounds.
|
|
39
|
+
# @param schema [Symbol, String, Nokogiri::XML::Schema, nil] An XSD schema to
|
|
40
|
+
# pre-validate the signature node (e.g. `:default`). Defaults to
|
|
41
|
+
# `policy.schema` (S-06): `Policy.strict.schema == :default` turns on
|
|
42
|
+
# XSD validation without the caller passing `schema:` explicitly. Pass
|
|
43
|
+
# `schema: nil` explicitly to opt out regardless of the policy.
|
|
44
|
+
# @param referenced_documents [Hash<String, String | Nokogiri::XML::Document>] The only source an external
|
|
45
|
+
# reference is read from, keyed by its URI (without any `#fragment`). Pass the exact bytes that were signed
|
|
46
|
+
# as a String; a Document is serialized first, which only matches if that reproduces those bytes. Nothing
|
|
47
|
+
# is ever fetched, and a URI not in the map fails verification with a {ReferenceError} in
|
|
48
|
+
# {Verification#errors}; only {#verify!} raises it (#74).
|
|
49
|
+
def initialize(certificate: nil, certificates: nil, store: nil, key: nil, keys: nil,
|
|
50
|
+
policy: HankoXml::Policy.strict, id_attributes: %w[Id ID id], resolver: nil,
|
|
51
|
+
namespaces: {}, signature: nil, verification_time: nil,
|
|
52
|
+
schema: UNSET_SCHEMA, referenced_documents: {}, certificate_subject: nil)
|
|
53
|
+
supplied_trusts = [certificate, certificates, store, key, keys].compact
|
|
54
|
+
if !@without_trust && supplied_trusts.size != 1
|
|
55
|
+
raise HankoXml::ConfigurationError,
|
|
56
|
+
"Specify exactly one trust source: certificate, certificates, store, key, or keys"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
@certificate = certificate && HankoXml::Key.coerce(certificate)
|
|
60
|
+
@certificates = certificates&.map { |c| HankoXml::Key.coerce(c) }
|
|
61
|
+
@store = store
|
|
62
|
+
@key = key && HankoXml::Key.coerce(key)
|
|
63
|
+
@keys = keys
|
|
64
|
+
|
|
65
|
+
# resolver: is called with the caller's document, so it needs one.
|
|
66
|
+
Dom.require!("Verifier resolver:") if resolver
|
|
67
|
+
|
|
68
|
+
@policy = policy
|
|
69
|
+
@id_attributes = id_attributes
|
|
70
|
+
@resolver = resolver
|
|
71
|
+
@namespaces = namespaces
|
|
72
|
+
@signature = signature
|
|
73
|
+
@verification_time = verification_time
|
|
74
|
+
@schema = schema.equal?(UNSET_SCHEMA) ? policy.schema : schema
|
|
75
|
+
@referenced_documents = coerce_referenced_documents(referenced_documents)
|
|
76
|
+
@certificate_subject = coerce_certificate_subject(certificate_subject)
|
|
77
|
+
|
|
78
|
+
freeze
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The keywords that name a trust source.
|
|
82
|
+
# @api private
|
|
83
|
+
TRUST_KEYWORDS = %i[certificate certificates store key keys certificate_subject].freeze
|
|
84
|
+
|
|
85
|
+
# An integrity-only Verifier (#103): it checks that the document was not
|
|
86
|
+
# changed since it was signed by the key in its own <X509Certificate>,
|
|
87
|
+
# and proves NOTHING about who signed it. Never sufficient on its own.
|
|
88
|
+
#
|
|
89
|
+
# The key is still pinned, never chosen by xmlsec from <KeyInfo> (S-01):
|
|
90
|
+
# it is the public key of the one end-entity certificate <KeyInfo>
|
|
91
|
+
# carries. Its Verification has #certificate set, #trusted? false and
|
|
92
|
+
# #certificate_chain empty, and #verify! raises UntrustedCertificate
|
|
93
|
+
# unless called with allow_untrusted: true -- two explicit steps to the
|
|
94
|
+
# dangerous outcome. Takes every Verifier keyword except the trust
|
|
95
|
+
# sources.
|
|
96
|
+
#
|
|
97
|
+
# @return [Verifier]
|
|
98
|
+
def self.without_trust_verification(**options)
|
|
99
|
+
conflicting = options.keys & TRUST_KEYWORDS
|
|
100
|
+
unless conflicting.empty?
|
|
101
|
+
raise HankoXml::ConfigurationError,
|
|
102
|
+
"without_trust_verification takes no trust source (got #{conflicting.join(", ")}): " \
|
|
103
|
+
"use Verifier.new with one to verify who signed"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
allocate.tap { |verifier| verifier.send(:initialize_without_trust, **options) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Whether this Verifier checks integrity only (.without_trust_verification).
|
|
110
|
+
def without_trust?
|
|
111
|
+
@without_trust == true
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Verifies the target signature(s). Returns a single {Verification} object.
|
|
115
|
+
# Does not raise cryptographic errors; instead, captures them in the returned object.
|
|
116
|
+
#
|
|
117
|
+
# @param xml [String, Nokogiri::XML::Node, Nokogiri::XML::Document, IO] The signed XML.
|
|
118
|
+
# @return [HankoXml::Verification] The verification result.
|
|
119
|
+
def verify(xml)
|
|
120
|
+
input = HankoXml::Input.read(xml, policy: @policy)
|
|
121
|
+
count = signature_count(input)
|
|
122
|
+
|
|
123
|
+
# A missing signature is an error state: a Verification with
|
|
124
|
+
# valid=false and a SignatureMissing error, never a raise.
|
|
125
|
+
return missing_signature(input, "No <Signature> element found") if count.zero?
|
|
126
|
+
|
|
127
|
+
# For :first, :only, integer, or xpath
|
|
128
|
+
index = resolve_signature_target(input, count, @signature)
|
|
129
|
+
return missing_signature(input, "Target signature not found") if index.nil?
|
|
130
|
+
|
|
131
|
+
verify_single(input, index)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Verifies the target signature(s) and raises an error if verification fails.
|
|
135
|
+
#
|
|
136
|
+
# @param xml [String, Nokogiri::XML::Node, Nokogiri::XML::Document, IO] The signed XML.
|
|
137
|
+
# @param allow_untrusted [Boolean] Accept an integrity-only result from a
|
|
138
|
+
# {.without_trust_verification} Verifier instead of raising (#103).
|
|
139
|
+
# @raise [HankoXml::VerificationError] If the signature is invalid or violates policy.
|
|
140
|
+
# @raise [HankoXml::UntrustedCertificate] If it is valid but untrusted, without allow_untrusted: true.
|
|
141
|
+
# @return [HankoXml::Verification] The valid verification result.
|
|
142
|
+
def verify!(xml, allow_untrusted: false)
|
|
143
|
+
result = verify(xml)
|
|
144
|
+
unless result.valid?
|
|
145
|
+
# Raise the first error, or a generic VerificationError
|
|
146
|
+
error = result.errors.first || HankoXml::VerificationError.new("Signature verification failed")
|
|
147
|
+
raise error
|
|
148
|
+
end
|
|
149
|
+
# The second explicit step for an integrity-only Verifier (#103).
|
|
150
|
+
if !result.trusted? && !allow_untrusted
|
|
151
|
+
raise HankoXml::UntrustedCertificate,
|
|
152
|
+
"the document was not modified, but nothing established trust in its signer " \
|
|
153
|
+
"(without_trust_verification); pass allow_untrusted: true to accept that"
|
|
154
|
+
end
|
|
155
|
+
result
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Returns true if the document signature is valid.
|
|
159
|
+
#
|
|
160
|
+
# @param xml [String, Nokogiri::XML::Node, Nokogiri::XML::Document, IO] The signed XML.
|
|
161
|
+
# @return [Boolean] True if valid, false otherwise.
|
|
162
|
+
def valid?(xml)
|
|
163
|
+
verify(xml).valid?
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Verifies all signatures in the document and returns an array of {Verification} results.
|
|
167
|
+
#
|
|
168
|
+
# @param xml [String, Nokogiri::XML::Node, Nokogiri::XML::Document, IO] The signed XML.
|
|
169
|
+
# @return [Array<HankoXml::Verification>] An array of verification results.
|
|
170
|
+
def verify_all(xml)
|
|
171
|
+
input = HankoXml::Input.read(xml, policy: @policy)
|
|
172
|
+
count = signature_count(input)
|
|
173
|
+
|
|
174
|
+
Array.new(count) { |index| verify_single(input, index) }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# The bundled XML-DSig schema `schema: :default` validates against.
|
|
178
|
+
DEFAULT_SCHEMA_PATH = File.expand_path("xmldsig-core-schema.xsd", __dir__).freeze
|
|
179
|
+
|
|
180
|
+
# The bundled XML-DSig schema as a Nokogiri::XML::Schema, for callers who
|
|
181
|
+
# validate other documents with it. `schema: :default` itself validates
|
|
182
|
+
# natively and does not use this (#102). Parsed with its own path as the
|
|
183
|
+
# base URL so its relative import of exc-c14n.xsd resolves (#61), and
|
|
184
|
+
# only once: a racing first call just parses it twice.
|
|
185
|
+
def self.default_schema
|
|
186
|
+
@default_schema ||= Dom.schema_from_file(DEFAULT_SCHEMA_PATH)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
private
|
|
190
|
+
|
|
191
|
+
# .without_trust_verification's entry point: the flag is set before
|
|
192
|
+
# #initialize, which then accepts no trust source.
|
|
193
|
+
def initialize_without_trust(**options)
|
|
194
|
+
@without_trust = true
|
|
195
|
+
initialize(**options)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# A HankoXml::Error from the native layer already has its fixed message,
|
|
199
|
+
# the class Rust chose, and #xmlsec_errors: keep it. Anything else gets a
|
|
200
|
+
# fixed NativeError, never its own message (#96, S-13).
|
|
201
|
+
def native_failure(error, message)
|
|
202
|
+
return error if error.is_a?(HankoXml::Error)
|
|
203
|
+
|
|
204
|
+
HankoXml::NativeError.new(message)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# xmlSecAddIDs (the native ID table verification resolves "#id" against)
|
|
208
|
+
# matches by unqualified attribute name only -- it has no concept of the
|
|
209
|
+
# namespace-qualified [ns, name] entries Verification's Ruby-side XPath
|
|
210
|
+
# lookup accepts. Passing one of those arrays straight through would fail
|
|
211
|
+
# Native.verify's String-array conversion, so only the local name crosses
|
|
212
|
+
# the FFI boundary; namespace-qualified matching stays a Ruby-side-only
|
|
213
|
+
# refinement over the same, broader native registration.
|
|
214
|
+
def native_id_attributes
|
|
215
|
+
@id_attributes.map { |attr| attr.is_a?(Array) ? attr[1].to_s : attr.to_s }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# The index of the <Signature> to verify among +count+ in document
|
|
219
|
+
# order, or nil when the selection matches none.
|
|
220
|
+
def resolve_signature_target(input, count, signature_option)
|
|
221
|
+
case signature_option
|
|
222
|
+
when nil, :only
|
|
223
|
+
# No explicit selection: fail closed on ambiguity rather than
|
|
224
|
+
# silently verifying only the first of several signatures (S-11).
|
|
225
|
+
# Pass `signature: :first` to opt into the old, unsafe default.
|
|
226
|
+
raise HankoXml::MultipleSignatures, "Expected exactly 1 signature, found #{count}" if count > 1
|
|
227
|
+
|
|
228
|
+
0
|
|
229
|
+
when :first
|
|
230
|
+
0
|
|
231
|
+
when Integer
|
|
232
|
+
index = signature_option.negative? ? count + signature_option : signature_option
|
|
233
|
+
index if index.between?(0, count - 1)
|
|
234
|
+
when String
|
|
235
|
+
xpath_signature_index(input, signature_option)
|
|
236
|
+
when :all
|
|
237
|
+
raise HankoXml::ConfigurationError,
|
|
238
|
+
"signature: :all is not supported; use Verifier#verify_all for one Verification per <Signature> (#105)"
|
|
239
|
+
else
|
|
240
|
+
raise HankoXml::ConfigurationError, "Invalid signature: option"
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# XPath selection needs a DOM. It must pick exactly one <ds:Signature>:
|
|
245
|
+
# taking the first of several matches would be `:first` again under
|
|
246
|
+
# another name (S-11, #95).
|
|
247
|
+
def xpath_signature_index(input, xpath)
|
|
248
|
+
Dom.require!("Verifier signature: XPath selection")
|
|
249
|
+
|
|
250
|
+
sigs = signature_nodes(input)
|
|
251
|
+
namespaces = HankoXml::NS.merge(@namespaces.transform_keys(&:to_s))
|
|
252
|
+
matches = input.document.xpath(xpath, namespaces).to_a & sigs
|
|
253
|
+
if matches.size > 1
|
|
254
|
+
raise HankoXml::MultipleSignatures,
|
|
255
|
+
"signature: XPath must select exactly 1 <Signature>, selected #{matches.size}"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
matches.first && sigs.index(matches.first)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# How many <ds:Signature> the document holds, from the native scan. A
|
|
262
|
+
# caller's DOM over max_document_size is counted on the DOM instead, so
|
|
263
|
+
# nothing native parses it (S-12); verify_single then fails each one on
|
|
264
|
+
# size before any native call.
|
|
265
|
+
def signature_count(input)
|
|
266
|
+
return signature_nodes(input).size if input.dom? && input.bytes.bytesize > @policy.max_document_size
|
|
267
|
+
|
|
268
|
+
input.scan(native_id_attributes)[:signature_count]
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# The caller's <ds:Signature> nodes in document order, or [] without a
|
|
272
|
+
# DOM. The same libxml2 walk as the native count, so indexes agree.
|
|
273
|
+
def signature_nodes(input)
|
|
274
|
+
return [] unless input.document
|
|
275
|
+
|
|
276
|
+
input.document.xpath(".//ds:Signature", ds: NS["ds"]).to_a
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def missing_signature(input, message)
|
|
280
|
+
failed_verification(input, nil, [HankoXml::SignatureMissing.new(message)])
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# The <Signature> errors run against: the XML-DSig schema natively for
|
|
284
|
+
# `schema: :default`, or a caller's own Nokogiri::XML::Schema on the DOM.
|
|
285
|
+
def schema_errors(input, sig_node, index)
|
|
286
|
+
return Native.validate_schema(input.bytes, index, DEFAULT_SCHEMA_PATH) if @schema == :default
|
|
287
|
+
|
|
288
|
+
Dom.require!("Verifier schema: with a custom schema")
|
|
289
|
+
Dom.schema_errors(@schema, sig_node).map(&:message)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# One linear sequence of interdependent checks -- schema, policy,
|
|
293
|
+
# certificate chain, time bounds, then crypto -- accumulating shared
|
|
294
|
+
# state as it goes. Splitting it would thread that state through helpers
|
|
295
|
+
# rather than genuinely simplify it, so the metrics cops are waived here
|
|
296
|
+
# pending a deliberate refactor.
|
|
297
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/AbcSize
|
|
298
|
+
# rubocop:disable Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
299
|
+
def verify_single(input, index)
|
|
300
|
+
sig_node = signature_nodes(input)[index]
|
|
301
|
+
|
|
302
|
+
# 1. max_document_size is checked before any native parse (S-12). An
|
|
303
|
+
# unparsed input was already checked while it was read; this covers a
|
|
304
|
+
# caller's DOM, measured by its serialized bytes.
|
|
305
|
+
xml_bytes = input.bytes
|
|
306
|
+
begin
|
|
307
|
+
@policy.enforce_document_size!(xml_bytes.bytesize)
|
|
308
|
+
rescue HankoXml::PolicyError => e
|
|
309
|
+
return failed_verification(input, sig_node, [e], signature_info: nil)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Schema validation before any crypto
|
|
313
|
+
if @schema
|
|
314
|
+
xsd_errors = schema_errors(input, sig_node, index)
|
|
315
|
+
if xsd_errors.any?
|
|
316
|
+
error = HankoXml::SchemaError.new("<Signature> does not validate against the XML-DSig schema",
|
|
317
|
+
xmlsec_errors: xsd_errors)
|
|
318
|
+
return failed_verification(input, sig_node, [error])
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# 2. Call Native.inspect_sig
|
|
323
|
+
begin
|
|
324
|
+
inspect_result = Native.inspect_sig(xml_bytes, index)
|
|
325
|
+
rescue StandardError => e
|
|
326
|
+
return failed_verification(input, sig_node,
|
|
327
|
+
[native_failure(e, "the native layer could not read <SignedInfo>")])
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
sig_info = HankoXml::SignatureInfo.from_native(inspect_result)
|
|
331
|
+
|
|
332
|
+
# Policy is a structural allowlist over inspect_sig output. Enforce it
|
|
333
|
+
# before Native.verify so a file:// or http:// Reference is never
|
|
334
|
+
# handed to libxml2 I/O (S-04). Native.verify also restricts
|
|
335
|
+
# enabledReferenceUris as defense in depth. document:/sig_node:/
|
|
336
|
+
# id_attributes:/xml_bytes: additionally enable require_signed_root,
|
|
337
|
+
# reject_duplicate_ids, and max_document_size (S-06).
|
|
338
|
+
begin
|
|
339
|
+
@policy.enforce!(sig_info, id_attributes: @id_attributes,
|
|
340
|
+
xml_bytes: xml_bytes, referenced_uris: @referenced_documents.keys)
|
|
341
|
+
rescue HankoXml::PolicyError => e
|
|
342
|
+
return failed_verification(input, sig_node, [e], signature_info: sig_info)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
unresolved = unreferenced_external_uri(sig_info)
|
|
346
|
+
if unresolved
|
|
347
|
+
# The URI is document text, so it goes in #reference_uri, never the
|
|
348
|
+
# message (§5.10).
|
|
349
|
+
error = HankoXml::ReferenceError.new(
|
|
350
|
+
"an external reference URI is not in referenced_documents:, and nothing is ever fetched",
|
|
351
|
+
reference_uri: unresolved
|
|
352
|
+
)
|
|
353
|
+
return failed_verification(input, sig_node, [error], signature_info: sig_info)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# 3. Resolve trust material and call Native.verify
|
|
357
|
+
trusted, untrusted, trust_error, store_chain = collect_trust(sig_info)
|
|
358
|
+
return failed_verification(input, sig_node, [trust_error], signature_info: sig_info) if trust_error
|
|
359
|
+
|
|
360
|
+
# min_rsa_key_bits / min_ec_key_bits (S-06): only the actually
|
|
361
|
+
# resolved trust material can be checked, so this runs after
|
|
362
|
+
# collect_trust rather than as part of the sig_info-only enforce!
|
|
363
|
+
# above.
|
|
364
|
+
begin
|
|
365
|
+
@policy.enforce_key_size!(parsed_trust_keys(trusted))
|
|
366
|
+
rescue HankoXml::PolicyError => e
|
|
367
|
+
return failed_verification(input, sig_node, [e], signature_info: sig_info)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Certificate expiration on the certificate:/certificates:/key: trust
|
|
371
|
+
# paths (S-07). signKey pinning (S-01) means xmlsec never reads
|
|
372
|
+
# KeyInfo to verify the embedded certificate, so it never checks these
|
|
373
|
+
# dates either -- this Ruby-side check is the only one that runs here
|
|
374
|
+
# (the store: path gets its own, equivalent check from
|
|
375
|
+
# OpenSSL::X509::StoreContext above). check_certificate_validity, not
|
|
376
|
+
# verification_time's presence, gates it -- an unset verification_time
|
|
377
|
+
# means "now", not "skip the check" (S-07); Policy.permissive sets
|
|
378
|
+
# check_certificate_validity: false to opt out entirely, matching the
|
|
379
|
+
# store: path's semantics. Key PEMs are skipped: they have no
|
|
380
|
+
# notBefore/notAfter. clock_skew (S-06) widens the window on both
|
|
381
|
+
# ends, the same way NTP-adjacent clock drift is usually tolerated.
|
|
382
|
+
if @policy.check_certificate_validity
|
|
383
|
+
verification_time = @verification_time || Time.now
|
|
384
|
+
skew = @policy.clock_skew
|
|
385
|
+
trusted.each do |pem|
|
|
386
|
+
next unless certificate_pem?(pem)
|
|
387
|
+
|
|
388
|
+
cert = OpenSSL::X509::Certificate.new(pem)
|
|
389
|
+
if verification_time < (cert.not_before - skew) || verification_time > (cert.not_after + skew)
|
|
390
|
+
return failed_verification(
|
|
391
|
+
input,
|
|
392
|
+
sig_node,
|
|
393
|
+
[HankoXml::CertificateExpired.new("Certificate expired at #{verification_time}")],
|
|
394
|
+
signature_info: sig_info
|
|
395
|
+
)
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
begin
|
|
401
|
+
verify_result = Native.verify(xml_bytes, trusted, untrusted, native_id_attributes, index,
|
|
402
|
+
@referenced_documents.to_a, @policy.allows?(:transform, :xpath_filter2))
|
|
403
|
+
rescue StandardError => e
|
|
404
|
+
return failed_verification(input, sig_node,
|
|
405
|
+
[native_failure(e, "the native layer failed unexpectedly while verifying")],
|
|
406
|
+
signature_info: sig_info)
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
valid = (verify_result[:status] == :succeeded)
|
|
410
|
+
errors = []
|
|
411
|
+
|
|
412
|
+
unless valid
|
|
413
|
+
# Fixed message: the xmlsec log can quote document text (S-13), so it
|
|
414
|
+
# is only exposed through #xmlsec_errors.
|
|
415
|
+
errors << HankoXml::VerificationError.new("Signature verification failed (#{verify_result[:status]})",
|
|
416
|
+
xmlsec_errors: verify_result[:errors], signature_node: sig_node)
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
HankoXml::Verification.new(
|
|
420
|
+
valid: valid,
|
|
421
|
+
errors: errors,
|
|
422
|
+
signature_info: sig_info,
|
|
423
|
+
signature_node: sig_node,
|
|
424
|
+
document: input.document,
|
|
425
|
+
namespaces: @namespaces,
|
|
426
|
+
id_attributes: @id_attributes,
|
|
427
|
+
resolver: @resolver,
|
|
428
|
+
reference_element_indexes: verify_result[:reference_element_indexes],
|
|
429
|
+
reference_valid: verify_result[:reference_valid],
|
|
430
|
+
signature_element_index: input.scan(native_id_attributes)[:signature_element_indexes][index],
|
|
431
|
+
certificate: valid ? verifying_certificate(trusted, verify_result[:trusted_index], store_chain&.first) : nil,
|
|
432
|
+
certificate_chain: valid ? store_chain : nil,
|
|
433
|
+
trusted: !without_trust?,
|
|
434
|
+
root_signature: input.scan(native_id_attributes)[:signature_element_indexes].include?(0)
|
|
435
|
+
)
|
|
436
|
+
end
|
|
437
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/AbcSize
|
|
438
|
+
# rubocop:enable Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
439
|
+
|
|
440
|
+
# A Hash of URI => bytes, frozen. Keys drop any #fragment, the way xmlsec
|
|
441
|
+
# opens the URI.
|
|
442
|
+
def coerce_referenced_documents(documents)
|
|
443
|
+
unless documents.is_a?(Hash)
|
|
444
|
+
raise HankoXml::ConfigurationError, "referenced_documents: must be a Hash of URI => String or Document"
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
documents.to_h do |uri, document|
|
|
448
|
+
bytes = if document.is_a?(String) then document.b
|
|
449
|
+
elsif Dom.document?(document) then HankoXml.serialize(document).b
|
|
450
|
+
else
|
|
451
|
+
raise HankoXml::ConfigurationError,
|
|
452
|
+
"referenced_documents: #{uri.to_s.inspect} must be a String of bytes or a " \
|
|
453
|
+
"Nokogiri::XML::Document, got #{document.class}"
|
|
454
|
+
end
|
|
455
|
+
[uri.to_s.split("#", 2).first.to_s.freeze, bytes.freeze]
|
|
456
|
+
end.freeze
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def unreferenced_external_uri(sig_info)
|
|
460
|
+
sig_info.references.find { |ref| ref.external? && !@referenced_documents.key?(ref.document_uri) }&.uri
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def failed_verification(input, sig_node, errors, signature_info: nil)
|
|
464
|
+
HankoXml::Verification.new(
|
|
465
|
+
trusted: !without_trust?,
|
|
466
|
+
valid: false,
|
|
467
|
+
errors: errors,
|
|
468
|
+
signature_info: signature_info,
|
|
469
|
+
signature_node: sig_node,
|
|
470
|
+
document: input.document,
|
|
471
|
+
namespaces: @namespaces,
|
|
472
|
+
id_attributes: @id_attributes,
|
|
473
|
+
resolver: @resolver
|
|
474
|
+
)
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def collect_trust(sig_info)
|
|
478
|
+
return embedded_trust(sig_info) if without_trust?
|
|
479
|
+
return keys_trust(sig_info) if @keys
|
|
480
|
+
return store_trust(sig_info) if @store
|
|
481
|
+
|
|
482
|
+
trusted = []
|
|
483
|
+
trusted << @key.to_pem if @key
|
|
484
|
+
trusted << @certificate.to_pem if @certificate
|
|
485
|
+
trusted.concat(@certificates.map(&:to_pem)) if @certificates
|
|
486
|
+
[trusted, [], nil]
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
def keys_trust(sig_info)
|
|
490
|
+
key_name = sig_info.key_names.first
|
|
491
|
+
unless key_name && @keys.key?(key_name)
|
|
492
|
+
return [nil, nil,
|
|
493
|
+
HankoXml::UntrustedCertificate.new("the document's <KeyName> is missing or not in the keys: map",
|
|
494
|
+
xmlsec_errors: ["KeyName: #{key_name.inspect}"])]
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
[[HankoXml::Key.coerce(@keys[key_name]).to_pem], [], nil]
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def store_trust(sig_info)
|
|
501
|
+
begin
|
|
502
|
+
embedded = load_embedded_certificates(sig_info)
|
|
503
|
+
rescue OpenSSL::X509::CertificateError => e
|
|
504
|
+
return [nil, nil,
|
|
505
|
+
HankoXml::UntrustedCertificate.new("Invalid X509Certificate in KeyInfo", xmlsec_errors: [e.message])]
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
if embedded.empty?
|
|
509
|
+
return [nil, nil,
|
|
510
|
+
HankoXml::UntrustedCertificate.new("No X509Certificate found in KeyInfo for store verification")]
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
chain, error = verified_chain_from_store(embedded)
|
|
514
|
+
leaf = chain&.first
|
|
515
|
+
error ||= signing_certificate_violation(leaf)
|
|
516
|
+
return [nil, nil, error] if error
|
|
517
|
+
|
|
518
|
+
# xmlsec treats trusted cert PEMs as CA anchors and still X509-verifies
|
|
519
|
+
# every KeyInfo cert. A CA:FALSE leaf therefore cannot be "pinned" that
|
|
520
|
+
# way. After Ruby has enforced chain policy, pin the leaf public key as
|
|
521
|
+
# signKey (same path as `key:`) so KeyInfo is ignored. The chain OpenSSL
|
|
522
|
+
# validated, leaf first, is what trust resolved to, for
|
|
523
|
+
# Verification#certificate and #certificate_chain (#104).
|
|
524
|
+
[[leaf.public_key.to_pem], [], nil, chain]
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
# .without_trust_verification: pin the public key of the single
|
|
528
|
+
# end-entity certificate <KeyInfo> carries, as store: pins its validated
|
|
529
|
+
# leaf, so xmlsec still never picks the key itself (S-01). Nothing about
|
|
530
|
+
# it is trusted.
|
|
531
|
+
def embedded_trust(sig_info)
|
|
532
|
+
begin
|
|
533
|
+
embedded = load_embedded_certificates(sig_info)
|
|
534
|
+
rescue OpenSSL::X509::CertificateError => e
|
|
535
|
+
return [nil, nil,
|
|
536
|
+
HankoXml::UntrustedCertificate.new("Invalid X509Certificate in KeyInfo", xmlsec_errors: [e.message])]
|
|
537
|
+
end
|
|
538
|
+
leaves = end_entity_certificates(embedded)
|
|
539
|
+
unless leaves.size == 1
|
|
540
|
+
return [nil, nil, HankoXml::UntrustedCertificate.new(
|
|
541
|
+
"without_trust_verification needs exactly one end-entity X509Certificate in KeyInfo"
|
|
542
|
+
)]
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
[[leaves.first.public_key.to_pem], [], nil, [leaves.first]]
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
def load_embedded_certificates(sig_info)
|
|
549
|
+
sig_info.certificates.map { |pem| OpenSSL::X509::Certificate.new(pem) }
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
# The chain OpenSSL built and verified for the one end-entity certificate
|
|
553
|
+
# that chains to the store, leaf first, or an error.
|
|
554
|
+
def verified_chain_from_store(certificates)
|
|
555
|
+
candidates = end_entity_certificates(certificates)
|
|
556
|
+
candidates = certificates if candidates.empty?
|
|
557
|
+
|
|
558
|
+
verified = []
|
|
559
|
+
last_error = "Certificate chain validation failed"
|
|
560
|
+
|
|
561
|
+
candidates.each do |leaf|
|
|
562
|
+
chain = certificates.reject { |cert| cert.equal?(leaf) }
|
|
563
|
+
ctx = OpenSSL::X509::StoreContext.new(@store, leaf, chain)
|
|
564
|
+
if @policy.check_certificate_validity
|
|
565
|
+
ctx.time = @verification_time if @verification_time
|
|
566
|
+
else
|
|
567
|
+
ctx.flags = OpenSSL::X509::V_FLAG_NO_CHECK_TIME
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
if ctx.verify
|
|
571
|
+
verified << ctx.chain
|
|
572
|
+
else
|
|
573
|
+
message = ctx.error_string.to_s
|
|
574
|
+
last_error = message.empty? ? last_error : "Certificate chain validation failed: #{message}"
|
|
575
|
+
end
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
case verified.size
|
|
579
|
+
when 1
|
|
580
|
+
[verified.first, nil]
|
|
581
|
+
when 0
|
|
582
|
+
[nil, HankoXml::UntrustedCertificate.new(last_error)]
|
|
583
|
+
else
|
|
584
|
+
[nil, HankoXml::UntrustedCertificate.new(
|
|
585
|
+
"Ambiguous KeyInfo: multiple certificates chain to the trust store"
|
|
586
|
+
)]
|
|
587
|
+
end
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
# End-entity certs in this KeyInfo set: nobody else in the set was issued
|
|
591
|
+
# by them. Document order is not a leaf indicator (XML-DSig does not
|
|
592
|
+
# require leaf-first X509Certificate).
|
|
593
|
+
# KeyUsage bits (RFC 5280 §4.2.1.3), numbered from the most significant
|
|
594
|
+
# bit of the BIT STRING.
|
|
595
|
+
DIGITAL_SIGNATURE_BIT = 0
|
|
596
|
+
NON_REPUDIATION_BIT = 1
|
|
597
|
+
private_constant :DIGITAL_SIGNATURE_BIT, :NON_REPUDIATION_BIT
|
|
598
|
+
|
|
599
|
+
def coerce_certificate_subject(subject)
|
|
600
|
+
return nil if subject.nil?
|
|
601
|
+
unless @store
|
|
602
|
+
raise HankoXml::ConfigurationError,
|
|
603
|
+
"certificate_subject: only applies to store: (the other trust sources already pin the key)"
|
|
604
|
+
end
|
|
605
|
+
return subject.dup.freeze if subject.is_a?(String)
|
|
606
|
+
return subject if subject.is_a?(OpenSSL::X509::Name)
|
|
607
|
+
|
|
608
|
+
raise HankoXml::ConfigurationError,
|
|
609
|
+
"certificate_subject: must be a String (the CN) or an OpenSSL::X509::Name, got #{subject.class}"
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
# Certificate-usage policy on the leaf store: already validated (#63):
|
|
613
|
+
# KeyUsage and the optional certificate_subject:.
|
|
614
|
+
def signing_certificate_violation(leaf)
|
|
615
|
+
if @policy.require_signing_key_usage && !signing_key_usage?(leaf)
|
|
616
|
+
return HankoXml::UntrustedCertificate.new(
|
|
617
|
+
"signing certificate's KeyUsage allows neither digitalSignature nor nonRepudiation " \
|
|
618
|
+
"(require_signing_key_usage)"
|
|
619
|
+
)
|
|
620
|
+
end
|
|
621
|
+
return nil if @certificate_subject.nil? || certificate_subject_matches?(leaf)
|
|
622
|
+
|
|
623
|
+
# The subject comes from the document's certificate: detail, not message.
|
|
624
|
+
HankoXml::UntrustedCertificate.new(
|
|
625
|
+
"signing certificate subject does not match certificate_subject:",
|
|
626
|
+
xmlsec_errors: ["subject: #{leaf.subject}", "certificate_subject: #{@certificate_subject}"]
|
|
627
|
+
)
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
# No KeyUsage extension means no restriction (RFC 5280). When present,
|
|
631
|
+
# either bit is enough: qualified signing certificates (eIDAS/XAdES) often
|
|
632
|
+
# carry nonRepudiation alone.
|
|
633
|
+
def signing_key_usage?(cert)
|
|
634
|
+
extension = cert.extensions.find { |ext| ext.oid == "keyUsage" }
|
|
635
|
+
return true if extension.nil?
|
|
636
|
+
|
|
637
|
+
bits = OpenSSL::ASN1.decode(extension.value_der).value.to_s.unpack1("B*").to_s
|
|
638
|
+
[DIGITAL_SIGNATURE_BIT, NON_REPUDIATION_BIT].any? { |bit| bits[bit] == "1" }
|
|
639
|
+
rescue OpenSSL::ASN1::ASN1Error
|
|
640
|
+
false
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
# A String must be the certificate's only CN; an X509::Name must equal
|
|
644
|
+
# the whole subject.
|
|
645
|
+
def certificate_subject_matches?(cert)
|
|
646
|
+
return cert.subject.cmp(@certificate_subject).zero? if @certificate_subject.is_a?(OpenSSL::X509::Name)
|
|
647
|
+
|
|
648
|
+
common_names = cert.subject.to_a.filter_map { |name, value, _type| value if name == "CN" }
|
|
649
|
+
common_names == [@certificate_subject]
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
def end_entity_certificates(certificates)
|
|
653
|
+
certificates.select do |candidate|
|
|
654
|
+
certificates.none? { |other| issued_by?(other, candidate) }
|
|
655
|
+
end
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
def issued_by?(cert, issuer)
|
|
659
|
+
!cert.equal?(issuer) && cert.issuer.to_der == issuer.subject.to_der
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
# API_CONTRACT.md §5.4 `v.certificate`: the certificate whose key verified
|
|
663
|
+
# the signature. Native reports which trusted PEM that was; under store:
|
|
664
|
+
# it is the chain leaf Ruby validated. nil for a bare key (key:, or a
|
|
665
|
+
# public key in keys:).
|
|
666
|
+
def verifying_certificate(trusted, trusted_index, store_leaf)
|
|
667
|
+
return store_leaf if store_leaf
|
|
668
|
+
return nil if trusted_index.nil?
|
|
669
|
+
|
|
670
|
+
pem = trusted[trusted_index]
|
|
671
|
+
pem && certificate_pem?(pem) ? OpenSSL::X509::Certificate.new(pem) : nil
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
def certificate_pem?(pem)
|
|
675
|
+
pem.include?("BEGIN CERTIFICATE")
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
# Parses collect_trust's PEM Strings into OpenSSL objects for
|
|
679
|
+
# Policy#enforce_key_size! (S-06). A PEM that fails to parse here would
|
|
680
|
+
# already have failed identically inside Native.verify; skip it rather
|
|
681
|
+
# than raise a second, redundant error.
|
|
682
|
+
def parsed_trust_keys(trusted)
|
|
683
|
+
trusted.filter_map do |pem|
|
|
684
|
+
if certificate_pem?(pem)
|
|
685
|
+
OpenSSL::X509::Certificate.new(pem)
|
|
686
|
+
else
|
|
687
|
+
HankoXml::Key.read_pkey(pem, nil)
|
|
688
|
+
end
|
|
689
|
+
rescue OpenSSL::OpenSSLError
|
|
690
|
+
nil
|
|
691
|
+
end
|
|
692
|
+
end
|
|
693
|
+
end
|
|
694
|
+
end
|