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,572 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HankoXml
|
|
4
|
+
# Declares the parts of a signature -- references, transforms, KeyInfo
|
|
5
|
+
# clauses, placement -- and builds the <Signature> template from them.
|
|
6
|
+
# See API_CONTRACT.md §5.2.
|
|
7
|
+
#
|
|
8
|
+
# A Signer is frozen after construction: immutable, reusable, safe to
|
|
9
|
+
# share across threads.
|
|
10
|
+
#
|
|
11
|
+
# #template and #to_h inspect what a Signer would produce without signing;
|
|
12
|
+
# #sign and #sign! perform the signature through the native extension.
|
|
13
|
+
class Signer # rubocop:disable Metrics/ClassLength
|
|
14
|
+
TEMPLATE_MODES = %i[adopt build require].freeze
|
|
15
|
+
|
|
16
|
+
# The key class xmlsec needs for each built-in signature method, by the
|
|
17
|
+
# method name's prefix (:rsa_sha256 -> RSA). HMAC takes no asymmetric key
|
|
18
|
+
# and custom registered methods are left to xmlsec.
|
|
19
|
+
SIGNATURE_KEY_TYPES = {
|
|
20
|
+
"rsa" => ::OpenSSL::PKey::RSA,
|
|
21
|
+
"ecdsa" => ::OpenSSL::PKey::EC,
|
|
22
|
+
"dsa" => ::OpenSSL::PKey::DSA
|
|
23
|
+
}.freeze
|
|
24
|
+
private_constant :SIGNATURE_KEY_TYPES
|
|
25
|
+
|
|
26
|
+
# What to suggest for each key class when it does not fit the method.
|
|
27
|
+
SIGNATURE_METHOD_FOR_KEY = {
|
|
28
|
+
::OpenSSL::PKey::RSA => :rsa_sha256,
|
|
29
|
+
::OpenSSL::PKey::EC => :ecdsa_sha256,
|
|
30
|
+
::OpenSSL::PKey::DSA => :dsa_sha1
|
|
31
|
+
}.freeze
|
|
32
|
+
private_constant :SIGNATURE_METHOD_FOR_KEY
|
|
33
|
+
|
|
34
|
+
# KeyInfo leaves xmlsec derives from the signing key. It fills only empty
|
|
35
|
+
# ones, so an adopted template's stale values must be emptied first (#72).
|
|
36
|
+
KEY_DERIVED_LEAVES = %w[
|
|
37
|
+
ds:X509Data/ds:X509Certificate ds:X509Data/ds:X509SubjectName
|
|
38
|
+
ds:X509Data/ds:X509IssuerSerial ds:X509Data/ds:X509SKI ds:KeyValue
|
|
39
|
+
].map { |path| "./#{path}" }.join(" | ").freeze
|
|
40
|
+
private_constant :KEY_DERIVED_LEAVES
|
|
41
|
+
|
|
42
|
+
# The setters are for the Signer.new block only: the Signer is frozen
|
|
43
|
+
# when #initialize returns, after which they raise FrozenError.
|
|
44
|
+
attr_accessor :key, :certificate, :certificates, :signature_method, :canonicalization_method,
|
|
45
|
+
:digest_method, :references, :placement, :id_attributes, :namespaces,
|
|
46
|
+
:signature_id, :policy
|
|
47
|
+
attr_reader :template_mode
|
|
48
|
+
# Reader only: #key_info below is a dual getter/setter (API_CONTRACT.md
|
|
49
|
+
# §5.2), so an attr_accessor reader here would be dead code shadowed by it.
|
|
50
|
+
attr_writer :key_info
|
|
51
|
+
|
|
52
|
+
# Creates a new Signer with the given configuration.
|
|
53
|
+
# Yields +self+ to a block if provided, allowing block-based configuration before freezing.
|
|
54
|
+
#
|
|
55
|
+
# @param key [Key, OpenSSL::PKey::PKey, String, Pathname] The private key used to sign the document.
|
|
56
|
+
# @param certificate [Key, OpenSSL::X509::Certificate, String, Pathname, nil]
|
|
57
|
+
# The certificate corresponding to the key.
|
|
58
|
+
# @param certificates [Array<Key, OpenSSL::X509::Certificate, String, Pathname>] Extra certificates for the chain.
|
|
59
|
+
# @param signature_method [Symbol] The signature algorithm (e.g., :rsa_sha256).
|
|
60
|
+
# @param digest_method [Symbol] The digest algorithm for references (e.g., :sha256).
|
|
61
|
+
# @param canonicalization_method [Symbol] The canonicalization method for SignedInfo (e.g., :exc_c14n).
|
|
62
|
+
# @param placement [Placement, Symbol] Where to insert the `<Signature>` node (e.g., :append_to_root).
|
|
63
|
+
# @param references [Array<Reference, String, Hash>] The references to sign.
|
|
64
|
+
# @param key_info [Array<Symbol, Hash>] The elements to include in `<KeyInfo>` (e.g., :x509_certificate).
|
|
65
|
+
# `<KeyName>` needs its text: `{ key_name: "idp-1" }`.
|
|
66
|
+
# @param id_attributes [Array<String, Array(String, String)>] The attribute names considered as IDs for
|
|
67
|
+
# `#foo` references, optionally as `[namespace_href, name]` pairs.
|
|
68
|
+
# @param template [Symbol] Either :adopt (reuse existing signature template), :build, or :require.
|
|
69
|
+
# @param signature_id [String, nil] The Id attribute to place on the `<Signature>` element itself.
|
|
70
|
+
def initialize(key:, certificate: nil, certificates: nil, signature_method: :rsa_sha256,
|
|
71
|
+
canonicalization_method: :exc_c14n, digest_method: :sha256, references: [],
|
|
72
|
+
key_info: [:x509_certificate], placement: :append_to_root,
|
|
73
|
+
id_attributes: %w[Id ID id], namespaces: {}, signature_id: nil,
|
|
74
|
+
policy: HankoXml::Policy.strict, template: :adopt)
|
|
75
|
+
unless key.is_a?(HankoXml::Key) || Dom.pkey?(key) || key.is_a?(String) ||
|
|
76
|
+
key.respond_to?(:to_path)
|
|
77
|
+
raise HankoXml::ConfigurationError,
|
|
78
|
+
"key: must be key material or an HankoXml::Key (external signers are not supported " \
|
|
79
|
+
"in v1 -- see API_CONTRACT.md §5.2)"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
unless TEMPLATE_MODES.include?(template.to_sym)
|
|
83
|
+
raise HankoXml::ConfigurationError, "template: must be one of #{TEMPLATE_MODES.inspect}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
@key = HankoXml::Key.coerce(key)
|
|
87
|
+
@certificate = certificate && HankoXml::Key.coerce(certificate)
|
|
88
|
+
@certificates = Array(certificates).map { |c| HankoXml::Key.coerce(c) }
|
|
89
|
+
@signature_method = signature_method
|
|
90
|
+
@canonicalization_method = canonicalization_method
|
|
91
|
+
@digest_method = digest_method
|
|
92
|
+
@references = Array(references).map { |r| HankoXml::Reference.coerce(r) }
|
|
93
|
+
@key_info = HankoXml::KeyInfo.coerce(key_info)
|
|
94
|
+
@placement = HankoXml::Placement.coerce(placement) || HankoXml::Placement.append_to_root
|
|
95
|
+
@id_attributes = id_attributes
|
|
96
|
+
@namespaces = namespaces
|
|
97
|
+
@signature_id = signature_id
|
|
98
|
+
@policy = policy
|
|
99
|
+
@template_mode = template.to_sym
|
|
100
|
+
|
|
101
|
+
yield self if block_given?
|
|
102
|
+
|
|
103
|
+
# After the block, which may reassign key/certificate (S-14, S-15).
|
|
104
|
+
check_key_material!
|
|
105
|
+
check_key_matches_certificate!
|
|
106
|
+
|
|
107
|
+
freeze
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Adds a Reference. Block form only -- see API_CONTRACT.md §5.2.
|
|
111
|
+
def reference(uri = "", **kwargs)
|
|
112
|
+
@references << HankoXml::Reference.new(uri: uri, **kwargs)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# With no args, the current KeyInfo clauses. With args, replaces them --
|
|
116
|
+
# block-form usage per API_CONTRACT.md §5.2 (`s.key_info :x509_certificate`,
|
|
117
|
+
# `s.key_info :key_name, name: "idp-1"`).
|
|
118
|
+
def key_info(*clauses, name: nil)
|
|
119
|
+
return @key_info if clauses.empty? && name.nil?
|
|
120
|
+
|
|
121
|
+
clauses = clauses.flatten
|
|
122
|
+
if name
|
|
123
|
+
unless clauses.any? { |c| c.to_s == "key_name" }
|
|
124
|
+
raise HankoXml::ConfigurationError, "key_info name: only applies to the :key_name clause"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
clauses = clauses.map { |c| c.to_s == "key_name" ? { key_name: name } : c }
|
|
128
|
+
end
|
|
129
|
+
@key_info = HankoXml::KeyInfo.coerce(clauses)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The unsigned <Signature> Nokogiri::XML::Element this Signer would
|
|
133
|
+
# build and insert, given its current configuration. Does not insert
|
|
134
|
+
# it into +doc_or_xml+ and performs no cryptography -- for inspection
|
|
135
|
+
# and testing (§5.2).
|
|
136
|
+
def template(doc_or_xml)
|
|
137
|
+
doc = HankoXml.coerce_document(doc_or_xml)
|
|
138
|
+
HankoXml::Template.build(doc, self)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def to_h
|
|
142
|
+
{
|
|
143
|
+
key: key, certificate: certificate, certificates: certificates,
|
|
144
|
+
signature_method: signature_method, canonicalization_method: canonicalization_method,
|
|
145
|
+
digest_method: digest_method, references: references, key_info: key_info,
|
|
146
|
+
placement: placement, id_attributes: id_attributes, namespaces: namespaces,
|
|
147
|
+
signature_id: signature_id, policy: policy, template: template_mode
|
|
148
|
+
}
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Signs a document without modifying the original.
|
|
152
|
+
# Returns a {SignedDocument} safe for serialization.
|
|
153
|
+
#
|
|
154
|
+
# Without Nokogiri it signs an existing <Signature> template in the
|
|
155
|
+
# bytes, through the native layer only, and the {SignedDocument} holds
|
|
156
|
+
# just the signed bytes (#102). Building a template needs a DOM.
|
|
157
|
+
#
|
|
158
|
+
# @param doc_or_xml [String, Nokogiri::XML::Node, Nokogiri::XML::Document, IO] The document to sign.
|
|
159
|
+
# @param force [Boolean] Whether to re-sign already signed signatures.
|
|
160
|
+
# @param signature [Integer, nil] The document-order index of the one
|
|
161
|
+
# <Signature> to sign (defaults to every unsigned one).
|
|
162
|
+
# @return [HankoXml::SignedDocument] The new signed document.
|
|
163
|
+
def sign(doc_or_xml, force: false, signature: nil)
|
|
164
|
+
node_signer = signer_for_node(doc_or_xml)
|
|
165
|
+
return node_signer.sign(doc_or_xml.document, force: force, signature: signature) if node_signer
|
|
166
|
+
return sign_bytes(doc_or_xml, force: force, signature: signature) unless Dom.available?
|
|
167
|
+
|
|
168
|
+
doc = HankoXml.coerce_document(doc_or_xml, policy: policy)
|
|
169
|
+
# Deep clone the document to avoid mutating the input if it's already a document.
|
|
170
|
+
cloned = doc.dup
|
|
171
|
+
sign!(cloned, force: force, signature: signature)
|
|
172
|
+
HankoXml::SignedDocument.new(cloned)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Signs a Nokogiri XML document in place.
|
|
176
|
+
# Mutates the provided document by appending/writing back signature bytes.
|
|
177
|
+
#
|
|
178
|
+
# @param doc_or_xml [Nokogiri::XML::Node, Nokogiri::XML::Document] The document to mutate.
|
|
179
|
+
# @param force [Boolean] Whether to re-sign already signed signatures.
|
|
180
|
+
# @param signature [Integer, nil] The document-order index of the one
|
|
181
|
+
# <Signature> to sign (defaults to every unsigned one), matching
|
|
182
|
+
# {Verifier}'s `signature:` Integer index (S-11).
|
|
183
|
+
# @return [Nokogiri::XML::Node, Nokogiri::XML::Document] The mutated document.
|
|
184
|
+
def sign!(doc_or_xml, force: false, signature: nil)
|
|
185
|
+
node_signer = signer_for_node(doc_or_xml)
|
|
186
|
+
return node_signer.sign!(doc_or_xml.document, force: force, signature: signature) if node_signer
|
|
187
|
+
|
|
188
|
+
doc = HankoXml.coerce_document(doc_or_xml, policy: policy)
|
|
189
|
+
Dom.require!("HankoXml::Signer#sign!")
|
|
190
|
+
|
|
191
|
+
signatures, adopted = find_or_build_signatures(doc)
|
|
192
|
+
all_sigs_in_order = doc.xpath(".//ds:Signature", ds: NS["ds"])
|
|
193
|
+
|
|
194
|
+
signatures_to_sign = target_signatures(signatures, all_sigs_in_order, signature)
|
|
195
|
+
signatures_to_sign.reject! { |node| node_signed?(node) } unless force
|
|
196
|
+
|
|
197
|
+
return doc if signatures_to_sign.empty?
|
|
198
|
+
|
|
199
|
+
xml_bytes = HankoXml.serialize(doc)
|
|
200
|
+
enforce_document_and_key_size!(xml_bytes)
|
|
201
|
+
|
|
202
|
+
# Policy + reference-URI checks run on every template before any
|
|
203
|
+
# Native.sign (S-05). inspect_sig does not fetch; Native.sign would.
|
|
204
|
+
# Two loops on purpose: a later template must not be signed after an
|
|
205
|
+
# earlier one already passed, if a subsequent one is hostile.
|
|
206
|
+
signatures_to_sign.each do |sig_node|
|
|
207
|
+
index = all_sigs_in_order.index(sig_node)
|
|
208
|
+
enforce_before_native_sign!(doc, xml_bytes, index, sig_node: sig_node, adopted: adopted)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
xml_bytes = prepare_key_infos!(doc, xml_bytes, signatures_to_sign, adopted: adopted)
|
|
212
|
+
|
|
213
|
+
signatures_to_sign.each do |sig_node|
|
|
214
|
+
index = all_sigs_in_order.index(sig_node)
|
|
215
|
+
|
|
216
|
+
xml_bytes = Native.sign(xml_bytes, @key.to_pem, certificate_pems, native_id_attributes, index)
|
|
217
|
+
|
|
218
|
+
write_back_signature(doc, xml_bytes, index, sig_node)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
doc
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def inspect
|
|
225
|
+
"#<#{self.class} key=[FILTERED] signature_method=#{signature_method.inspect} " \
|
|
226
|
+
"canonicalization_method=#{canonicalization_method.inspect} digest_method=#{digest_method.inspect} " \
|
|
227
|
+
"references=#{references.size}>"
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
private
|
|
231
|
+
|
|
232
|
+
# A certificate or public key as key: can only fail later inside xmlsec,
|
|
233
|
+
# and a key as certificate: would send private key PEM down the
|
|
234
|
+
# certificate path (S-15, F5/F7). Only checks what coercion produced;
|
|
235
|
+
# raw values assigned in the block are left to the native layer.
|
|
236
|
+
def check_key_material!
|
|
237
|
+
# A disposed Key raises KeyLoadError here rather than being misreported
|
|
238
|
+
# as the wrong kind of key below.
|
|
239
|
+
[key, certificate].grep(HankoXml::Key).each(&:to_openssl)
|
|
240
|
+
|
|
241
|
+
if key.is_a?(HankoXml::Key) && !key.private_key?
|
|
242
|
+
raise HankoXml::ConfigurationError, "key: must be a private key (got a #{key_kind(key)})"
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
check_key_type!
|
|
246
|
+
return unless certificate.is_a?(HankoXml::Key) && !certificate.certificate?
|
|
247
|
+
|
|
248
|
+
raise HankoXml::ConfigurationError, "certificate: must be an X.509 certificate (got a #{key_kind(certificate)})"
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# No XML-DSig signature method here takes an Ed25519/Ed448 key, which
|
|
252
|
+
# otherwise failed as a NoMethodError from inside the library (#69).
|
|
253
|
+
def check_key_type!
|
|
254
|
+
pkey = key.is_a?(HankoXml::Key) ? key.to_openssl : key
|
|
255
|
+
return unless pkey.is_a?(::OpenSSL::PKey::PKey)
|
|
256
|
+
return if SIGNATURE_METHOD_FOR_KEY.key?(pkey.class)
|
|
257
|
+
|
|
258
|
+
raise HankoXml::ConfigurationError,
|
|
259
|
+
"key: is an unsupported #{pkey.oid} key; signing supports RSA, EC and DSA keys"
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# xmlsec reports a key that does not fit the signature method only as
|
|
263
|
+
# "xmlSecDSigCtxSign failed" (#70). Checked against the method actually
|
|
264
|
+
# being signed, which for an adopted template is the template's.
|
|
265
|
+
def check_key_fits_signature_method!(sig_info)
|
|
266
|
+
name = HankoXml::Algorithms.name_for(:signature, sig_info.signature_method)
|
|
267
|
+
needed = SIGNATURE_KEY_TYPES[signature_key_kind(name)]
|
|
268
|
+
pkey = @key.to_openssl
|
|
269
|
+
return if needed.nil? || pkey.is_a?(needed)
|
|
270
|
+
|
|
271
|
+
raise HankoXml::ConfigurationError,
|
|
272
|
+
"signature method #{name.inspect} needs #{key_type_name(needed)} key, but key: is " \
|
|
273
|
+
"#{key_type_name(pkey.class)} key; use signature_method: #{SIGNATURE_METHOD_FOR_KEY[pkey.class].inspect}"
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# With +signature+, the one <Signature> at that document-order index, to
|
|
277
|
+
# agree with Verifier's Integer index (S-11). Otherwise every signature,
|
|
278
|
+
# innermost first, so nested signatures are signed before the ones that
|
|
279
|
+
# envelope them.
|
|
280
|
+
def target_signatures(signatures, all_sigs_in_order, signature)
|
|
281
|
+
return signatures.sort_by { |node| -node.ancestors.size } unless signature
|
|
282
|
+
|
|
283
|
+
target = all_sigs_in_order[signature]
|
|
284
|
+
raise HankoXml::ConfigurationError, "signature: #{signature.inspect} not found" unless target
|
|
285
|
+
|
|
286
|
+
[target]
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# A Node other than the root is the default reference target (API_CONTRACT.md
|
|
290
|
+
# §6.2, #96): with no references: of its own, the Signer signs "#<its Id>",
|
|
291
|
+
# exactly as if the caller had written references: ["#<its Id>"]. Returns
|
|
292
|
+
# that Signer, or nil when the input is a whole document, the root, or
|
|
293
|
+
# this Signer already names its references.
|
|
294
|
+
def signer_for_node(input)
|
|
295
|
+
return nil unless HankoXml::Dom.node?(input) && !HankoXml::Dom.document?(input)
|
|
296
|
+
return nil if input == input.document.root || !@references.empty?
|
|
297
|
+
|
|
298
|
+
id = @id_attributes.lazy.map { |attr| input[attr.is_a?(Array) ? attr[1].to_s : attr.to_s] }.find { |v| v }
|
|
299
|
+
unless id
|
|
300
|
+
raise HankoXml::ConfigurationError,
|
|
301
|
+
"a #{input.name} node was passed to sign, so it is the reference target, but it has no " \
|
|
302
|
+
"#{@id_attributes.inspect} attribute; give it one or pass references:"
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
self.class.new(**to_h.merge(references: ["##{id}"]))
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Built-in method names encode the key class in the prefix: rsa_sha256
|
|
309
|
+
# and rsa_pss_sha256 are RSA, ecdsa_sha256 and ecdsa_sha3_256 are EC.
|
|
310
|
+
# HMAC and custom registrations are left to xmlsec (needed is nil).
|
|
311
|
+
def signature_key_kind(name)
|
|
312
|
+
method = name.to_s
|
|
313
|
+
return "ecdsa" if method.start_with?("ecdsa")
|
|
314
|
+
return "rsa" if method.start_with?("rsa")
|
|
315
|
+
return "dsa" if method.start_with?("dsa")
|
|
316
|
+
|
|
317
|
+
nil
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def key_type_name(klass)
|
|
321
|
+
type = klass.name.split("::").last
|
|
322
|
+
"#{type == "DSA" ? "a" : "an"} #{type}"
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def key_kind(key)
|
|
326
|
+
return "certificate" if key.certificate?
|
|
327
|
+
|
|
328
|
+
key.private_key? ? "private key" : "public key"
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# A key that does not belong to certificate: produces a signature that
|
|
332
|
+
# verifies against neither that certificate nor any other (S-14). Only
|
|
333
|
+
# checks a private key against an X.509 certificate; other shapes are
|
|
334
|
+
# left to Key coercion and the native layer.
|
|
335
|
+
def check_key_matches_certificate!
|
|
336
|
+
pkey = key.is_a?(HankoXml::Key) ? key.to_openssl : key
|
|
337
|
+
cert = certificate.is_a?(HankoXml::Key) ? certificate.to_openssl : certificate
|
|
338
|
+
return unless pkey.is_a?(::OpenSSL::PKey::PKey) && cert.is_a?(::OpenSSL::X509::Certificate)
|
|
339
|
+
return if cert.check_private_key(pkey)
|
|
340
|
+
|
|
341
|
+
raise HankoXml::ConfigurationError,
|
|
342
|
+
"key: does not match certificate: (subject #{cert.subject}); " \
|
|
343
|
+
"the signature would not verify against that certificate"
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def find_or_build_signatures(doc)
|
|
347
|
+
existing = doc.xpath(".//ds:Signature", ds: NS["ds"]).to_a
|
|
348
|
+
|
|
349
|
+
if existing.empty?
|
|
350
|
+
raise HankoXml::ConfigurationError, "template: :require but no <Signature> found" if @template_mode == :require
|
|
351
|
+
|
|
352
|
+
# API_CONTRACT.md §5.5: detached builds a <Signature> but inserts
|
|
353
|
+
# nothing, so there is nothing in the document to sign. Without this
|
|
354
|
+
# the unplaced node fell through to a TypeError (#52 G6).
|
|
355
|
+
if @placement.detached?
|
|
356
|
+
raise HankoXml::ConfigurationError,
|
|
357
|
+
"placement: :detached inserts no <Signature>, so there is nothing to sign; use " \
|
|
358
|
+
"Signer#template(doc) and insert it yourself, or Placement.custom"
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
template_node = template(doc)
|
|
362
|
+
@placement.insert(doc, template_node, namespaces: @namespaces)
|
|
363
|
+
[[template_node], false]
|
|
364
|
+
else
|
|
365
|
+
if @template_mode == :build
|
|
366
|
+
raise HankoXml::ConfigurationError,
|
|
367
|
+
"template: :build but <Signature> already exists"
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
[existing, true]
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# Before inspect_sig, which is itself a native parse (S-12).
|
|
375
|
+
def enforce_document_and_key_size!(xml_bytes)
|
|
376
|
+
@policy.enforce_document_size!(xml_bytes.bytesize)
|
|
377
|
+
@policy.enforce_key_size!([@key.to_openssl])
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Policy is a structural allowlist over inspect_sig output. Enforce it
|
|
381
|
+
# before Native.sign so a hostile adopted template cannot choose
|
|
382
|
+
# algorithms, external URIs, or XSLT (S-05). On :adopt/:require, the
|
|
383
|
+
# template's Reference URIs must also match this Signer's references
|
|
384
|
+
# (empty references means whole-document URI=""); that is the
|
|
385
|
+
# chosen-message opt-in. Returns inspect_sig's output.
|
|
386
|
+
def enforce_before_native_sign!(doc, xml_bytes, index, sig_node:, adopted:)
|
|
387
|
+
inspected = Native.inspect_sig(xml_bytes, index)
|
|
388
|
+
sig_info = HankoXml::SignatureInfo.from_native(inspected)
|
|
389
|
+
# require_signed_root is a verification-time XSW defence, not a
|
|
390
|
+
# constraint on what is legitimate to sign (see Policy#violations) --
|
|
391
|
+
# Signer deliberately does not check it, so a caller-chosen fragment
|
|
392
|
+
# signature (references:, R5) still signs under Policy.strict.
|
|
393
|
+
@policy.enforce!(sig_info, document: doc, sig_node: sig_node, id_attributes: @id_attributes,
|
|
394
|
+
xml_bytes: xml_bytes, check_root_signed: false)
|
|
395
|
+
ensure_adopted_references_match!(sig_info) if adopted
|
|
396
|
+
check_key_fits_signature_method!(sig_info)
|
|
397
|
+
inspected
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# #sign without Nokogiri (#102): the same checks as #sign! on the same
|
|
401
|
+
# bytes, then Native.sign. Only an existing template can be signed, one
|
|
402
|
+
# per call, and only when its KeyInfo needs no DOM edits first.
|
|
403
|
+
def sign_bytes(source, force:, signature:)
|
|
404
|
+
input = HankoXml::Input.read(source, policy: policy)
|
|
405
|
+
bytes = input.bytes
|
|
406
|
+
index = bytes_target_index(input.scan(native_id_attributes), signature, force)
|
|
407
|
+
return HankoXml::SignedDocument.from_bytes(bytes) unless index
|
|
408
|
+
|
|
409
|
+
enforce_document_and_key_size!(bytes)
|
|
410
|
+
inspected = enforce_before_native_sign!(nil, bytes, index, sig_node: nil, adopted: true)
|
|
411
|
+
check_key_info_without_dom!(inspected)
|
|
412
|
+
|
|
413
|
+
HankoXml::SignedDocument.from_bytes(
|
|
414
|
+
Native.sign(bytes, @key.to_pem, certificate_pems, native_id_attributes, index)
|
|
415
|
+
)
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# The document-order index of the template to sign, or nil when it is
|
|
419
|
+
# already signed and force: is false. Mirrors find_or_build_signatures
|
|
420
|
+
# and target_signatures, from Native.scan instead of a DOM.
|
|
421
|
+
def bytes_target_index(scan, signature, force)
|
|
422
|
+
signed = scan[:signature_values]
|
|
423
|
+
if signed.empty?
|
|
424
|
+
raise HankoXml::ConfigurationError, "template: :require but no <Signature> found" if @template_mode == :require
|
|
425
|
+
|
|
426
|
+
Dom.require!("Building a <Signature> template (template: #{@template_mode.inspect})")
|
|
427
|
+
end
|
|
428
|
+
raise HankoXml::ConfigurationError, "template: :build but <Signature> already exists" if @template_mode == :build
|
|
429
|
+
|
|
430
|
+
candidates = signature.nil? ? signed.each_index.to_a : [bytes_signature_index(signed.size, signature)]
|
|
431
|
+
candidates.reject! { |i| signed[i] } unless force
|
|
432
|
+
if candidates.size > 1
|
|
433
|
+
Dom.require!("Signing several <Signature> templates in one call (pass signature: to sign one at a time)")
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
candidates.first
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def bytes_signature_index(count, signature)
|
|
440
|
+
unless signature.is_a?(Integer) && signature.between?(-count, count - 1)
|
|
441
|
+
raise HankoXml::ConfigurationError, "signature: #{signature.inspect} not found"
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
signature % count
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# prepare_key_infos! edits KeyInfo in the DOM: it names an empty
|
|
448
|
+
# <KeyName> (#67) and empties stale key-derived leaves (#72). Without a
|
|
449
|
+
# DOM neither can happen, so such a template is refused rather than
|
|
450
|
+
# signed with an empty name or a stale certificate.
|
|
451
|
+
def check_key_info_without_dom!(inspected)
|
|
452
|
+
Dom.require!("Signing a template with an empty <KeyName>") if inspected[:empty_key_names]
|
|
453
|
+
return unless inspected[:key_derived_values]
|
|
454
|
+
|
|
455
|
+
Dom.require!("Signing a template whose <KeyInfo> already holds certificate or key values (#72)")
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def certificate_pems
|
|
459
|
+
pems = @certificates.map(&:to_pem)
|
|
460
|
+
pems.unshift(@certificate.to_pem) if @certificate
|
|
461
|
+
pems
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# Same mapping as Verifier#native_id_attributes: xmlSecAddIDs matches by
|
|
465
|
+
# local name only, so a [href, name] pair crosses the FFI as its name (#68).
|
|
466
|
+
def native_id_attributes
|
|
467
|
+
@id_attributes.map { |attr| attr.is_a?(Array) ? attr[1].to_s : attr.to_s }
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# Readies each template's KeyInfo for xmlsec, and returns the bytes to
|
|
471
|
+
# sign. Runs only once every check passed, so a refused template leaves
|
|
472
|
+
# the caller's document untouched.
|
|
473
|
+
def prepare_key_infos!(doc, xml_bytes, signatures_to_sign, adopted:)
|
|
474
|
+
changed = signatures_to_sign.map do |sig_node|
|
|
475
|
+
key_info = sig_node.at_xpath("./ds:KeyInfo", ds: NS["ds"])
|
|
476
|
+
next false unless key_info
|
|
477
|
+
|
|
478
|
+
# Both run: | does not short-circuit.
|
|
479
|
+
name_empty_key_names!(key_info) | (adopted && empty_key_derived_leaves!(key_info))
|
|
480
|
+
end
|
|
481
|
+
changed.any? ? HankoXml.serialize(doc) : xml_bytes
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# xmlsec writes the key's own name into <KeyName>, and a key loaded from
|
|
485
|
+
# PEM has none (#67). Returns whether anything was named.
|
|
486
|
+
def name_empty_key_names!(key_info)
|
|
487
|
+
empty = key_info.xpath("./ds:KeyName", ds: NS["ds"]).select { |node| node.content.strip.empty? }
|
|
488
|
+
return false if empty.empty?
|
|
489
|
+
|
|
490
|
+
key_name = @key_info.find { |c| c.name == :key_name && c.value }&.value
|
|
491
|
+
unless key_name
|
|
492
|
+
raise HankoXml::ConfigurationError,
|
|
493
|
+
"<KeyName> would be signed empty: a key has no name of its own; pass " \
|
|
494
|
+
"key_info: [{ key_name: \"...\" }] or, in the block form, s.key_info :key_name, name: \"...\""
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
empty.each { |node| node.content = key_name }
|
|
498
|
+
true
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# xmlsec fills only empty key-derived leaves, so an adopted template's
|
|
502
|
+
# stale certificate stayed in the signed document (#72). Returns whether
|
|
503
|
+
# anything was emptied.
|
|
504
|
+
def empty_key_derived_leaves!(key_info)
|
|
505
|
+
stale = key_info.xpath(KEY_DERIVED_LEAVES, ds: NS["ds"]).reject { |leaf| leaf.children.empty? }
|
|
506
|
+
stale.each { |leaf| leaf.children.each(&:unlink) }
|
|
507
|
+
stale.any?
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def ensure_adopted_references_match!(sig_info)
|
|
511
|
+
expected = effective_reference_uris
|
|
512
|
+
actual = sig_info.references.map(&:uri)
|
|
513
|
+
return if expected == actual
|
|
514
|
+
|
|
515
|
+
raise HankoXml::ConfigurationError,
|
|
516
|
+
"template: #{@template_mode} would sign reference URIs #{actual.inspect}, " \
|
|
517
|
+
"which do not match this Signer's references #{expected.inspect}"
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
def effective_reference_uris
|
|
521
|
+
refs = @references
|
|
522
|
+
refs = [HankoXml::Reference.new(uri: "")] if refs.empty?
|
|
523
|
+
refs.map(&:uri)
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
def node_signed?(sig_node)
|
|
527
|
+
val = sig_node.at_xpath("./ds:SignatureValue", ds: NS["ds"])
|
|
528
|
+
val && !val.content.strip.empty?
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
def write_back_signature(doc, signed_bytes, index, caller_sig_node)
|
|
532
|
+
# Parse the signed bytes
|
|
533
|
+
signed_doc = HankoXml.parse(signed_bytes)
|
|
534
|
+
signed_sigs = signed_doc.xpath(".//ds:Signature", ds: NS["ds"])
|
|
535
|
+
signed_sig_node = signed_sigs[index]
|
|
536
|
+
|
|
537
|
+
# Copy ds:Reference/ds:DigestValue
|
|
538
|
+
caller_refs = caller_sig_node.xpath(".//ds:Reference", ds: NS["ds"])
|
|
539
|
+
signed_refs = signed_sig_node.xpath(".//ds:Reference", ds: NS["ds"])
|
|
540
|
+
|
|
541
|
+
caller_refs.each_with_index do |caller_ref, i|
|
|
542
|
+
signed_ref = signed_refs[i]
|
|
543
|
+
caller_dv = caller_ref.at_xpath("./ds:DigestValue", ds: NS["ds"])
|
|
544
|
+
signed_dv = signed_ref.at_xpath("./ds:DigestValue", ds: NS["ds"])
|
|
545
|
+
|
|
546
|
+
caller_dv.content = signed_dv.content if caller_dv && signed_dv
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
# Copy ds:SignatureValue
|
|
550
|
+
caller_sv = caller_sig_node.at_xpath("./ds:SignatureValue", ds: NS["ds"])
|
|
551
|
+
signed_sv = signed_sig_node.at_xpath("./ds:SignatureValue", ds: NS["ds"])
|
|
552
|
+
caller_sv.content = signed_sv.content if caller_sv && signed_sv
|
|
553
|
+
|
|
554
|
+
write_back_key_info(doc, caller_sig_node, signed_sig_node)
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
# xmlsec fills KeyInfo from the key: certificates (possibly several, for
|
|
558
|
+
# a certificates: chain), <RSAKeyValue>/<ECKeyValue>, X509SubjectName and
|
|
559
|
+
# the rest. Copying individual leaves missed everything but
|
|
560
|
+
# X509Certificate, so the caller's document kept an empty <KeyValue/>
|
|
561
|
+
# that no longer matched what was signed (#52 E5/E6/E8). Mirror the
|
|
562
|
+
# signed KeyInfo's children wholesale instead.
|
|
563
|
+
def write_back_key_info(doc, caller_sig_node, signed_sig_node)
|
|
564
|
+
caller_key_info = caller_sig_node.at_xpath("./ds:KeyInfo", ds: NS["ds"])
|
|
565
|
+
signed_key_info = signed_sig_node.at_xpath("./ds:KeyInfo", ds: NS["ds"])
|
|
566
|
+
return unless caller_key_info && signed_key_info
|
|
567
|
+
|
|
568
|
+
caller_key_info.children.each(&:unlink)
|
|
569
|
+
signed_key_info.children.each { |child| caller_key_info.add_child(child.dup(1, doc)) }
|
|
570
|
+
end
|
|
571
|
+
end
|
|
572
|
+
end
|