moxml 0.1.25 → 0.1.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/release.yml +9 -0
- data/README.adoc +64 -0
- data/docs/signature/algorithms.md +108 -0
- data/docs/signature/architecture.md +148 -0
- data/docs/signature/c14n.md +110 -0
- data/docs/signature/examples.md +109 -0
- data/docs/signature/flows.md +132 -0
- data/docs/signature/quick-reference.md +115 -0
- data/docs/signature/security.md +143 -0
- data/examples/signature/auto_key_extraction.rb +24 -0
- data/examples/signature/enveloped_rsa.rb +45 -0
- data/examples/signature/hmac.rb +33 -0
- data/lib/moxml/c14n/attribute_handler.rb +71 -0
- data/lib/moxml/c14n/character_encoder.rb +29 -0
- data/lib/moxml/c14n/data_model.rb +145 -0
- data/lib/moxml/c14n/exclusive.rb +188 -0
- data/lib/moxml/c14n/inclusive_10.rb +42 -0
- data/lib/moxml/c14n/inclusive_11.rb +21 -0
- data/lib/moxml/c14n/namespace_context.rb +68 -0
- data/lib/moxml/c14n/namespace_handler.rb +71 -0
- data/lib/moxml/c14n/node.rb +42 -0
- data/lib/moxml/c14n/nodes/attribute_node.rb +50 -0
- data/lib/moxml/c14n/nodes/comment_node.rb +29 -0
- data/lib/moxml/c14n/nodes/element_node.rb +62 -0
- data/lib/moxml/c14n/nodes/namespace_node.rb +41 -0
- data/lib/moxml/c14n/nodes/processing_instruction_node.rb +30 -0
- data/lib/moxml/c14n/nodes/root_node.rb +22 -0
- data/lib/moxml/c14n/nodes/text_node.rb +29 -0
- data/lib/moxml/c14n/nodes.rb +17 -0
- data/lib/moxml/c14n/processor.rb +118 -0
- data/lib/moxml/c14n/writer.rb +121 -0
- data/lib/moxml/c14n/xml_base_handler.rb +140 -0
- data/lib/moxml/c14n.rb +96 -0
- data/lib/moxml/signature/algorithms/base64_transform.rb +40 -0
- data/lib/moxml/signature/algorithms/canonicalization_base.rb +87 -0
- data/lib/moxml/signature/algorithms/digest_base.rb +51 -0
- data/lib/moxml/signature/algorithms/dsa_sha.rb +107 -0
- data/lib/moxml/signature/algorithms/ecdsa_sha.rb +125 -0
- data/lib/moxml/signature/algorithms/enveloped_signature_transform.rb +79 -0
- data/lib/moxml/signature/algorithms/exc_c14n_10.rb +23 -0
- data/lib/moxml/signature/algorithms/hmac_sha.rb +117 -0
- data/lib/moxml/signature/algorithms/inclusive_c14n_10.rb +21 -0
- data/lib/moxml/signature/algorithms/inclusive_c14n_11.rb +23 -0
- data/lib/moxml/signature/algorithms/rsa_pkcs1_sha.rb +56 -0
- data/lib/moxml/signature/algorithms/sha1.rb +13 -0
- data/lib/moxml/signature/algorithms/sha224.rb +13 -0
- data/lib/moxml/signature/algorithms/sha256.rb +13 -0
- data/lib/moxml/signature/algorithms/sha384.rb +13 -0
- data/lib/moxml/signature/algorithms/sha512.rb +13 -0
- data/lib/moxml/signature/algorithms/signature_method_base.rb +58 -0
- data/lib/moxml/signature/algorithms/transform_base.rb +40 -0
- data/lib/moxml/signature/algorithms.rb +113 -0
- data/lib/moxml/signature/errors.rb +99 -0
- data/lib/moxml/signature/key_extractor.rb +172 -0
- data/lib/moxml/signature/model/algorithm_method.rb +18 -0
- data/lib/moxml/signature/model/digest_method.rb +15 -0
- data/lib/moxml/signature/model/key/dsa_key_value.rb +29 -0
- data/lib/moxml/signature/model/key/ec_key_value.rb +22 -0
- data/lib/moxml/signature/model/key/rsa_key_value.rb +19 -0
- data/lib/moxml/signature/model/key/x509_data.rb +32 -0
- data/lib/moxml/signature/model/key/x509_digest.rb +19 -0
- data/lib/moxml/signature/model/key/x509_issuer_serial.rb +19 -0
- data/lib/moxml/signature/model/key_info.rb +21 -0
- data/lib/moxml/signature/model/key_value.rb +20 -0
- data/lib/moxml/signature/model/object_element.rb +18 -0
- data/lib/moxml/signature/model/reference.rb +22 -0
- data/lib/moxml/signature/model/signature.rb +21 -0
- data/lib/moxml/signature/model/signature_value.rb +16 -0
- data/lib/moxml/signature/model/signed_info.rb +20 -0
- data/lib/moxml/signature/model/transform.rb +16 -0
- data/lib/moxml/signature/model/transforms.rb +36 -0
- data/lib/moxml/signature/model.rb +35 -0
- data/lib/moxml/signature/parser.rb +270 -0
- data/lib/moxml/signature/reference_resolver.rb +79 -0
- data/lib/moxml/signature/reference_result.rb +22 -0
- data/lib/moxml/signature/serializer.rb +163 -0
- data/lib/moxml/signature/signer.rb +73 -0
- data/lib/moxml/signature/single_verification_result.rb +31 -0
- data/lib/moxml/signature/transform_pipeline.rb +106 -0
- data/lib/moxml/signature/verification_result.rb +27 -0
- data/lib/moxml/signature/verifier.rb +129 -0
- data/lib/moxml/signature.rb +94 -0
- data/lib/moxml/version.rb +1 -1
- data/lib/moxml.rb +2 -0
- data/reference-docs/w3c-xmldsig-bestpractices.md +216 -0
- data/reference-docs/w3c-xmldsig-core.md +400 -0
- data/spec/fixtures/xmldsig/keys/rsa_private.pem +28 -0
- data/spec/fixtures/xmldsig/keys/rsa_public.pem +9 -0
- data/spec/fixtures/xmldsig/keys/rsa_ref.pem +15 -0
- data/spec/fixtures/xmldsig/keys/rsa_ref.pub +6 -0
- data/spec/fixtures/xmldsig/sign2-doc.xml +6 -0
- data/spec/fixtures/xmldsig/sign2-result.xml +25 -0
- data/spec/fixtures/xmldsig/sign3-result.xml +39 -0
- data/spec/moxml/c14n/api_spec.rb +117 -0
- data/spec/moxml/c14n/c14n_spec.rb +88 -0
- data/spec/moxml/c14n/comments_pis_spec.rb +65 -0
- data/spec/moxml/c14n/inclusive10_spec.rb +60 -0
- data/spec/moxml/c14n/namespace_edge_cases_spec.rb +88 -0
- data/spec/moxml/c14n/xml_attributes_spec.rb +54 -0
- data/spec/moxml/signature/algorithms/base64_transform_spec.rb +26 -0
- data/spec/moxml/signature/algorithms/digest_base_spec.rb +72 -0
- data/spec/moxml/signature/algorithms/dsa_sha_spec.rb +46 -0
- data/spec/moxml/signature/algorithms/ecdsa_sha_spec.rb +72 -0
- data/spec/moxml/signature/algorithms/enveloped_signature_transform_spec.rb +40 -0
- data/spec/moxml/signature/algorithms/hmac_sha_spec.rb +71 -0
- data/spec/moxml/signature/algorithms/rsa_pkcs1_sha_spec.rb +55 -0
- data/spec/moxml/signature/algorithms_spec.rb +76 -0
- data/spec/moxml/signature/cross_verify_spec.rb +64 -0
- data/spec/moxml/signature/edge_cases_spec.rb +284 -0
- data/spec/moxml/signature/fixtures_spec.rb +80 -0
- data/spec/moxml/signature/key_extractor_spec.rb +96 -0
- data/spec/moxml/signature/model_spec.rb +54 -0
- data/spec/moxml/signature/round_trip_spec.rb +113 -0
- metadata +111 -2
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
|
|
5
|
+
module Moxml
|
|
6
|
+
module Signature
|
|
7
|
+
module Algorithms
|
|
8
|
+
# Base64 decode transform (W3C §6.6.2).
|
|
9
|
+
#
|
|
10
|
+
# Input: octets or nodeset. For nodeset, logically applies self::text(),
|
|
11
|
+
# sorts nodes by document order, concatenates string values, then decodes.
|
|
12
|
+
# Output: octets.
|
|
13
|
+
class Base64Transform < TransformBase
|
|
14
|
+
identifier "http://www.w3.org/2000/09/xmldsig#base64"
|
|
15
|
+
|
|
16
|
+
def self.input_type; :octets; end
|
|
17
|
+
def self.output_type; :octets; end
|
|
18
|
+
|
|
19
|
+
def transform(input)
|
|
20
|
+
text = input.is_a?(Array) ? input.map { |n| text_of(n) }.join : input.to_s
|
|
21
|
+
stripped = text.gsub(/\s+/, "")
|
|
22
|
+
Base64.strict_decode64(stripped)
|
|
23
|
+
rescue ArgumentError => e
|
|
24
|
+
raise TransformError.new(
|
|
25
|
+
"base64 decode failed: #{e.message}",
|
|
26
|
+
algorithm: self.class.identifier_uri,
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def text_of(node)
|
|
33
|
+
return node.text if node.is_a?(::Moxml::Node)
|
|
34
|
+
|
|
35
|
+
node.to_s
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module Moxml
|
|
6
|
+
module Signature
|
|
7
|
+
module Algorithms
|
|
8
|
+
# Base class for canonicalization algorithms.
|
|
9
|
+
#
|
|
10
|
+
# Subclasses declare `identifier "http://..."` and implement #engine
|
|
11
|
+
# (returning a Moxml::C14n::* walker). Canonicalizers operate on a
|
|
12
|
+
# Moxml::Node subtree and return UTF-8 octets.
|
|
13
|
+
#
|
|
14
|
+
# Canonicalization algorithms can also be used as transforms per
|
|
15
|
+
# spec §6.6.1. The base class provides the #transform method that
|
|
16
|
+
# adapts the canonicalize interface to the transform pipeline.
|
|
17
|
+
class CanonicalizationBase
|
|
18
|
+
class << self
|
|
19
|
+
# Canonicalization presents the transform interface (spec §6.6.1).
|
|
20
|
+
# Input: octet stream or node-set. Output: octet stream.
|
|
21
|
+
def input_type; :nodeset; end
|
|
22
|
+
|
|
23
|
+
def output_type; :octets; end
|
|
24
|
+
|
|
25
|
+
def identifier(uri)
|
|
26
|
+
Algorithms.register(:canonicalization, uri, self)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def for_uri(uri, **opts)
|
|
30
|
+
new(identifier_uri: uri, **opts)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
attr_reader :identifier_uri, :with_comments, :inclusive_namespaces,
|
|
35
|
+
:context
|
|
36
|
+
|
|
37
|
+
# `context:` is required when this algorithm is used as a transform
|
|
38
|
+
# so that octet-stream input can be parsed with the same adapter
|
|
39
|
+
# the caller used for the rest of the document.
|
|
40
|
+
def initialize(identifier_uri: nil, with_comments: nil,
|
|
41
|
+
inclusive_namespaces: [], context: nil, **_unused)
|
|
42
|
+
@identifier_uri = identifier_uri
|
|
43
|
+
@with_comments = with_comments.nil? ? uri_has_comments?(identifier_uri) : with_comments
|
|
44
|
+
@inclusive_namespaces = inclusive_namespaces || []
|
|
45
|
+
@context = context
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def canonicalize(node)
|
|
49
|
+
engine.canonicalize(
|
|
50
|
+
node,
|
|
51
|
+
with_comments: @with_comments,
|
|
52
|
+
inclusive_namespaces: @inclusive_namespaces,
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Adapt to the transform interface (spec §6.6.1). Octet-stream
|
|
57
|
+
# input is parsed using the same Moxml::Context the caller used;
|
|
58
|
+
# this preserves the byte-exact invariant across adapters.
|
|
59
|
+
def transform(input)
|
|
60
|
+
return canonicalize(input) if input.is_a?(::Moxml::Node)
|
|
61
|
+
|
|
62
|
+
if context.nil?
|
|
63
|
+
raise TransformError,
|
|
64
|
+
"canonicalization transform requires a context to parse " \
|
|
65
|
+
"octet-stream input; none was provided"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
parsed = context.parse(input.to_s)
|
|
69
|
+
canonicalize(parsed.root)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def uri_has_comments?(uri)
|
|
75
|
+
return false if uri.nil?
|
|
76
|
+
|
|
77
|
+
uri.end_with?("#WithComments")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def engine
|
|
81
|
+
raise NotImplementedError,
|
|
82
|
+
"#{self.class} must implement #engine"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "base64"
|
|
5
|
+
|
|
6
|
+
module Moxml
|
|
7
|
+
module Signature
|
|
8
|
+
module Algorithms
|
|
9
|
+
# Base class for message digest algorithms (SHA family).
|
|
10
|
+
#
|
|
11
|
+
# Subclasses MUST declare:
|
|
12
|
+
# - `identifier "http://..."` to register
|
|
13
|
+
# - either `digest_bits N` and `openssl_digest_name "SHA256"` etc.,
|
|
14
|
+
# OR override `#compute_digest`.
|
|
15
|
+
class DigestBase
|
|
16
|
+
class << self
|
|
17
|
+
attr_reader :identifier_uri, :digest_size_bits
|
|
18
|
+
|
|
19
|
+
def identifier(uri)
|
|
20
|
+
@identifier_uri = uri
|
|
21
|
+
Algorithms.register(:digest, uri, self)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def digest_bits(bits)
|
|
25
|
+
@digest_size_bits = bits
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def openssl_digest_name(name)
|
|
29
|
+
define_method(:compute_digest) do |data|
|
|
30
|
+
OpenSSL::Digest.digest(name, data)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def digest(data)
|
|
36
|
+
compute_digest(data.to_s)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def digest_base64(data)
|
|
40
|
+
Base64.strict_encode64(digest(data))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def compute_digest(_data)
|
|
44
|
+
raise NotImplementedError,
|
|
45
|
+
"#{self.class} must implement #compute_digest " \
|
|
46
|
+
"or declare openssl_digest_name"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module Moxml
|
|
6
|
+
module Signature
|
|
7
|
+
module Algorithms
|
|
8
|
+
# DSA signature methods per FIPS 186-3.
|
|
9
|
+
#
|
|
10
|
+
# Two URIs:
|
|
11
|
+
# - SHA1 (1024-bit, q=160): wire format r‖s with 20-byte halves
|
|
12
|
+
# - SHA256 (2048-bit, q=256): wire format r‖s with N-byte halves
|
|
13
|
+
# where N = byte length of q (32 for SHA-256 case)
|
|
14
|
+
#
|
|
15
|
+
# OpenSSL returns DER; we convert to raw r‖s.
|
|
16
|
+
class DsaSha < SignatureMethodBase
|
|
17
|
+
PAIRINGS = {
|
|
18
|
+
"http://www.w3.org/2000/09/xmldsig#dsa-sha1" => "SHA1",
|
|
19
|
+
"http://www.w3.org/2009/xmldsig11#dsa-sha256" => "SHA256",
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
PAIRINGS.each_key { |uri| identifier uri }
|
|
23
|
+
|
|
24
|
+
def initialize(identifier_uri:, parameters: nil)
|
|
25
|
+
super(nil)
|
|
26
|
+
@identifier_uri = identifier_uri
|
|
27
|
+
@parameters = parameters
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def compute_signature(data, key)
|
|
31
|
+
dsa = coerce_signing_key(key)
|
|
32
|
+
digest_name = self.class::PAIRINGS.fetch(@identifier_uri)
|
|
33
|
+
der_signature = dsa.sign(digest_name, data)
|
|
34
|
+
der_to_raw(der_signature, coordinate_bytes_for(dsa))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def verify_signature(data, key, signature)
|
|
38
|
+
dsa = coerce_verify_key(key)
|
|
39
|
+
digest_name = self.class::PAIRINGS.fetch(@identifier_uri)
|
|
40
|
+
n_bytes = coordinate_bytes_for(dsa)
|
|
41
|
+
der_signature = raw_to_der(signature, n_bytes)
|
|
42
|
+
dsa.verify(digest_name, der_signature, data)
|
|
43
|
+
rescue ArgumentError
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def coerce_signing_key(key)
|
|
50
|
+
case key
|
|
51
|
+
when OpenSSL::PKey::DSA
|
|
52
|
+
key
|
|
53
|
+
else
|
|
54
|
+
raise SignatureKeyError,
|
|
55
|
+
"DSA signature method requires OpenSSL::PKey::DSA, " \
|
|
56
|
+
"got #{key.class}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def coerce_verify_key(key)
|
|
61
|
+
coerce_signing_key(key)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def coordinate_bytes_for(dsa)
|
|
65
|
+
# q is the subgroup order. byte length of q.
|
|
66
|
+
q_bits = dsa.q.num_bits
|
|
67
|
+
(q_bits + 7) / 8
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def der_to_raw(der, n_bytes)
|
|
71
|
+
seq = OpenSSL::ASN1.decode(der)
|
|
72
|
+
r = seq.value[0].value
|
|
73
|
+
s = seq.value[1].value
|
|
74
|
+
i2osp(r, n_bytes) + i2osp(s, n_bytes)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def raw_to_der(raw, n_bytes)
|
|
78
|
+
raise ArgumentError, "raw signature has wrong size" if raw.bytesize != (2 * n_bytes)
|
|
79
|
+
|
|
80
|
+
r = raw.byteslice(0, n_bytes)
|
|
81
|
+
s = raw.byteslice(n_bytes, n_bytes)
|
|
82
|
+
seq = OpenSSL::ASN1::Sequence.new([
|
|
83
|
+
OpenSSL::ASN1::Integer.new(asn1_integer_value(r)),
|
|
84
|
+
OpenSSL::ASN1::Integer.new(asn1_integer_value(s)),
|
|
85
|
+
])
|
|
86
|
+
seq.to_der
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def i2osp(value, length)
|
|
90
|
+
n = value.to_i
|
|
91
|
+
raise ArgumentError, "I2OSP: integer too large" if n >= (1 << (8 * length))
|
|
92
|
+
|
|
93
|
+
bytes = Array.new(length, 0)
|
|
94
|
+
(length - 1).downto(0) do |i|
|
|
95
|
+
bytes[i] = n & 0xff
|
|
96
|
+
n >>= 8
|
|
97
|
+
end
|
|
98
|
+
bytes.pack("C*")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def asn1_integer_value(octets)
|
|
102
|
+
octets.bytes.reduce(0) { |acc, byte| (acc << 8) | byte }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module Moxml
|
|
6
|
+
module Signature
|
|
7
|
+
module Algorithms
|
|
8
|
+
# ECDSA signature methods per FIPS 186-3, bound to a digest.
|
|
9
|
+
#
|
|
10
|
+
# Spec §6.4.3 requires support for ECDSAwithSHA256 over the P-256
|
|
11
|
+
# curve. Recommended: P-384, P-521.
|
|
12
|
+
#
|
|
13
|
+
# The wire format is base64(I2OSP(r, n_bytes) || I2OSP(s, n_bytes))
|
|
14
|
+
# where n_bytes is the byte length of the base point order
|
|
15
|
+
# (32 for P-256, 48 for P-384, 66 for P-521).
|
|
16
|
+
#
|
|
17
|
+
# OpenSSL returns a DER-encoded ASN.1 sequence of (r, s). We convert
|
|
18
|
+
# to the raw r||s form on sign, and back from raw to DER on verify.
|
|
19
|
+
class EcdsaSha < SignatureMethodBase
|
|
20
|
+
PAIRINGS = {
|
|
21
|
+
"http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha1" => "SHA1",
|
|
22
|
+
"http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha224" => "SHA224",
|
|
23
|
+
"http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256" => "SHA256",
|
|
24
|
+
"http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384" => "SHA384",
|
|
25
|
+
"http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512" => "SHA512",
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
# Map OpenSSL curve name → byte length of base point order.
|
|
29
|
+
CURVE_ORDER_BYTES = {
|
|
30
|
+
"prime256v1" => 32, # P-256
|
|
31
|
+
"secp384r1" => 48, # P-384
|
|
32
|
+
"secp521r1" => 66, # P-521 (ceil(521/8) = 66)
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
PAIRINGS.each_key { |uri| identifier uri }
|
|
36
|
+
|
|
37
|
+
def initialize(identifier_uri:, parameters: nil)
|
|
38
|
+
super(nil)
|
|
39
|
+
@identifier_uri = identifier_uri
|
|
40
|
+
@parameters = parameters
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def compute_signature(data, key)
|
|
44
|
+
ec_key = coerce_key(key)
|
|
45
|
+
digest_name = self.class::PAIRINGS.fetch(@identifier_uri)
|
|
46
|
+
der_signature = ec_key.sign(digest_name, data)
|
|
47
|
+
der_to_raw(der_signature, coordinate_bytes_for(ec_key))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def verify_signature(data, key, signature)
|
|
51
|
+
ec_key = coerce_key(key)
|
|
52
|
+
digest_name = self.class::PAIRINGS.fetch(@identifier_uri)
|
|
53
|
+
n_bytes = coordinate_bytes_for(ec_key)
|
|
54
|
+
der_signature = raw_to_der(signature, n_bytes)
|
|
55
|
+
ec_key.verify(digest_name, der_signature, data)
|
|
56
|
+
rescue ArgumentError
|
|
57
|
+
false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def coerce_key(key)
|
|
63
|
+
case key
|
|
64
|
+
when OpenSSL::PKey::EC then key
|
|
65
|
+
when OpenSSL::PKey::EC::Point
|
|
66
|
+
raise SignatureKeyError,
|
|
67
|
+
"ECDSA requires an EC key, not a bare Point"
|
|
68
|
+
else
|
|
69
|
+
raise SignatureKeyError,
|
|
70
|
+
"ECDSA signature method requires OpenSSL::PKey::EC, " \
|
|
71
|
+
"got #{key.class}"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def coordinate_bytes_for(ec_key)
|
|
76
|
+
curve_name = ec_key.group.curve_name
|
|
77
|
+
self.class::CURVE_ORDER_BYTES.fetch(curve_name) do
|
|
78
|
+
raise SignatureError,
|
|
79
|
+
"ECDSA curve #{curve_name.inspect} not supported; " \
|
|
80
|
+
"add it to EcdsaSha::CURVE_ORDER_BYTES"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Convert ASN.1 DER sequence of two INTEGERs to raw r‖s octets.
|
|
85
|
+
def der_to_raw(der, n_bytes)
|
|
86
|
+
seq = OpenSSL::ASN1.decode(der)
|
|
87
|
+
r = seq.value[0].value
|
|
88
|
+
s = seq.value[1].value
|
|
89
|
+
i2osp(r, n_bytes) + i2osp(s, n_bytes)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Convert raw r‖s octets back to ASN.1 DER.
|
|
93
|
+
def raw_to_der(raw, n_bytes)
|
|
94
|
+
raise ArgumentError, "raw signature has wrong size" if raw.bytesize != (2 * n_bytes)
|
|
95
|
+
|
|
96
|
+
r = raw.byteslice(0, n_bytes)
|
|
97
|
+
s = raw.byteslice(n_bytes, n_bytes)
|
|
98
|
+
seq = OpenSSL::ASN1::Sequence.new([
|
|
99
|
+
OpenSSL::ASN1::Integer.new(asn1_integer_value(r)),
|
|
100
|
+
OpenSSL::ASN1::Integer.new(asn1_integer_value(s)),
|
|
101
|
+
])
|
|
102
|
+
seq.to_der
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Integer to octet stream (I2OSP), minimal length parameter.
|
|
106
|
+
def i2osp(value, length)
|
|
107
|
+
n = value.to_i
|
|
108
|
+
raise ArgumentError, "I2OSP: integer too large" if n >= (1 << (8 * length))
|
|
109
|
+
|
|
110
|
+
bytes = Array.new(length, 0)
|
|
111
|
+
(length - 1).downto(0) do |i|
|
|
112
|
+
bytes[i] = n & 0xff
|
|
113
|
+
n >>= 8
|
|
114
|
+
end
|
|
115
|
+
bytes.pack("C*")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Interpret an octet string as a positive integer (OS2IP).
|
|
119
|
+
def asn1_integer_value(octets)
|
|
120
|
+
octets.bytes.reduce(0) { |acc, byte| (acc << 8) | byte }
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moxml
|
|
4
|
+
module Signature
|
|
5
|
+
module Algorithms
|
|
6
|
+
# Enveloped Signature Transform (W3C §6.6.4).
|
|
7
|
+
#
|
|
8
|
+
# Removes the containing ds:Signature element from the node-set so the
|
|
9
|
+
# signature does not include itself in its own digest calculation.
|
|
10
|
+
#
|
|
11
|
+
# Semantics during signing: `signature_element` is nil (the Signature
|
|
12
|
+
# has not been attached to the document yet), so the transform is a
|
|
13
|
+
# no-op — the document contains no Signature to exclude.
|
|
14
|
+
#
|
|
15
|
+
# Semantics during verification: `signature_element` is the Signature
|
|
16
|
+
# element being verified. The transform detaches it (and its
|
|
17
|
+
# descendants) from a deep copy of the input so the canonicalizer
|
|
18
|
+
# walks a tree without the Signature.
|
|
19
|
+
class EnvelopedSignatureTransform < TransformBase
|
|
20
|
+
identifier "http://www.w3.org/2000/09/xmldsig#enveloped-signature"
|
|
21
|
+
|
|
22
|
+
def self.input_type; :nodeset; end
|
|
23
|
+
def self.output_type; :nodeset; end
|
|
24
|
+
|
|
25
|
+
def transform(input)
|
|
26
|
+
return input if @signature_element.nil?
|
|
27
|
+
|
|
28
|
+
# If the signature is not within the input subtree, no-op.
|
|
29
|
+
return input unless signature_within?(input)
|
|
30
|
+
|
|
31
|
+
working_copy = deep_copy(input)
|
|
32
|
+
remove_signature_within(working_copy)
|
|
33
|
+
working_copy
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def signature_within?(input)
|
|
39
|
+
!find_signature_element(input).nil?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def remove_signature_within(node)
|
|
43
|
+
sig = find_signature_element(node)
|
|
44
|
+
sig&.remove
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def find_signature_element(node)
|
|
48
|
+
return nil unless node
|
|
49
|
+
return node if signature_element?(node)
|
|
50
|
+
|
|
51
|
+
children = node.children if node.is_a?(::Moxml::Node)
|
|
52
|
+
children&.each do |child|
|
|
53
|
+
found = find_signature_element(child)
|
|
54
|
+
return found if found
|
|
55
|
+
end
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def signature_element?(node)
|
|
60
|
+
node.is_a?(::Moxml::Element) &&
|
|
61
|
+
node.name == "Signature" &&
|
|
62
|
+
node.namespace_uri == DSIG_NS
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def deep_copy(input)
|
|
66
|
+
case input
|
|
67
|
+
when ::Moxml::Document
|
|
68
|
+
@context.parse(input.root.to_xml(indent: 0))
|
|
69
|
+
when ::Moxml::Element
|
|
70
|
+
wrapper = @context.parse("<__wrap__>#{input.to_xml(indent: 0)}</__wrap__>")
|
|
71
|
+
wrapper.root.children.first
|
|
72
|
+
else
|
|
73
|
+
input
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moxml
|
|
4
|
+
module Signature
|
|
5
|
+
module Algorithms
|
|
6
|
+
# Exclusive XML Canonicalization 1.0 (https://www.w3.org/TR/xml-exc-c14n/)
|
|
7
|
+
#
|
|
8
|
+
# Two registered URIs: one omits comments, one includes comments.
|
|
9
|
+
# The with_comments variant is detected from the URI suffix at
|
|
10
|
+
# instantiation time (see CanonicalizationBase#uri_has_comments?).
|
|
11
|
+
class ExcC14n10 < CanonicalizationBase
|
|
12
|
+
identifier "http://www.w3.org/2001/10/xml-exc-c14n#"
|
|
13
|
+
identifier "http://www.w3.org/2001/10/xml-exc-c14n#WithComments"
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def engine
|
|
18
|
+
::Moxml::C14n::Exclusive.new
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module Moxml
|
|
6
|
+
module Signature
|
|
7
|
+
module Algorithms
|
|
8
|
+
# HMAC per RFC 2104, bound to a digest.
|
|
9
|
+
#
|
|
10
|
+
# Per W3C XML Signature §4.4.2 and §6.3.1, if `HMACOutputLength` is
|
|
11
|
+
# specified, the output is truncated to that many bits and the
|
|
12
|
+
# truncation length MUST be at least max(hash_bits / 2, 80) bits.
|
|
13
|
+
class HmacSha < SignatureMethodBase
|
|
14
|
+
PAIRINGS = {
|
|
15
|
+
"http://www.w3.org/2000/09/xmldsig#hmac-sha1" => "SHA1",
|
|
16
|
+
"http://www.w3.org/2001/04/xmldsig-more#hmac-sha224" => "SHA224",
|
|
17
|
+
"http://www.w3.org/2001/04/xmldsig-more#hmac-sha256" => "SHA256",
|
|
18
|
+
"http://www.w3.org/2001/04/xmldsig-more#hmac-sha384" => "SHA384",
|
|
19
|
+
"http://www.w3.org/2001/04/xmldsig-more#hmac-sha512" => "SHA512",
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
HASH_BITS = {
|
|
23
|
+
"SHA1" => 160,
|
|
24
|
+
"SHA224" => 224,
|
|
25
|
+
"SHA256" => 256,
|
|
26
|
+
"SHA384" => 384,
|
|
27
|
+
"SHA512" => 512,
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
MIN_TRUNCATION_BITS = 80
|
|
31
|
+
|
|
32
|
+
PAIRINGS.each_key { |uri| identifier uri }
|
|
33
|
+
|
|
34
|
+
# parameters: optional { hmac_output_length: Integer } (bits, multiple of 8)
|
|
35
|
+
def initialize(identifier_uri:, parameters: nil)
|
|
36
|
+
super(parameters)
|
|
37
|
+
@identifier_uri = identifier_uri
|
|
38
|
+
@truncation_bits = parameters&.dig(:hmac_output_length)
|
|
39
|
+
validate_truncation! if @truncation_bits
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def compute_signature(data, key)
|
|
43
|
+
secret = coerce_key(key)
|
|
44
|
+
full = OpenSSL::HMAC.digest(digest_name, secret, data)
|
|
45
|
+
truncate(full)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def verify_signature(data, key, signature)
|
|
49
|
+
expected = compute_signature(data, key)
|
|
50
|
+
fixed_comparison(expected, signature)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def digest_name
|
|
56
|
+
PAIRINGS.fetch(@identifier_uri)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def hash_bits
|
|
60
|
+
HASH_BITS.fetch(digest_name)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def truncate(full_mac)
|
|
64
|
+
return full_mac unless @truncation_bits
|
|
65
|
+
|
|
66
|
+
unless (@truncation_bits % 8).zero?
|
|
67
|
+
raise SignatureError,
|
|
68
|
+
"HMACOutputLength (#{@truncation_bits}) must be a " \
|
|
69
|
+
"multiple of 8"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
min = [hash_bits / 2, MIN_TRUNCATION_BITS].max
|
|
73
|
+
if @truncation_bits < min
|
|
74
|
+
raise SignatureError,
|
|
75
|
+
"HMACOutputLength (#{@truncation_bits}) below minimum " \
|
|
76
|
+
"of #{min} for #{digest_name}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
full_mac.byteslice(0, @truncation_bits / 8)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def validate_truncation!
|
|
83
|
+
unless (@truncation_bits % 8).zero?
|
|
84
|
+
raise SignatureError,
|
|
85
|
+
"HMACOutputLength (#{@truncation_bits}) must be a " \
|
|
86
|
+
"multiple of 8"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
min = [hash_bits / 2, MIN_TRUNCATION_BITS].max
|
|
90
|
+
return if @truncation_bits >= min
|
|
91
|
+
|
|
92
|
+
raise SignatureError,
|
|
93
|
+
"HMACOutputLength (#{@truncation_bits}) below minimum " \
|
|
94
|
+
"of #{min} for #{digest_name}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def coerce_key(key)
|
|
98
|
+
case key
|
|
99
|
+
when String then key
|
|
100
|
+
else
|
|
101
|
+
raise SignatureKeyError,
|
|
102
|
+
"HMAC signature method requires a String secret, " \
|
|
103
|
+
"got #{key.class}"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def fixed_comparison(expected, actual)
|
|
108
|
+
return false unless expected.bytesize == actual.bytesize
|
|
109
|
+
|
|
110
|
+
OpenSSL.fixed_length_secure_compare(expected, actual)
|
|
111
|
+
rescue ArgumentError
|
|
112
|
+
false
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moxml
|
|
4
|
+
module Signature
|
|
5
|
+
module Algorithms
|
|
6
|
+
# Canonical XML 1.0 (https://www.w3.org/TR/xml-c14n/)
|
|
7
|
+
#
|
|
8
|
+
# Implemented via the canon-derived C14n::Inclusive10 engine.
|
|
9
|
+
class InclusiveC14n10 < CanonicalizationBase
|
|
10
|
+
identifier "http://www.w3.org/TR/2001/REC-xml-c14n-20010315"
|
|
11
|
+
identifier "http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments"
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def engine
|
|
16
|
+
::Moxml::C14n::Inclusive10.new
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moxml
|
|
4
|
+
module Signature
|
|
5
|
+
module Algorithms
|
|
6
|
+
# Canonical XML 1.1 (https://www.w3.org/TR/xml-c14n11/)
|
|
7
|
+
#
|
|
8
|
+
# Currently delegates to Inclusive 1.0. Full 1.1 differences
|
|
9
|
+
# (XML 1.1 line-ending handling, notations, DTD entity refs)
|
|
10
|
+
# are documented in TODO.complete/05-c14n-engine.md.
|
|
11
|
+
class InclusiveC14n11 < CanonicalizationBase
|
|
12
|
+
identifier "http://www.w3.org/2006/12/xml-c14n11"
|
|
13
|
+
identifier "http://www.w3.org/2006/12/xml-c14n11#WithComments"
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def engine
|
|
18
|
+
::Moxml::C14n::Inclusive10.new
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|