hanko_xml 0.1.0-x86_64-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,502 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module HankoXml
|
|
6
|
+
# A frozen allowlist, enforced in Ruby before anything reaches
|
|
7
|
+
# libxmlsec1, on both signing and verification. See API_CONTRACT.md §5.8.
|
|
8
|
+
class Policy # rubocop:disable Metrics/ClassLength
|
|
9
|
+
FIELDS = %i[
|
|
10
|
+
signature_methods digest_methods canonicalization_methods transforms
|
|
11
|
+
allow_external_references allow_retrieval_method allow_same_document_xpointer
|
|
12
|
+
max_references require_references
|
|
13
|
+
require_signed_root reject_duplicate_ids max_document_size max_transforms_per_reference
|
|
14
|
+
min_rsa_key_bits min_ec_key_bits min_dsa_key_bits allow_hmac check_certificate_validity clock_skew
|
|
15
|
+
require_signing_key_usage schema
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
STRICT_DEFAULTS = {
|
|
19
|
+
signature_methods: %i[rsa_sha256 rsa_sha384 rsa_sha512 ecdsa_sha256 ecdsa_sha384 ecdsa_sha512],
|
|
20
|
+
digest_methods: %i[sha256 sha384 sha512],
|
|
21
|
+
canonicalization_methods: %i[c14n exc_c14n c14n11 c14n_with_comments exc_c14n_with_comments],
|
|
22
|
+
transforms: %i[enveloped_signature base64 c14n exc_c14n c14n11 c14n_with_comments
|
|
23
|
+
exc_c14n_with_comments],
|
|
24
|
+
|
|
25
|
+
allow_external_references: false,
|
|
26
|
+
allow_retrieval_method: false,
|
|
27
|
+
allow_same_document_xpointer: false,
|
|
28
|
+
max_references: 8,
|
|
29
|
+
require_references: true,
|
|
30
|
+
|
|
31
|
+
require_signed_root: true,
|
|
32
|
+
reject_duplicate_ids: true,
|
|
33
|
+
max_document_size: 16 * 1024 * 1024,
|
|
34
|
+
max_transforms_per_reference: 4,
|
|
35
|
+
|
|
36
|
+
min_rsa_key_bits: 2048,
|
|
37
|
+
min_ec_key_bits: 256,
|
|
38
|
+
min_dsa_key_bits: 2048,
|
|
39
|
+
allow_hmac: false,
|
|
40
|
+
check_certificate_validity: true,
|
|
41
|
+
clock_skew: 60,
|
|
42
|
+
# store: only -- the signing certificate's KeyUsage, when present, must
|
|
43
|
+
# allow digitalSignature or nonRepudiation (#63).
|
|
44
|
+
require_signing_key_usage: true,
|
|
45
|
+
|
|
46
|
+
schema: :default
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
attr_reader(*FIELDS)
|
|
50
|
+
|
|
51
|
+
def initialize(**overrides)
|
|
52
|
+
unknown = overrides.keys - FIELDS
|
|
53
|
+
raise ArgumentError, "unknown Policy field(s): #{unknown.inspect}" unless unknown.empty?
|
|
54
|
+
|
|
55
|
+
values = STRICT_DEFAULTS.merge(overrides)
|
|
56
|
+
FIELDS.each { |f| instance_variable_set(:"@#{f}", values[f]) }
|
|
57
|
+
@allowed_uris = snapshot_allowed_uris
|
|
58
|
+
freeze
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def with(**overrides)
|
|
62
|
+
self.class.new(**to_h.merge(overrides))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def to_h
|
|
66
|
+
FIELDS.each_with_object({}) { |f, h| h[f] = public_send(f) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
class << self
|
|
70
|
+
def strict
|
|
71
|
+
@strict ||= new
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# strict, minus require_signed_root -- for protocols that sign a
|
|
75
|
+
# fragment (SAML assertions).
|
|
76
|
+
def fragment
|
|
77
|
+
@fragment ||= strict.with(require_signed_root: false)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# strict + SHA-1 digest and RSA-SHA1 signature.
|
|
81
|
+
def legacy_sha1
|
|
82
|
+
@legacy_sha1 ||= strict.with(
|
|
83
|
+
digest_methods: strict.digest_methods + [:sha1],
|
|
84
|
+
signature_methods: strict.signature_methods + [:rsa_sha1]
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# strict + RSA-PSS (RFC 6931 MGF1) and SHA-3 digest / ECDSA-SHA3
|
|
89
|
+
# signature methods, the algorithms EU XAdES profiles often want (#60).
|
|
90
|
+
# Kept out of Policy.strict the same way SHA-1 is. xmlsec 1.3 (vendored
|
|
91
|
+
# / platform gems) implements them; xmlsec 1.2 (Ubuntu CI) does not —
|
|
92
|
+
# Native.sign/verify then fails closed.
|
|
93
|
+
def xades
|
|
94
|
+
@xades ||= strict.with(
|
|
95
|
+
# EU DSS's enveloped form, in the one shape Policy accepts (#85).
|
|
96
|
+
transforms: strict.transforms + [:xpath_filter2],
|
|
97
|
+
digest_methods: strict.digest_methods + %i[sha3_256 sha3_384 sha3_512],
|
|
98
|
+
signature_methods: strict.signature_methods + %i[
|
|
99
|
+
rsa_pss_sha256 rsa_pss_sha384 rsa_pss_sha512
|
|
100
|
+
ecdsa_sha3_256 ecdsa_sha3_384 ecdsa_sha3_512
|
|
101
|
+
]
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# fragment + what the W3C interop signatures (merlin-xmldsig-twenty-three,
|
|
106
|
+
# phaos-xmldsig-three) need: SHA-1 with RSA or DSA, and 1024-bit RSA and
|
|
107
|
+
# DSA keys.
|
|
108
|
+
# For verifying legacy signatures from other implementations (#62), never
|
|
109
|
+
# as a default. External references, RetrievalMethod, HMAC and
|
|
110
|
+
# XPath/XSLT stay off, as in strict.
|
|
111
|
+
def interop
|
|
112
|
+
@interop ||= fragment.with(
|
|
113
|
+
transforms: fragment.transforms + [:xpath_filter2],
|
|
114
|
+
digest_methods: fragment.digest_methods + [:sha1],
|
|
115
|
+
signature_methods: fragment.signature_methods + %i[rsa_sha1 dsa_sha1],
|
|
116
|
+
min_rsa_key_bits: 1024,
|
|
117
|
+
min_dsa_key_bits: 1024
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Allows everything the Ruby checks cover. Documented as unsafe. The
|
|
122
|
+
# native layer still refuses XPath and XSLT transforms (S-10) and reads
|
|
123
|
+
# an external URI only from referenced_documents: (#74), whatever this
|
|
124
|
+
# says. XPath Filter 2.0 still passes only in the subtract-the-signatures
|
|
125
|
+
# shape (#85).
|
|
126
|
+
def permissive
|
|
127
|
+
@permissive ||= new(
|
|
128
|
+
signature_methods: Algorithms.signature.names,
|
|
129
|
+
digest_methods: Algorithms.digest.names,
|
|
130
|
+
canonicalization_methods: Algorithms.canonicalization.names,
|
|
131
|
+
transforms: Algorithms.transform.names,
|
|
132
|
+
allow_external_references: true,
|
|
133
|
+
allow_retrieval_method: true,
|
|
134
|
+
allow_same_document_xpointer: true,
|
|
135
|
+
max_references: Float::INFINITY,
|
|
136
|
+
require_references: false,
|
|
137
|
+
require_signed_root: false,
|
|
138
|
+
reject_duplicate_ids: false,
|
|
139
|
+
max_document_size: Float::INFINITY,
|
|
140
|
+
max_transforms_per_reference: Float::INFINITY,
|
|
141
|
+
min_rsa_key_bits: 0,
|
|
142
|
+
min_ec_key_bits: 0,
|
|
143
|
+
min_dsa_key_bits: 0,
|
|
144
|
+
allow_hmac: true,
|
|
145
|
+
check_certificate_validity: false,
|
|
146
|
+
clock_skew: 0,
|
|
147
|
+
require_signing_key_usage: false,
|
|
148
|
+
schema: nil
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Validates +signature_info+ (a SignatureInfo) against this policy.
|
|
154
|
+
# Returns an Array<HankoXml::PolicyError>, empty when compliant. Never
|
|
155
|
+
# raises for a policy violation -- that is #enforce!'s job.
|
|
156
|
+
#
|
|
157
|
+
# +signature_info+ alone is enough for every algorithm/structural check
|
|
158
|
+
# (works without a DOM, per API_CONTRACT.md §6.1). The keyword
|
|
159
|
+
# arguments are optional context that only Signer/Verifier -- which
|
|
160
|
+
# always have a DOM and trust material -- can supply, for the handful
|
|
161
|
+
# of fields that genuinely need it (S-06):
|
|
162
|
+
#
|
|
163
|
+
# @param document [Nokogiri::XML::Document, nil] the document, when the
|
|
164
|
+
# caller has only a DOM: it is serialized for the checks below.
|
|
165
|
+
# @param sig_node [Nokogiri::XML::Node, nil] internal (@api private):
|
|
166
|
+
# accepted for compatibility; no check needs it any more (#102).
|
|
167
|
+
# @param id_attributes [Array<String>, nil] the caller's `id_attributes:`,
|
|
168
|
+
# needed by +reject_duplicate_ids+ and +require_signed_root+.
|
|
169
|
+
# @param xml_bytes [String, nil] the document's bytes, for
|
|
170
|
+
# +max_document_size+, +require_signed_root+ and +reject_duplicate_ids+.
|
|
171
|
+
# Both structural checks read the native layer's parse of these bytes
|
|
172
|
+
# (Native.scan), so they run identically with or without Nokogiri
|
|
173
|
+
# (#102). Without +xml_bytes+ or +document+ an enabled check reports a
|
|
174
|
+
# violation rather than being skipped (#96).
|
|
175
|
+
# @param check_root_signed [Boolean] internal (@api private), for
|
|
176
|
+
# {Signer}: whether to apply
|
|
177
|
+
# +require_signed_root+ here. `require_signed_root` (API_CONTRACT.md
|
|
178
|
+
# §5.8.1) is a *verification*-time XSW defence -- it says nothing
|
|
179
|
+
# about what is legitimate to produce, so {Signer} deliberately signs
|
|
180
|
+
# a caller-chosen fragment (opted into via `references:`, R5) without
|
|
181
|
+
# tripping it; only {Verifier} checks it.
|
|
182
|
+
# @param referenced_uris [Array<String>] internal (@api private), for
|
|
183
|
+
# {Verifier}: URIs the caller serves through
|
|
184
|
+
# `referenced_documents:` (#74). An external reference to one of them
|
|
185
|
+
# is allowed whatever +allow_external_references+ says: it is read from
|
|
186
|
+
# the caller's map, never fetched.
|
|
187
|
+
#
|
|
188
|
+
# +min_rsa_key_bits+ / +min_ec_key_bits+ are not checked here -- they
|
|
189
|
+
# need the actual signing/trust key material, which is only available
|
|
190
|
+
# once Signer/Verifier have resolved it. See #enforce_key_size!.
|
|
191
|
+
# sig_node: stays in the signature for callers that pass it (#102).
|
|
192
|
+
def violations(signature_info, document: nil, sig_node: nil, id_attributes: nil, xml_bytes: nil, # rubocop:disable Lint/UnusedMethodArgument
|
|
193
|
+
check_root_signed: true, referenced_uris: [])
|
|
194
|
+
errors = []
|
|
195
|
+
|
|
196
|
+
errors << algorithm_violation(:signature, signature_info.signature_method)
|
|
197
|
+
errors << algorithm_violation(:canonicalization, signature_info.canonicalization_method)
|
|
198
|
+
errors << hmac_violation(signature_info)
|
|
199
|
+
errors << retrieval_method_violation(signature_info)
|
|
200
|
+
|
|
201
|
+
if require_references && signature_info.references.empty?
|
|
202
|
+
errors << HankoXml::PolicyError.new(
|
|
203
|
+
"this policy requires at least one <Reference>, but <SignedInfo> has none " \
|
|
204
|
+
"(require_references)"
|
|
205
|
+
)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
if signature_info.references.size > max_references
|
|
209
|
+
errors << HankoXml::PolicyError.new(
|
|
210
|
+
"<SignedInfo> has #{signature_info.references.size} references, more than this " \
|
|
211
|
+
"policy's max_references (#{max_references})"
|
|
212
|
+
)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
errors.concat(reference_violations(signature_info.references, referenced_uris))
|
|
216
|
+
|
|
217
|
+
# The structural checks parse the document natively, so an oversized
|
|
218
|
+
# one is reported and never parsed (S-12). A DOM is measured by its
|
|
219
|
+
# serialized bytes, as Signer and Verifier measure it.
|
|
220
|
+
xml_bytes ||= HankoXml.serialize(document) if document
|
|
221
|
+
size_error = document_size_violation(xml_bytes&.bytesize)
|
|
222
|
+
if size_error
|
|
223
|
+
errors << size_error
|
|
224
|
+
else
|
|
225
|
+
structure = DocumentStructure.new(xml_bytes, id_attributes)
|
|
226
|
+
errors << root_signed_violation(structure, signature_info) if check_root_signed
|
|
227
|
+
errors << duplicate_id_violation(structure)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
errors.compact
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Raises HankoXml::PolicyError (with all violations joined into the
|
|
234
|
+
# message) if +signature_info+ violates this policy. Accepts the same
|
|
235
|
+
# optional context keywords as #violations.
|
|
236
|
+
def enforce!(signature_info, document: nil, sig_node: nil, id_attributes: nil, xml_bytes: nil,
|
|
237
|
+
check_root_signed: true, referenced_uris: [])
|
|
238
|
+
errors = violations(signature_info, document: document, sig_node: sig_node, id_attributes: id_attributes,
|
|
239
|
+
xml_bytes: xml_bytes, check_root_signed: check_root_signed,
|
|
240
|
+
referenced_uris: referenced_uris)
|
|
241
|
+
return if errors.empty?
|
|
242
|
+
|
|
243
|
+
raise HankoXml::PolicyError, errors.map(&:message).join("; ")
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Raises HankoXml::PolicyError if +bytesize+ exceeds
|
|
247
|
+
# +max_document_size+. Separate from #enforce! so Signer/Verifier can
|
|
248
|
+
# apply it before parsing and before any native call -- #enforce! needs
|
|
249
|
+
# Native.inspect_sig output, which already means a native parse (S-12).
|
|
250
|
+
# Raises HankoXml::PolicyError unless +uri_or_name+ is in this policy's
|
|
251
|
+
# allowlist for +slot+ (:signature, :digest, :canonicalization,
|
|
252
|
+
# :transform). For algorithms named outside <SignedInfo>, such as a XAdES
|
|
253
|
+
# certificate digest.
|
|
254
|
+
# Whether +uri_or_name+ is in this policy's allowlist for +slot+.
|
|
255
|
+
def allows?(slot, uri_or_name)
|
|
256
|
+
algorithm_violation(slot, uri_or_name).nil?
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def enforce_algorithm!(slot, uri_or_name)
|
|
260
|
+
error = algorithm_violation(slot, uri_or_name)
|
|
261
|
+
raise error if error
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def enforce_document_size!(bytesize)
|
|
265
|
+
error = document_size_violation(bytesize)
|
|
266
|
+
raise error if error
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Raises HankoXml::PolicyError if any of +keys+
|
|
270
|
+
# (OpenSSL::PKey::PKey / OpenSSL::X509::Certificate) is smaller than
|
|
271
|
+
# this policy's min_rsa_key_bits / min_ec_key_bits / min_dsa_key_bits (S-06). Separate from
|
|
272
|
+
# #enforce! because the real signing/trust key is only known once
|
|
273
|
+
# Signer/Verifier have resolved it (Signer's own key; Verifier's
|
|
274
|
+
# resolved trust material) -- after SignatureInfo has already been
|
|
275
|
+
# policy-checked.
|
|
276
|
+
def enforce_key_size!(keys)
|
|
277
|
+
errors = Array(keys).filter_map { |key| key_size_violation(key) }
|
|
278
|
+
return if errors.empty?
|
|
279
|
+
|
|
280
|
+
raise HankoXml::PolicyError, errors.map(&:message).join("; ")
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
private
|
|
284
|
+
|
|
285
|
+
def reference_violations(references, referenced_uris)
|
|
286
|
+
references.flat_map do |ref|
|
|
287
|
+
errors = [
|
|
288
|
+
algorithm_violation(:digest, ref.digest_method, reference_uri: ref.uri)
|
|
289
|
+
]
|
|
290
|
+
|
|
291
|
+
if ref.transforms.size > max_transforms_per_reference
|
|
292
|
+
errors << HankoXml::PolicyError.new(
|
|
293
|
+
"reference #{ref.uri.inspect} has #{ref.transforms.size} transforms, more than this " \
|
|
294
|
+
"policy's max_transforms_per_reference (#{max_transforms_per_reference})"
|
|
295
|
+
)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
ref.transforms.each do |transform_uri|
|
|
299
|
+
errors << algorithm_violation(:transform, transform_uri, reference_uri: ref.uri)
|
|
300
|
+
end
|
|
301
|
+
errors << xpath_filter2_violation(ref)
|
|
302
|
+
|
|
303
|
+
if !allow_external_references && ref.external? && !referenced_uris.include?(ref.document_uri)
|
|
304
|
+
errors << HankoXml::PolicyError.new(
|
|
305
|
+
"reference URI #{ref.uri.inspect} is external, and allow_external_references is false"
|
|
306
|
+
)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
if !allow_same_document_xpointer && ref.same_document_xpointer?
|
|
310
|
+
errors << HankoXml::PolicyError.new(
|
|
311
|
+
"reference URI #{ref.uri.inspect} is a same-document XPointer, and " \
|
|
312
|
+
"allow_same_document_xpointer is false"
|
|
313
|
+
)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
errors
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# An allowed XPath Filter 2.0 transform must be the standard enveloped
|
|
321
|
+
# form, "the whole document minus the signatures" (#85): arbitrary
|
|
322
|
+
# filters can select a node set #signed? does not model (signature
|
|
323
|
+
# wrapping), and are a denial-of-service vector (S-10). xmlsec evaluates
|
|
324
|
+
# the accepted shape; nothing here evaluates XPath.
|
|
325
|
+
def xpath_filter2_violation(ref)
|
|
326
|
+
return nil unless ref.xpath_filter2? && allows?(:transform, :xpath_filter2)
|
|
327
|
+
return nil if ref.signature_exclusion
|
|
328
|
+
|
|
329
|
+
expressions = ref.xpath_filters.map { |f| f.transform_keys(&:to_sym).values_at(:filter, :expression).join(" ") }
|
|
330
|
+
found = expressions.empty? ? "no filter <XPath>" : expressions.join("; ").inspect
|
|
331
|
+
HankoXml::PolicyError.new(
|
|
332
|
+
"reference #{ref.uri.inspect} has an XPath Filter 2.0 transform other than a single subtract of " \
|
|
333
|
+
"/descendant::ds:Signature or /*/ds:Signature on the whole document (#{found})"
|
|
334
|
+
)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def hmac_violation(signature_info)
|
|
338
|
+
return nil if allow_hmac
|
|
339
|
+
|
|
340
|
+
name = Algorithms.name_for(:signature, signature_info.signature_method)
|
|
341
|
+
return nil unless name.to_s.start_with?("hmac")
|
|
342
|
+
|
|
343
|
+
HankoXml::PolicyError.new(
|
|
344
|
+
"signature method #{signature_info.signature_method} (#{name.inspect}) is HMAC-based, " \
|
|
345
|
+
"and allow_hmac is false"
|
|
346
|
+
)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def retrieval_method_violation(signature_info)
|
|
350
|
+
return nil if allow_retrieval_method
|
|
351
|
+
return nil unless signature_info.retrieval_method?
|
|
352
|
+
|
|
353
|
+
HankoXml::PolicyError.new(
|
|
354
|
+
"<KeyInfo> contains a <RetrievalMethod>, and allow_retrieval_method is false"
|
|
355
|
+
)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# The document facts +require_signed_root+ and +reject_duplicate_ids+
|
|
359
|
+
# need, from the native layer's parse of the same bytes xmlsec verifies
|
|
360
|
+
# (Native.scan). Scanned at most once, and only when a check asks.
|
|
361
|
+
class DocumentStructure
|
|
362
|
+
def initialize(xml_bytes, id_attributes)
|
|
363
|
+
@xml_bytes = xml_bytes
|
|
364
|
+
@id_attributes = id_attributes
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def available?
|
|
368
|
+
!@xml_bytes.nil?
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def id_attributes?
|
|
372
|
+
!@id_attributes.nil?
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def id_names
|
|
376
|
+
(@id_attributes || []).map { |attr| attr.is_a?(Array) ? attr[1].to_s : attr.to_s }
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def scan
|
|
380
|
+
@scan ||= Native.scan(@xml_bytes.b, id_names, false)
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
private_constant :DocumentStructure
|
|
384
|
+
|
|
385
|
+
def missing_context_violation(field)
|
|
386
|
+
HankoXml::PolicyError.new(
|
|
387
|
+
"#{field} needs the document to check; pass xml_bytes: (or document:) and id_attributes:, " \
|
|
388
|
+
"or use a policy with #{field}: false"
|
|
389
|
+
)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Structural check: does any Reference cover the document root? An empty
|
|
393
|
+
# URI or `#xpointer(/)` is the whole document (#85); a bare `#id` covers the root when xmlsec's
|
|
394
|
+
# ID table resolves it to the root element (Native.scan's root_ids).
|
|
395
|
+
# Runs before any cryptography, and does not wait for a real
|
|
396
|
+
# Verification#covers_root? (S-06).
|
|
397
|
+
def root_signed_violation(structure, signature_info)
|
|
398
|
+
return nil unless require_signed_root
|
|
399
|
+
# Without the document this cannot be checked, and an unchecked
|
|
400
|
+
# structural rule must fail closed, not pass (#96).
|
|
401
|
+
return missing_context_violation(:require_signed_root) unless structure.available?
|
|
402
|
+
|
|
403
|
+
return nil if signature_info.references.any? { |ref| SignatureInfo::WHOLE_DOCUMENT_URIS.include?(ref.uri.to_s) }
|
|
404
|
+
|
|
405
|
+
root_ids = structure.id_attributes? ? structure.scan[:root_ids] : []
|
|
406
|
+
return nil if signature_info.references.any? { |ref| ref.fragment? && root_ids.include?(ref.uri[1..]) }
|
|
407
|
+
|
|
408
|
+
HankoXml::PolicyError.new(
|
|
409
|
+
"this policy requires the root element to be signed (require_signed_root), but no " \
|
|
410
|
+
"<Reference> covers it"
|
|
411
|
+
)
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# Whole-document duplicate-ID scan (S-06), as coarse as xmlSecAddIDs
|
|
415
|
+
# (S-03): any attribute whose local name is an ID attribute counts,
|
|
416
|
+
# whatever its namespace, because xmlsec registers all of them.
|
|
417
|
+
def duplicate_id_violation(structure)
|
|
418
|
+
return nil unless reject_duplicate_ids
|
|
419
|
+
return missing_context_violation(:reject_duplicate_ids) unless structure.available? && structure.id_attributes?
|
|
420
|
+
return nil if structure.id_names.empty?
|
|
421
|
+
return nil unless structure.scan[:duplicate_ids]
|
|
422
|
+
|
|
423
|
+
HankoXml::PolicyError.new(
|
|
424
|
+
"document has more than one element with the same ID attribute value, and " \
|
|
425
|
+
"reject_duplicate_ids is true"
|
|
426
|
+
)
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def document_size_violation(bytesize)
|
|
430
|
+
return nil if bytesize.nil?
|
|
431
|
+
return nil if bytesize <= max_document_size
|
|
432
|
+
|
|
433
|
+
HankoXml::PolicyError.new(
|
|
434
|
+
"document is #{bytesize} bytes, more than this policy's max_document_size " \
|
|
435
|
+
"(#{max_document_size})"
|
|
436
|
+
)
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def key_size_violation(key)
|
|
440
|
+
kind, bits = key_bits(key)
|
|
441
|
+
return nil if kind.nil?
|
|
442
|
+
|
|
443
|
+
field = :"min_#{kind}_key_bits"
|
|
444
|
+
minimum = public_send(field)
|
|
445
|
+
return nil if bits >= minimum
|
|
446
|
+
|
|
447
|
+
HankoXml::PolicyError.new("#{kind} key is #{bits} bits, less than this policy's #{field} (#{minimum})")
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def key_bits(key)
|
|
451
|
+
public_key = key.is_a?(::OpenSSL::X509::Certificate) ? key.public_key : key
|
|
452
|
+
case public_key
|
|
453
|
+
when ::OpenSSL::PKey::RSA
|
|
454
|
+
[:rsa, public_key.n.num_bits]
|
|
455
|
+
when ::OpenSSL::PKey::EC
|
|
456
|
+
[:ec, public_key.group.degree]
|
|
457
|
+
when ::OpenSSL::PKey::DSA
|
|
458
|
+
[:dsa, public_key.p.num_bits]
|
|
459
|
+
else
|
|
460
|
+
[nil, nil]
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
ALGORITHM_FIELDS = {
|
|
465
|
+
signature: :signature_methods,
|
|
466
|
+
digest: :digest_methods,
|
|
467
|
+
canonicalization: :canonicalization_methods,
|
|
468
|
+
transform: :transforms
|
|
469
|
+
}.freeze
|
|
470
|
+
private_constant :ALGORITHM_FIELDS
|
|
471
|
+
|
|
472
|
+
# The allowlists resolved to URIs once, at construction (S-16), so a
|
|
473
|
+
# later Algorithms.register cannot change what an existing Policy --
|
|
474
|
+
# Policy.strict included -- accepts.
|
|
475
|
+
def snapshot_allowed_uris
|
|
476
|
+
ALGORITHM_FIELDS.to_h do |slot, field|
|
|
477
|
+
uris = Array(public_send(field)).filter_map { |entry| Algorithms.uri_for(slot, entry) }
|
|
478
|
+
[slot, uris.to_h { |uri| [uri, true] }.freeze]
|
|
479
|
+
end.freeze
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def algorithm_violation(slot, uri_or_name, reference_uri: nil)
|
|
483
|
+
return nil if uri_or_name.nil?
|
|
484
|
+
|
|
485
|
+
field_name = ALGORITHM_FIELDS.fetch(slot)
|
|
486
|
+
|
|
487
|
+
# A String from the document is compared verbatim: resolving it through
|
|
488
|
+
# the registry would let Algorithm="sha256" stand in for the real URI.
|
|
489
|
+
candidate = uri_or_name.is_a?(String) ? uri_or_name : Algorithms.uri_for(slot, uri_or_name)
|
|
490
|
+
return nil if @allowed_uris.fetch(slot).key?(candidate)
|
|
491
|
+
|
|
492
|
+
name = Algorithms.name_for(slot, uri_or_name)
|
|
493
|
+
location = reference_uri ? " on reference #{reference_uri.inspect}" : ""
|
|
494
|
+
|
|
495
|
+
HankoXml::PolicyError.new(
|
|
496
|
+
"#{slot} method #{uri_or_name}#{name ? " (#{name.inspect})" : ""}#{location} is not in " \
|
|
497
|
+
"this policy's #{field_name}. Use a preset such as HankoXml::Policy.legacy_sha1, " \
|
|
498
|
+
"HankoXml::Policy.xades or Policy#with(#{field_name}: …) if this document genuinely requires it."
|
|
499
|
+
)
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HankoXml
|
|
4
|
+
# A single <ds:Reference> a Signer will emit. Frozen value object. See
|
|
5
|
+
# API_CONTRACT.md §5.2.
|
|
6
|
+
class Reference
|
|
7
|
+
attr_reader :uri, :transforms, :digest_method, :id, :type
|
|
8
|
+
|
|
9
|
+
# +uri+: "" for the whole document (enveloped), "#id" for a fragment,
|
|
10
|
+
# or nil to default to "" when this is the only/default reference.
|
|
11
|
+
# +transforms+: Array of Transform / Symbol / String, in order.
|
|
12
|
+
# +digest_method+: Symbol or URI String, defaults to :sha256.
|
|
13
|
+
def initialize(uri: "", transforms: [:enveloped_signature], digest_method: :sha256, id: nil, type: nil)
|
|
14
|
+
@uri = uri.nil? ? "" : uri.to_s
|
|
15
|
+
@transforms = Array(transforms).map { |t| Transform.coerce(t) }
|
|
16
|
+
@digest_method = digest_method
|
|
17
|
+
@id = id
|
|
18
|
+
@type = type
|
|
19
|
+
freeze
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def digest_method_uri
|
|
23
|
+
Algorithms.uri_for(:digest, digest_method)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def enveloped?
|
|
27
|
+
transforms.any?(&:enveloped_signature?)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def fragment?
|
|
31
|
+
uri.start_with?("#")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def target_id
|
|
35
|
+
fragment? ? uri.sub(/\A#/, "") : nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.coerce(value)
|
|
39
|
+
case value
|
|
40
|
+
when Reference then value
|
|
41
|
+
when String then new(uri: value)
|
|
42
|
+
when Hash then new(**value)
|
|
43
|
+
else
|
|
44
|
+
raise ArgumentError, "expected a HankoXml::Reference, URI String, or Hash of Reference " \
|
|
45
|
+
"keywords, got #{value.class}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HankoXml
|
|
4
|
+
# DOM-free, unauthenticated view of a <ds:Signature>'s <SignedInfo> --
|
|
5
|
+
# exactly the shape HankoXml::Native.inspect returns (§7 of
|
|
6
|
+
# API_CONTRACT.md), and nothing more. Policy is checked against this, not
|
|
7
|
+
# against Nokogiri nodes, so Policy enforcement works identically whether
|
|
8
|
+
# or not Nokogiri is loaded (§6.1).
|
|
9
|
+
#
|
|
10
|
+
# Everything here is "what the document claims" -- it runs before any
|
|
11
|
+
# cryptography, and is not proof of anything until a Verification says so.
|
|
12
|
+
#
|
|
13
|
+
# The library builds these; callers only read them, through
|
|
14
|
+
# Verification#signed_info / #signature_info. The constructors are not
|
|
15
|
+
# part of the public API.
|
|
16
|
+
class SignatureInfo
|
|
17
|
+
XPATH_FILTER2 = "http://www.w3.org/2002/06/xmldsig-filter2"
|
|
18
|
+
# The whole document: without comments (""), and with them ("#xpointer(/)",
|
|
19
|
+
# which allow_same_document_xpointer still governs).
|
|
20
|
+
WHOLE_DOCUMENT_URIS = ["", "#xpointer(/)"].freeze
|
|
21
|
+
DSIG_NS = "http://www.w3.org/2000/09/xmldsig#"
|
|
22
|
+
|
|
23
|
+
ReferenceInfo = Struct.new(:uri, :digest_method, :transforms, :id, :type, :xpath_filters, keyword_init: true) do
|
|
24
|
+
def initialize(uri:, digest_method:, transforms: [], id: nil, type: nil, xpath_filters: [])
|
|
25
|
+
super
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Which <ds:Signature> elements an XPath Filter 2.0 transform takes out
|
|
29
|
+
# of this Reference, for the one shape HankoXml accepts (#85): a
|
|
30
|
+
# whole-document reference (WHOLE_DOCUMENT_URIS) whose first transform is a single `subtract` of
|
|
31
|
+
# `/descendant::P:Signature` (every signature, :all) or `/*/P:Signature`
|
|
32
|
+
# (the root's signature children, :root_children), with P bound to the
|
|
33
|
+
# XML-DSig namespace on the <XPath> element. nil for anything else, which
|
|
34
|
+
# Policy refuses. The expression is matched token for token (XPath
|
|
35
|
+
# allows whitespace between tokens), never by its prefix's spelling:
|
|
36
|
+
# `name()='ds:Signature'` would match any namespace bound to `ds`.
|
|
37
|
+
def signature_exclusion
|
|
38
|
+
return nil unless WHOLE_DOCUMENT_URIS.include?(uri.to_s) && transforms.first == XPATH_FILTER2
|
|
39
|
+
return nil unless transforms.count(XPATH_FILTER2) == 1 && xpath_filters.size == 1
|
|
40
|
+
|
|
41
|
+
filter = xpath_filters.first.transform_keys(&:to_sym)
|
|
42
|
+
return nil unless filter[:filter] == "subtract"
|
|
43
|
+
|
|
44
|
+
match = filter[:expression].to_s.gsub(/\s+/, "").match(%r{\A/(descendant::|\*/)([A-Za-z_][\w.-]*):Signature\z})
|
|
45
|
+
return nil unless match && filter[:namespaces].to_h[match[2]] == DSIG_NS
|
|
46
|
+
|
|
47
|
+
match[1] == "descendant::" ? :all : :root_children
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def xpath_filter2?
|
|
51
|
+
transforms.include?(XPATH_FILTER2)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def digest_method_name
|
|
55
|
+
Algorithms.name_for(:digest, digest_method)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def transform_names
|
|
59
|
+
transforms.map { |uri| Algorithms.name_for(:transform, uri) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def fragment?
|
|
63
|
+
uri.to_s.start_with?("#")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def external?
|
|
67
|
+
!(uri.to_s.empty? || fragment?)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The URI xmlsec reads an external reference from: everything before
|
|
71
|
+
# any `#` fragment.
|
|
72
|
+
def document_uri
|
|
73
|
+
uri.to_s.split("#", 2).first.to_s
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Same-document XPointer, e.g. `#xpointer(/)` or `#xpointer(id('x'))` --
|
|
77
|
+
# not a bare `#id` fragment (S-06, `allow_same_document_xpointer`).
|
|
78
|
+
def same_document_xpointer?
|
|
79
|
+
fragment? && uri[1..].to_s.include?("(")
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
attr_reader :signature_method, :canonicalization_method, :signature_count,
|
|
84
|
+
:key_names, :certificates, :references
|
|
85
|
+
|
|
86
|
+
# @api private
|
|
87
|
+
def initialize(signature_method:, canonicalization_method:, references:,
|
|
88
|
+
signature_count: 1, key_names: [], certificates: [], has_retrieval_method: false)
|
|
89
|
+
@signature_method = signature_method
|
|
90
|
+
@canonicalization_method = canonicalization_method
|
|
91
|
+
@signature_count = signature_count
|
|
92
|
+
@key_names = key_names.freeze
|
|
93
|
+
@certificates = certificates.freeze
|
|
94
|
+
@references = references.map { |r| r.is_a?(ReferenceInfo) ? r : ReferenceInfo.new(**r) }.freeze
|
|
95
|
+
@has_retrieval_method = has_retrieval_method
|
|
96
|
+
freeze
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Whether `<KeyInfo>` contains a `<RetrievalMethod>` (S-06,
|
|
100
|
+
# `allow_retrieval_method`).
|
|
101
|
+
def retrieval_method?
|
|
102
|
+
@has_retrieval_method
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def signature_method_name
|
|
106
|
+
Algorithms.name_for(:signature, signature_method)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def canonicalization_method_name
|
|
110
|
+
Algorithms.name_for(:canonicalization, canonicalization_method)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Builds a SignatureInfo from the native Native.inspect Hash shape
|
|
114
|
+
# (String keys/URIs throughout).
|
|
115
|
+
#
|
|
116
|
+
# @api private
|
|
117
|
+
def self.from_native(hash)
|
|
118
|
+
new(
|
|
119
|
+
signature_method: fetch(hash, :signature_method),
|
|
120
|
+
canonicalization_method: fetch(hash, :canonicalization_method),
|
|
121
|
+
signature_count: fetch(hash, :signature_count) || 1,
|
|
122
|
+
key_names: fetch(hash, :key_names) || [],
|
|
123
|
+
certificates: fetch(hash, :certificates) || [],
|
|
124
|
+
references: fetch(hash, :references) || [],
|
|
125
|
+
has_retrieval_method: fetch(hash, :has_retrieval_method) || false
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def self.fetch(hash, key)
|
|
130
|
+
hash[key] || hash[key.to_s]
|
|
131
|
+
end
|
|
132
|
+
private_class_method :fetch
|
|
133
|
+
end
|
|
134
|
+
end
|