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.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +44 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +222 -0
  5. data/THIRD_PARTY_LICENSES.md +392 -0
  6. data/dependencies.yml +87 -0
  7. data/lib/hanko_xml/3.1/hanko_xml.so +0 -0
  8. data/lib/hanko_xml/3.2/hanko_xml.so +0 -0
  9. data/lib/hanko_xml/3.3/hanko_xml.so +0 -0
  10. data/lib/hanko_xml/3.4/hanko_xml.so +0 -0
  11. data/lib/hanko_xml/algorithms.rb +228 -0
  12. data/lib/hanko_xml/dom.rb +137 -0
  13. data/lib/hanko_xml/errors.rb +132 -0
  14. data/lib/hanko_xml/exc-c14n.xsd +26 -0
  15. data/lib/hanko_xml/input.rb +50 -0
  16. data/lib/hanko_xml/key.rb +213 -0
  17. data/lib/hanko_xml/key_info.rb +67 -0
  18. data/lib/hanko_xml/native_version.rb +75 -0
  19. data/lib/hanko_xml/nokogiri_ext.rb +34 -0
  20. data/lib/hanko_xml/placement.rb +127 -0
  21. data/lib/hanko_xml/policy.rb +502 -0
  22. data/lib/hanko_xml/reference.rb +49 -0
  23. data/lib/hanko_xml/signature_info.rb +134 -0
  24. data/lib/hanko_xml/signed_document.rb +155 -0
  25. data/lib/hanko_xml/signer.rb +572 -0
  26. data/lib/hanko_xml/template.rb +150 -0
  27. data/lib/hanko_xml/transform.rb +87 -0
  28. data/lib/hanko_xml/verification.rb +377 -0
  29. data/lib/hanko_xml/verifier.rb +694 -0
  30. data/lib/hanko_xml/version.rb +5 -0
  31. data/lib/hanko_xml/xades/verifier.rb +319 -0
  32. data/lib/hanko_xml/xades.rb +287 -0
  33. data/lib/hanko_xml/xml.rb +93 -0
  34. data/lib/hanko_xml/xmldsig-core-schema.xsd +332 -0
  35. data/lib/hanko_xml.rb +122 -0
  36. data/sig/hanko_xml/key.rbs +48 -0
  37. data/sig/hanko_xml/native.rbs +12 -0
  38. data/sig/hanko_xml/native_version.rbs +13 -0
  39. data/sig/hanko_xml/nokogiri_ext.rbs +23 -0
  40. data/sig/hanko_xml/placement.rbs +27 -0
  41. data/sig/hanko_xml/policy.rbs +83 -0
  42. data/sig/hanko_xml/reference.rbs +36 -0
  43. data/sig/hanko_xml/signed_document.rbs +66 -0
  44. data/sig/hanko_xml/signer.rbs +50 -0
  45. data/sig/hanko_xml/template.rbs +41 -0
  46. data/sig/hanko_xml/verification.rbs +100 -0
  47. data/sig/hanko_xml/verifier.rbs +45 -0
  48. data/sig/hanko_xml/xades.rbs +70 -0
  49. data/sig/hanko_xml.rbs +62 -0
  50. metadata +104 -0
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module HankoXml
6
+ # Explicit wrapper around key/certificate material. See
7
+ # API_CONTRACT.md §5.6.
8
+ #
9
+ # Everything crossing into the native layer is PEM, always -- Key
10
+ # normalizes with OpenSSL::PKey#to_pem / OpenSSL::X509::Certificate#to_pem.
11
+ #
12
+ # A Key never appears in #inspect or #to_s, and its constructor filters
13
+ # any value coming from a Key out of exception messages (see errors.rb).
14
+ class Key
15
+ # The label of each `-----BEGIN <label>-----` line.
16
+ PEM_LABEL = /^-----BEGIN ([A-Z0-9 ]+)-----\s*$/
17
+
18
+ class << self
19
+ # Decides certificate vs key from the PEM block labels, not from
20
+ # substrings anywhere in the text (S-15). A PEM holding both a
21
+ # certificate and a key raises instead of silently keeping one.
22
+ def from_pem(pem, password: nil)
23
+ pem = pem.to_s
24
+ material =
25
+ if certificate_pem?(pem)
26
+ begin
27
+ ::OpenSSL::X509::Certificate.new(pem)
28
+ rescue ::OpenSSL::X509::CertificateError => e
29
+ raise HankoXml::KeyLoadError, "could not parse certificate PEM: #{e.message}"
30
+ end
31
+ else
32
+ begin
33
+ read_pkey(pem, password)
34
+ rescue ::OpenSSL::PKey::PKeyError => e
35
+ raise HankoXml::KeyLoadError,
36
+ "could not parse key PEM: #{e.message}#{encrypted_hint(pem, password)}#{path_hint(pem)}"
37
+ end
38
+ end
39
+
40
+ new(material)
41
+ end
42
+
43
+ # OpenSSL::PKey.read with a nil password falls back to OpenSSL's
44
+ # default callback, which prompts "Enter PEM pass phrase:" on the
45
+ # controlling terminal and blocks the process until someone types.
46
+ # An empty String never prompts: an encrypted key just fails to load.
47
+ def read_pkey(material, password)
48
+ ::OpenSSL::PKey.read(material, password.nil? ? "" : password.to_s)
49
+ end
50
+
51
+ def encrypted_hint(pem, password)
52
+ return "" unless password.nil?
53
+ return "" unless pem.include?("ENCRYPTED")
54
+
55
+ ". The key is encrypted; pass password: (HankoXml::Key.from_pem(pem, password: ...))"
56
+ end
57
+ private :encrypted_hint
58
+
59
+ # A String is always key material, never a filename (S-21): accepting
60
+ # both would make "which one was meant" a guess. The hint never echoes
61
+ # the input, which may be (malformed) key material.
62
+ def path_hint(pem)
63
+ return "" if pem.include?("-----BEGIN")
64
+
65
+ ". A String is read as PEM/DER content, not a file path; to load a file, pass " \
66
+ "Pathname.new(path) or use HankoXml::Key.from_file(path)"
67
+ end
68
+ private :path_hint
69
+
70
+ def certificate_pem?(pem)
71
+ labels = pem.scan(PEM_LABEL).flatten
72
+ certificates = labels.count { |label| label.end_with?("CERTIFICATE") }
73
+ keys = labels.count { |label| label.end_with?("KEY") }
74
+ return false if certificates.zero?
75
+ return true if keys.zero?
76
+
77
+ raise HankoXml::KeyLoadError,
78
+ "PEM contains both a certificate and a key; pass them separately as key: and certificate:"
79
+ end
80
+ private :certificate_pem?
81
+
82
+ def from_der(der)
83
+ material =
84
+ begin
85
+ ::OpenSSL::X509::Certificate.new(der)
86
+ rescue ::OpenSSL::X509::CertificateError
87
+ begin
88
+ read_pkey(der, nil)
89
+ rescue ::OpenSSL::PKey::PKeyError => e
90
+ raise HankoXml::KeyLoadError, "could not parse DER key/certificate: #{e.message}"
91
+ end
92
+ end
93
+
94
+ new(material)
95
+ end
96
+
97
+ def from_file(path, password: nil)
98
+ from_pem(::File.binread(path.respond_to?(:to_path) ? path.to_path : path), password: password)
99
+ end
100
+
101
+ def from_pkcs12(path, password:)
102
+ bytes = ::File.binread(path.respond_to?(:to_path) ? path.to_path : path)
103
+ pkcs12 =
104
+ begin
105
+ ::OpenSSL::PKCS12.new(bytes, password)
106
+ rescue ::OpenSSL::PKCS12::PKCS12Error => e
107
+ raise HankoXml::KeyLoadError, "could not read PKCS#12 bundle: #{e.message}"
108
+ end
109
+
110
+ Bundle.new(
111
+ key: new(pkcs12.key),
112
+ certificate: new(pkcs12.certificate),
113
+ certificates: [new(pkcs12.certificate), *Array(pkcs12.ca_certs).map { |c| new(c) }]
114
+ )
115
+ end
116
+ end
117
+
118
+ # key: / certificate: / certificates: -- coerces any of the accepted
119
+ # input shapes (OpenSSL object, PEM String, path-like, or an existing
120
+ # Key) into a Key. Returns +input+ unchanged if it is already a Key.
121
+ def self.coerce(input, password: nil)
122
+ case input
123
+ when Key
124
+ input
125
+ when ::OpenSSL::PKey::PKey, ::OpenSSL::X509::Certificate
126
+ new(input)
127
+ when String
128
+ from_pem(input, password: password)
129
+ when ->(obj) { obj.respond_to?(:to_path) }
130
+ from_file(input, password: password)
131
+ else
132
+ raise ArgumentError,
133
+ "expected an OpenSSL::PKey::PKey, OpenSSL::X509::Certificate, PEM String, " \
134
+ "path-like object, or HankoXml::Key, got #{input.class}"
135
+ end
136
+ end
137
+
138
+ def initialize(material)
139
+ @material = material
140
+ @disposed = false
141
+ end
142
+
143
+ def certificate?
144
+ @material.is_a?(::OpenSSL::X509::Certificate)
145
+ end
146
+
147
+ # True only for a key that has its private half (S-15). A public-only
148
+ # OpenSSL::PKey::PKey is false.
149
+ def private_key?
150
+ return false unless @material.is_a?(::OpenSSL::PKey::PKey)
151
+ return @material.private? if @material.respond_to?(:private?)
152
+
153
+ # Generic PKey (e.g. Ed25519) has no #private?; only a private key
154
+ # serializes its private half.
155
+ @material.private_to_der
156
+ true
157
+ rescue ::OpenSSL::PKey::PKeyError
158
+ false
159
+ end
160
+
161
+ def to_pem
162
+ raise HankoXml::KeyLoadError, "this key has been disposed" if @disposed
163
+
164
+ @material.to_pem
165
+ end
166
+
167
+ def to_openssl
168
+ raise HankoXml::KeyLoadError, "this key has been disposed" if @disposed
169
+
170
+ @material
171
+ end
172
+
173
+ # Drops this Key's reference to its OpenSSL object and makes #to_pem /
174
+ # #to_openssl raise from then on. It does NOT zeroize anything (S-15):
175
+ # the key bytes live in OpenSSL-owned memory freed whenever Ruby's GC
176
+ # collects the object, and every String #to_pem already returned is the
177
+ # caller's, untouched. Use it to stop later accidental use, not as a
178
+ # memory-hygiene guarantee.
179
+ def dispose!
180
+ return if @disposed
181
+
182
+ @disposed = true
183
+ @material = nil
184
+ end
185
+
186
+ def disposed?
187
+ @disposed
188
+ end
189
+
190
+ def inspect
191
+ "#<#{self.class} #{certificate? ? "certificate" : "key"}=[FILTERED]>"
192
+ end
193
+
194
+ def to_s
195
+ inspect
196
+ end
197
+
198
+ # key:, certificate:, certificates: bundle returned by .from_pkcs12.
199
+ class Bundle
200
+ attr_reader :key, :certificate, :certificates
201
+
202
+ def initialize(key:, certificate:, certificates:)
203
+ @key = key
204
+ @certificate = certificate
205
+ @certificates = certificates
206
+ end
207
+
208
+ def to_h
209
+ { key: key, certificate: certificate, certificates: certificates }
210
+ end
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HankoXml
4
+ # KeyInfo clauses a Signer emits into <ds:KeyInfo>, and the parsed,
5
+ # unauthenticated view a Verification exposes as what the document
6
+ # *claimed*. See API_CONTRACT.md §5.2, §5.4.
7
+ module KeyInfo
8
+ # Recognised clause names, and which <ds:X509Data> child (if any) they
9
+ # map to when building a template.
10
+ CLAUSES = %i[
11
+ x509_certificate
12
+ x509_subject_name
13
+ x509_issuer_serial
14
+ x509_ski
15
+ key_name
16
+ key_value
17
+ ].freeze
18
+
19
+ # A single clause a Signer will emit, e.g. :x509_certificate. +value+ is
20
+ # the text a clause writes itself rather than leaving to xmlsec: only
21
+ # :key_name has one, the name for <KeyName> (#67).
22
+ Clause = Struct.new(:name, :value) do
23
+ def initialize(name, value = nil)
24
+ name = name.to_sym
25
+ unless KeyInfo::CLAUSES.include?(name)
26
+ raise HankoXml::ConfigurationError,
27
+ "unknown key_info clause #{name.inspect}, expected one of #{KeyInfo::CLAUSES.inspect}"
28
+ end
29
+ if !value.nil? && name != :key_name
30
+ raise HankoXml::ConfigurationError, "key_info clause #{name.inspect} takes no value"
31
+ end
32
+
33
+ super(name, value&.to_s&.freeze)
34
+ freeze
35
+ end
36
+ end
37
+
38
+ class << self
39
+ # Normalizes a Signer's key_info: value (an Array of Symbols/Clauses,
40
+ # or a single one) into a frozen Array<Clause>. A Hash names a clause
41
+ # with its value, e.g. `{ key_name: "idp-1" }`.
42
+ def coerce(value)
43
+ value = [value] if value.is_a?(Hash) # Array(hash) would split it into pairs
44
+ Array(value).flat_map do |v|
45
+ case v
46
+ when Clause then v
47
+ when Hash then v.map { |name, clause_value| Clause.new(name, clause_value) }
48
+ else Clause.new(v)
49
+ end
50
+ end.freeze
51
+ end
52
+ end
53
+
54
+ # What a document's <ds:KeyInfo> actually contained, read out but never
55
+ # authenticated (Verification#key_info, API_CONTRACT.md §5.4): its
56
+ # <KeyName> texts and <X509Certificate>s as PEM Strings.
57
+ class Parsed
58
+ attr_reader :key_names, :certificates
59
+
60
+ def initialize(key_names: [], certificates: [])
61
+ @key_names = key_names.freeze
62
+ @certificates = certificates.freeze
63
+ freeze
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HankoXml
4
+ # Guards the one failure mode a bindgen-built extension cannot detect on its
5
+ # own: being loaded against a libxmlsec1 whose structs are laid out
6
+ # differently from the headers the bindings were generated from.
7
+ #
8
+ # +ext/hanko_xml/build.rs+ runs bindgen at compile time, which bakes xmlsec's
9
+ # and libxml2's struct layouts into the compiled extension. Nothing about a
10
+ # later layout change is visible to the dynamic linker: every symbol still
11
+ # resolves, the library still loads, and then a field is read at the offset
12
+ # our headers claimed rather than the one the loaded library actually used.
13
+ # In a library whose entire job is deciding whether a signature can be
14
+ # trusted, that has to be a hard failure at require time rather than a wrong
15
+ # answer at verify time.
16
+ #
17
+ # For a source-installed gem this check passes by construction -- the machine
18
+ # that compiled the extension is the machine loading it. It earns its keep
19
+ # for precompiled platform gems, and for an extension that outlives an OS
20
+ # package upgrade of libxmlsec1.
21
+ module NativeVersion
22
+ # Downgrades a mismatch from a raise to a warning when set to any non-empty
23
+ # value. This is an escape hatch for someone who has established that their
24
+ # two versions really are compatible; it is not a supported configuration,
25
+ # and a wrong call here is silent memory corruption rather than an error.
26
+ SKIP_ENV = "HANKO_XML_SKIP_VERSION_CHECK"
27
+
28
+ class << self
29
+ # Verifies the loaded libxmlsec1 against the compiled-in one.
30
+ #
31
+ # @return [Symbol] +:ok+ when they match exactly, +:abi_compatible+ when
32
+ # they differ only in a way xmlsec considers ABI safe, or +:skipped+
33
+ # when {SKIP_ENV} suppressed the check.
34
+ # @raise [HankoXml::NativeVersionMismatch] when they are not ABI compatible.
35
+ def check!
36
+ return :ok if Native.check_version
37
+
38
+ unless Native.abi_compatible?
39
+ raise NativeVersionMismatch, mismatch_message if skip.nil?
40
+
41
+ warn("hanko_xml: #{mismatch_message}")
42
+ return :skipped
43
+ end
44
+
45
+ :abi_compatible
46
+ end
47
+
48
+ # The xmlsec1, libxml2, and crypto backend versions this extension was
49
+ # compiled against.
50
+ #
51
+ # @return [Hash{Symbol => String}]
52
+ def build_versions
53
+ Native.versions
54
+ end
55
+
56
+ private
57
+
58
+ def skip
59
+ value = ENV[SKIP_ENV]
60
+ value unless value.nil? || value.empty?
61
+ end
62
+
63
+ def mismatch_message
64
+ built = build_versions
65
+ "the loaded libxmlsec1 is not ABI compatible with the one this " \
66
+ "extension was compiled against (xmlsec1 #{built[:xmlsec]}, " \
67
+ "libxml2 #{built[:libxml2]}). Reinstall hanko_xml so the extension " \
68
+ "is rebuilt against the libxmlsec1 currently on this machine " \
69
+ "(`gem pristine hanko_xml`, or `bundle exec rake compile` from a " \
70
+ "checkout). Set #{SKIP_ENV}=1 to downgrade this to a warning if you " \
71
+ "have established the two are compatible."
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../hanko_xml"
4
+
5
+ # Core extensions for Nokogiri::XML::Document and Nokogiri::XML::Node to
6
+ # support direct #sign, #sign!, #signature_valid?, #signatures, and #signed?
7
+ # See API_CONTRACT.md §6.6.
8
+ module HankoXml
9
+ # Mixed into Nokogiri::XML::Document and Nokogiri::XML::Node to provide the
10
+ # signing and verification shorthands described in API_CONTRACT.md §6.6.
11
+ module NokogiriExt
12
+ def sign(**kwargs)
13
+ HankoXml.sign(self, **kwargs)
14
+ end
15
+
16
+ def sign!(**kwargs)
17
+ HankoXml.sign!(self, **kwargs)
18
+ end
19
+
20
+ def signature_valid?(**kwargs)
21
+ HankoXml.valid?(self, **kwargs)
22
+ end
23
+
24
+ def signatures
25
+ xpath(".//ds:Signature", ds: NS["ds"])
26
+ end
27
+
28
+ def signature_present?
29
+ HankoXml.signature_present?(self)
30
+ end
31
+ end
32
+ end
33
+
34
+ HankoXml::Dom.extend_nodes(HankoXml::NokogiriExt)
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HankoXml
4
+ # Where the <Signature> element goes. Frozen value object; Symbols coerce.
5
+ # See API_CONTRACT.md §5.5.
6
+ class Placement
7
+ MODES = %i[append_to_root prepend_to_root append_to prepend_to after before detached custom].freeze
8
+
9
+ attr_reader :mode, :target, :namespaces, :block
10
+
11
+ def initialize(mode, target: nil, namespaces: {}, &block)
12
+ mode = mode.to_sym
13
+ raise HankoXml::ConfigurationError, "unknown placement mode #{mode.inspect}" unless MODES.include?(mode)
14
+
15
+ @mode = mode
16
+ @target = target
17
+ @namespaces = namespaces
18
+ @block = block
19
+ freeze
20
+ end
21
+
22
+ def detached?
23
+ mode == :detached
24
+ end
25
+
26
+ class << self
27
+ def append_to_root
28
+ new(:append_to_root)
29
+ end
30
+
31
+ def prepend_to_root
32
+ new(:prepend_to_root)
33
+ end
34
+
35
+ def append_to(node_or_xpath, namespaces: {})
36
+ new(:append_to, target: node_or_xpath, namespaces: namespaces)
37
+ end
38
+
39
+ def prepend_to(node_or_xpath, namespaces: {})
40
+ new(:prepend_to, target: node_or_xpath, namespaces: namespaces)
41
+ end
42
+
43
+ def after(node_or_xpath, namespaces: {})
44
+ new(:after, target: node_or_xpath, namespaces: namespaces)
45
+ end
46
+
47
+ def before(node_or_xpath, namespaces: {})
48
+ new(:before, target: node_or_xpath, namespaces: namespaces)
49
+ end
50
+
51
+ def detached
52
+ new(:detached)
53
+ end
54
+
55
+ def custom(&block)
56
+ new(:custom, &block)
57
+ end
58
+
59
+ # Coerces a Placement, a recognised Symbol (:append_to_root,
60
+ # :prepend_to_root, :detached), or leaves nil as-is.
61
+ def coerce(value)
62
+ case value
63
+ when Placement then value
64
+ when Symbol then new(value)
65
+ when nil then nil
66
+ else
67
+ raise ArgumentError, "expected a HankoXml::Placement or Symbol, got #{value.class}"
68
+ end
69
+ end
70
+ end
71
+
72
+ # Resolves +target+ to exactly one Nokogiri::XML::Node within +doc+.
73
+ # The XPath sees HankoXml::NS, then the Signer's +namespaces:+, then
74
+ # Placement#namespaces, each merged over the last (#96). Raises
75
+ # HankoXml::PlacementError if the XPath matches zero or more than one
76
+ # node.
77
+ def resolve(doc, namespaces: {})
78
+ return doc.root if target.nil?
79
+ return target if HankoXml::Dom.node?(target)
80
+
81
+ ns = HankoXml::NS.merge((namespaces || {}).transform_keys(&:to_s)).merge(self.namespaces.transform_keys(&:to_s))
82
+ matches = HankoXml::Dom.xpath(doc, target, ns) do
83
+ raise HankoXml::PlacementError,
84
+ "placement XPath #{target.inspect} is not valid XPath; bind every prefix it uses in namespaces:"
85
+ end
86
+
87
+ if matches.empty?
88
+ raise HankoXml::PlacementError, "placement XPath #{target.inspect} matched no nodes"
89
+ elsif matches.size > 1
90
+ raise HankoXml::PlacementError,
91
+ "placement XPath #{target.inspect} matched #{matches.size} nodes, expected exactly one"
92
+ end
93
+
94
+ matches.first
95
+ end
96
+
97
+ # Inserts +signature_node+ into +doc+ per this placement's mode.
98
+ # +namespaces+ are the Signer's, see #resolve. Returns +signature_node+.
99
+ def insert(doc, signature_node, namespaces: {})
100
+ case mode
101
+ when :append_to_root
102
+ doc.root.add_child(signature_node)
103
+ when :prepend_to_root
104
+ if doc.root.children.first
105
+ doc.root.children.first.add_previous_sibling(signature_node)
106
+ else
107
+ doc.root.add_child(signature_node)
108
+ end
109
+ when :append_to
110
+ resolve(doc, namespaces: namespaces).add_child(signature_node)
111
+ when :prepend_to
112
+ node = resolve(doc, namespaces: namespaces)
113
+ node.children.first ? node.children.first.add_previous_sibling(signature_node) : node.add_child(signature_node)
114
+ when :after
115
+ resolve(doc, namespaces: namespaces).add_next_sibling(signature_node)
116
+ when :before
117
+ resolve(doc, namespaces: namespaces).add_previous_sibling(signature_node)
118
+ when :detached
119
+ # Caller does not insert; Signer#template returns the built node.
120
+ when :custom
121
+ block.call(doc, signature_node)
122
+ end
123
+
124
+ signature_node
125
+ end
126
+ end
127
+ end