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,132 @@
|
|
|
1
|
+
# Signing and verification flows
|
|
2
|
+
|
|
3
|
+
## Signer (spec §3.1)
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
Moxml::Signature.sign(context:, document:, key:, **options)
|
|
7
|
+
│
|
|
8
|
+
▼
|
|
9
|
+
build a Model::Signature with the requested algorithms
|
|
10
|
+
│
|
|
11
|
+
▼
|
|
12
|
+
Signer#sign
|
|
13
|
+
│
|
|
14
|
+
├── for each Reference in SignedInfo:
|
|
15
|
+
│ ├── ReferenceResolver.resolve(uri) → node or octets
|
|
16
|
+
│ ├── TransformPipeline.apply(input, transforms)
|
|
17
|
+
│ ├── TransformPipeline.to_octets(transformed, reference)
|
|
18
|
+
│ └── DigestMethod#digest_base64(canonical) → Reference#digest_value
|
|
19
|
+
│
|
|
20
|
+
├── Serializer.serialize_signed_info(signed_info) → Moxml::Document
|
|
21
|
+
├── CanonicalizationMethod#canonicalize(signed_info_root) → octets
|
|
22
|
+
└── SignatureMethod#sign(canonical_octets, key) → SignatureValue
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The Signer never touches algorithms directly — every step delegates to
|
|
26
|
+
the algorithm registry. This is OCP: new algorithms are added without
|
|
27
|
+
touching the Signer.
|
|
28
|
+
|
|
29
|
+
## Verifier (spec §3.2 + Best Practice 1)
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
Moxml::Signature.verify(context:, document:, key: nil, key_map: {})
|
|
33
|
+
│
|
|
34
|
+
▼
|
|
35
|
+
find all ds:Signature elements in the document
|
|
36
|
+
│
|
|
37
|
+
▼ for each Signature
|
|
38
|
+
verify_one(signature_element)
|
|
39
|
+
│
|
|
40
|
+
├── Parser.parse(signature_element) → Model::Signature
|
|
41
|
+
│
|
|
42
|
+
├── verify_signature_value FIRST (Best Practice 1)
|
|
43
|
+
│ ├── find original SignedInfo element (do NOT re-serialize)
|
|
44
|
+
│ ├── CanonicalizationMethod#canonicalize(signed_info_elem)
|
|
45
|
+
│ ├── resolve_key(signature) (KeyExtractor if no key:)
|
|
46
|
+
│ └── SignatureMethod#verify(canonical, key, sig_value)
|
|
47
|
+
│
|
|
48
|
+
└── if signature value verifies:
|
|
49
|
+
for each Reference:
|
|
50
|
+
├── ReferenceResolver.resolve(uri)
|
|
51
|
+
├── TransformPipeline.apply(...)
|
|
52
|
+
├── TransformPipeline.to_octets(...)
|
|
53
|
+
└── DigestMethod#digest_base64 vs Reference#digest_value
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Best Practice 1: authenticate before transforms
|
|
57
|
+
|
|
58
|
+
The verifier deliberately runs `SignatureValue` validation BEFORE
|
|
59
|
+
applying any transforms. Reason: a malicious signature could include
|
|
60
|
+
XSLT or expensive XPath transforms. Best Practice 1 says only run
|
|
61
|
+
those after authenticating the signer.
|
|
62
|
+
|
|
63
|
+
If `SignatureValue` doesn't verify, we skip reference transforms
|
|
64
|
+
entirely. The result object reports `signature_valid?: false` and
|
|
65
|
+
`references: []`.
|
|
66
|
+
|
|
67
|
+
### Why we canonicalize the original element
|
|
68
|
+
|
|
69
|
+
The verifier canonicalizes the SignedInfo **as it appears in the
|
|
70
|
+
document**, not a re-serialized model. Reason: re-serialization could
|
|
71
|
+
change namespace prefixes (e.g., default-namespace SignedInfo might
|
|
72
|
+
become `ds:SignedInfo`), breaking the byte-exact match the signature
|
|
73
|
+
was computed against.
|
|
74
|
+
|
|
75
|
+
The Signer serializes (it has to — it's building new XML). The Verifier
|
|
76
|
+
has the original element from the document and uses it directly. This
|
|
77
|
+
asymmetry is intentional and necessary for cross-verification with
|
|
78
|
+
libxmlsec1-produced signatures.
|
|
79
|
+
|
|
80
|
+
### Errors are captured, not raised
|
|
81
|
+
|
|
82
|
+
`SingleVerificationResult#error` carries any `VerificationError` or
|
|
83
|
+
`UnknownAlgorithm` that caused failure. The verifier returns a result
|
|
84
|
+
object rather than raising, so a hostile signature cannot panic the
|
|
85
|
+
application. Use `result.results.first.error` for debugging.
|
|
86
|
+
|
|
87
|
+
## Key resolution
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
Moxml::Signature.verify(context:, document:) # no key:
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
When no `key:` is passed, the Verifier uses `KeyExtractor` to derive
|
|
94
|
+
one from the signature's `KeyInfo`:
|
|
95
|
+
|
|
96
|
+
1. **X509Certificate** (preferred) — decode base64 DER →
|
|
97
|
+
`OpenSSL::X509::Certificate` → `.public_key`
|
|
98
|
+
2. **RSAKeyValue** — reconstruct via ASN.1 (OpenSSL 3.x dropped
|
|
99
|
+
`RSA.new(n, e)`)
|
|
100
|
+
3. **DSAKeyValue** — reconstruct via ASN.1
|
|
101
|
+
4. **ECKeyValue** — build SubjectPublicKeyInfo DER for the named curve
|
|
102
|
+
(OpenSSL 3.x made PKey immutable)
|
|
103
|
+
6. **KeyName** — look up in the application-supplied `key_map:`
|
|
104
|
+
|
|
105
|
+
OpenSSL 3.x compatibility notes are inline in `key_extractor.rb`.
|
|
106
|
+
|
|
107
|
+
## Transform pipeline
|
|
108
|
+
|
|
109
|
+
`TransformPipeline` is shared by Signer and Verifier. Each Transform
|
|
110
|
+
in the chain declares `input_type` (`:octets` or `:nodeset`) and
|
|
111
|
+
`output_type`; the pipeline coerces types between transforms per
|
|
112
|
+
spec §4.4.3.2:
|
|
113
|
+
|
|
114
|
+
- `octets → nodeset`: parse as XML via the caller's `context:`
|
|
115
|
+
- `nodeset → octets`: apply inclusive C14N 1.0
|
|
116
|
+
|
|
117
|
+
Final conversion to octets (for digesting) uses the last canonicalization
|
|
118
|
+
transform in the chain if any, else inclusive C14N 1.0 as the spec default.
|
|
119
|
+
|
|
120
|
+
## Result objects
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
result = Moxml::Signature.verify(context:, document:, key:)
|
|
124
|
+
result.valid? # → bool (all signatures valid)
|
|
125
|
+
result.signature_count # → Integer
|
|
126
|
+
result.results # → [SingleVerificationResult, ...]
|
|
127
|
+
result.results.first.signature_valid?
|
|
128
|
+
result.results.first.references # → [ReferenceResult, ...]
|
|
129
|
+
result.results.first.references.first.valid?
|
|
130
|
+
result.results.first.error # → Exception or nil
|
|
131
|
+
result.failing # → [SingleVerificationResult, ...]
|
|
132
|
+
```
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Quick reference
|
|
2
|
+
|
|
3
|
+
## Public API
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
# Sign a document
|
|
7
|
+
signature = Moxml::Signature.sign(
|
|
8
|
+
context:, # Moxml::Context (from Moxml.new(:nokogiri))
|
|
9
|
+
document:, # Moxml::Document to sign
|
|
10
|
+
key:, # OpenSSL::PKey::* or HMAC secret String
|
|
11
|
+
signature_method:, # algorithm URI
|
|
12
|
+
canonicalization_method:, # algorithm URI
|
|
13
|
+
digest_method:, # algorithm URI
|
|
14
|
+
reference_uri:, # "" for whole document
|
|
15
|
+
transforms:, # array of algorithm URIs
|
|
16
|
+
key_info: nil, # optional Model::KeyInfo
|
|
17
|
+
signature_id: nil, # optional Id attribute
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# Verify a document
|
|
21
|
+
result = Moxml::Signature.verify(
|
|
22
|
+
context:,
|
|
23
|
+
document:,
|
|
24
|
+
key: nil, # optional; auto-extracted from KeyInfo if absent
|
|
25
|
+
key_map: {}, # for KeyName-based resolution
|
|
26
|
+
)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Algorithm URIs
|
|
30
|
+
|
|
31
|
+
### Digests
|
|
32
|
+
- `http://www.w3.org/2000/09/xmldsig#sha1` (verification only)
|
|
33
|
+
- `http://www.w3.org/2001/04/xmlenc#sha256` (REQUIRED)
|
|
34
|
+
- `http://www.w3.org/2001/04/xmlenc#sha512`
|
|
35
|
+
- `http://www.w3.org/2001/04/xmldsig-more#sha224`
|
|
36
|
+
- `http://www.w3.org/2001/04/xmldsig-more#sha384`
|
|
37
|
+
|
|
38
|
+
### Signature methods
|
|
39
|
+
- RSA-PKCS1v1.5: `http://www.w3.org/2001/04/xmldsig-more#rsa-sha{1,224,256,384,512}`
|
|
40
|
+
- HMAC: `http://www.w3.org/2001/04/xmldsig-more#hmac-sha{1,224,256,384,512}`
|
|
41
|
+
- ECDSA: `http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha{1,224,256,384,512}`
|
|
42
|
+
- DSA: `http://www.w3.org/2000/09/xmldsig#dsa-sha1`, `http://www.w3.org/2009/xmldsig11#dsa-sha256`
|
|
43
|
+
|
|
44
|
+
### Canonicalization
|
|
45
|
+
- Exclusive: `http://www.w3.org/2001/10/xml-exc-c14n#` (add `#WithComments`)
|
|
46
|
+
- Inclusive 1.0: `http://www.w3.org/TR/2001/REC-xml-c14n-20010315`
|
|
47
|
+
- Inclusive 1.1: `http://www.w3.org/2006/12/xml-c14n11`
|
|
48
|
+
|
|
49
|
+
### Transforms
|
|
50
|
+
- `http://www.w3.org/2000/09/xmldsig#enveloped-signature`
|
|
51
|
+
- `http://www.w3.org/2000/09/xmldsig#base64`
|
|
52
|
+
|
|
53
|
+
## Common algorithm combinations
|
|
54
|
+
|
|
55
|
+
### Enveloped RSA-SHA256 (most common)
|
|
56
|
+
```ruby
|
|
57
|
+
signature_method: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
|
|
58
|
+
canonicalization_method: "http://www.w3.org/2001/10/xml-exc-c14n#",
|
|
59
|
+
digest_method: "http://www.w3.org/2001/04/xmlenc#sha256",
|
|
60
|
+
transforms: ["http://www.w3.org/2000/09/xmldsig#enveloped-signature"],
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### HMAC-SHA256
|
|
64
|
+
```ruby
|
|
65
|
+
signature_method: "http://www.w3.org/2001/04/xmldsig-more#hmac-sha256",
|
|
66
|
+
canonicalization_method: "http://www.w3.org/2001/10/xml-exc-c14n#",
|
|
67
|
+
digest_method: "http://www.w3.org/2001/04/xmlenc#sha256",
|
|
68
|
+
transforms: ["http://www.w3.org/2000/09/xmldsig#enveloped-signature"],
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### ECDSA-SHA256 (P-256)
|
|
72
|
+
```ruby
|
|
73
|
+
signature_method: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
|
|
74
|
+
canonicalization_method: "http://www.w3.org/2001/10/xml-exc-c14n#",
|
|
75
|
+
digest_method: "http://www.w3.org/2001/04/xmlenc#sha256",
|
|
76
|
+
transforms: ["http://www.w3.org/2000/09/xmldsig#enveloped-signature"],
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Result inspection
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
result.valid? # → bool (all signatures)
|
|
83
|
+
result.signature_count # → Integer
|
|
84
|
+
result.failing # → [SingleVerificationResult]
|
|
85
|
+
|
|
86
|
+
result.results.first.signature_valid? # → bool (crypto verify)
|
|
87
|
+
result.results.first.references # → [ReferenceResult]
|
|
88
|
+
result.results.first.failing_references # → [ReferenceResult]
|
|
89
|
+
result.results.first.error # → Exception or nil (debugging)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## C14N top-level API
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
Moxml::C14n.canonicalize(node_or_xml, with_comments: false)
|
|
96
|
+
Moxml::C14n.canonicalize_exclusive(node_or_xml, with_comments: false,
|
|
97
|
+
inclusive_namespaces: [])
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Errors
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
Moxml::Signature::Error # base
|
|
104
|
+
Moxml::Signature::SignatureError # generic
|
|
105
|
+
Moxml::Signature::UnknownAlgorithm # URI not in registry
|
|
106
|
+
Moxml::Signature::DuplicateAlgorithm # URI registered twice
|
|
107
|
+
Moxml::Signature::SigningError # signing failed
|
|
108
|
+
Moxml::Signature::VerificationError # verification failed
|
|
109
|
+
Moxml::Signature::ReferenceDigestMismatch # digest mismatch
|
|
110
|
+
Moxml::Signature::SignatureValueMismatch # crypto verify failed
|
|
111
|
+
Moxml::Signature::TransformError # transform pipeline
|
|
112
|
+
Moxml::Signature::CanonicalizationError # C14N failure
|
|
113
|
+
Moxml::Signature::MalformedSignatureError # XML schema violation
|
|
114
|
+
Moxml::Signature::SignatureKeyError # wrong key type
|
|
115
|
+
```
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# Security considerations
|
|
2
|
+
|
|
3
|
+
XML signature is a complex spec with a long history of attacks. This
|
|
4
|
+
document explains what `Moxml::Signature` does to mitigate them and
|
|
5
|
+
what the application is responsible for.
|
|
6
|
+
|
|
7
|
+
## Built-in mitigations
|
|
8
|
+
|
|
9
|
+
### Best Practice 1 — authenticate before transforms (DONE)
|
|
10
|
+
|
|
11
|
+
The Verifier validates `SignatureValue` before running any reference
|
|
12
|
+
transforms. A hostile signature with expensive XSLT or XPath transforms
|
|
13
|
+
cannot consume server resources unless its `SignatureValue` verifies
|
|
14
|
+
against a key the application trusts.
|
|
15
|
+
|
|
16
|
+
### Best Practice 26 — HMAC truncation floor (DONE)
|
|
17
|
+
|
|
18
|
+
`HMACOutputLength` values below `max(hash_bits/2, 80)` are rejected
|
|
19
|
+
during algorithm instantiation (spec §4.4.2). Signatures with
|
|
20
|
+
sub-minimum truncation are deemed invalid.
|
|
21
|
+
|
|
22
|
+
### Best Practice 11 — opaque certificate handling (DONE)
|
|
23
|
+
|
|
24
|
+
`X509Certificate` payloads are decoded as raw DER and passed to
|
|
25
|
+
`OpenSSL::X509::Certificate.new`. We never re-encode certificates, so
|
|
26
|
+
the signature on the certificate itself is preserved.
|
|
27
|
+
|
|
28
|
+
## What the application must do
|
|
29
|
+
|
|
30
|
+
The library cannot make trust decisions; the application must.
|
|
31
|
+
|
|
32
|
+
### Best Practice 2 — establish trust in the key
|
|
33
|
+
|
|
34
|
+
Just because `SignatureValue` verifies against a public key in
|
|
35
|
+
`KeyInfo` does NOT mean the signature should be trusted. The key must
|
|
36
|
+
come from a trusted source:
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
# Application-side: pin the expected key, ignore KeyInfo entirely
|
|
40
|
+
result = Moxml::Signature.verify(
|
|
41
|
+
context: ctx,
|
|
42
|
+
document: doc,
|
|
43
|
+
key: trusted_public_key, # do NOT rely on KeyInfo extraction
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Or, with certificate validation:
|
|
47
|
+
cert = OpenSSL::X509::Certificate.new(cert_der)
|
|
48
|
+
store = OpenSSL::X509::Store.new
|
|
49
|
+
store.add_trust_file("cacert.pem")
|
|
50
|
+
unless store.verify(cert)
|
|
51
|
+
raise "certificate chain invalid"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
result = Moxml::Signature.verify(
|
|
55
|
+
context: ctx,
|
|
56
|
+
document: doc,
|
|
57
|
+
key: cert.public_key,
|
|
58
|
+
)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Best Practice 12 — see what was signed
|
|
62
|
+
|
|
63
|
+
Use `SingleVerificationResult#references` to inspect what the signature
|
|
64
|
+
actually covers:
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
result.results.first.references.each do |ref|
|
|
68
|
+
puts "#{ref.uri}: #{ref.valid?}"
|
|
69
|
+
# Confirm ref.uri matches what the application expects to be signed.
|
|
70
|
+
# Wrapping attacks work by getting the verifier to confirm a different
|
|
71
|
+
# node than the application acts on.
|
|
72
|
+
end
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Best Practice 14 — check name AND position
|
|
76
|
+
|
|
77
|
+
When checking a reference URI, don't just verify the element name. A
|
|
78
|
+
wrapping attack moves the signed element into an `<Object>` and points
|
|
79
|
+
the reference at it; the application then acts on a different (unsigned)
|
|
80
|
+
element with the same name.
|
|
81
|
+
|
|
82
|
+
### Best Practice 8 — control external references
|
|
83
|
+
|
|
84
|
+
External URI dereferencing is **not enabled by default**. If you need
|
|
85
|
+
it, wrap the resolver in a policy that:
|
|
86
|
+
|
|
87
|
+
- Allows only same-document URIs (`#id`, `""`)
|
|
88
|
+
- Allows only specific schemes (`https://`, never `file://`)
|
|
89
|
+
- Caps size and timeout
|
|
90
|
+
- Disallows query parameters that mutate server state
|
|
91
|
+
|
|
92
|
+
## What this library does NOT do
|
|
93
|
+
|
|
94
|
+
### Not a chain validator
|
|
95
|
+
|
|
96
|
+
`Moxml::Signature` does not validate X.509 certificate chains, check
|
|
97
|
+
revocation, or evaluate certificate policies. The application is
|
|
98
|
+
responsible for these (see Best Practice 2 above).
|
|
99
|
+
|
|
100
|
+
### Not a timestamp authority
|
|
101
|
+
|
|
102
|
+
Long-lived signatures need RFC 3161 timestamps from a TSA. This library
|
|
103
|
+
does not implement timestamp verification.
|
|
104
|
+
|
|
105
|
+
### Not a wrapping-attack detector
|
|
106
|
+
|
|
107
|
+
Wrapping attacks succeed when the application acts on a different node
|
|
108
|
+
than the one the signature actually covers. The library returns the
|
|
109
|
+
list of references; the application must verify they cover the right
|
|
110
|
+
nodes.
|
|
111
|
+
|
|
112
|
+
## SHA-1 warning
|
|
113
|
+
|
|
114
|
+
The W3C spec marks SHA-1 as REQUIRED for backwards compatibility but
|
|
115
|
+
DISCOURAGED for new signatures. Cryptanalytic advances (SHAttered,
|
|
116
|
+
2017) demonstrated practical collisions. Use SHA-256 or stronger for
|
|
117
|
+
new signatures.
|
|
118
|
+
|
|
119
|
+
## HMAC security
|
|
120
|
+
|
|
121
|
+
HMAC signatures require a shared secret. Any verifier with the secret
|
|
122
|
+
can forge signatures. Use distinct keys for signing vs. encryption
|
|
123
|
+
(Best Practice 27).
|
|
124
|
+
|
|
125
|
+
## Limited XPath support
|
|
126
|
+
|
|
127
|
+
The library implements the Enveloped Signature transform directly
|
|
128
|
+
(no XPath). XPath Filter and XSLT transforms are not implemented;
|
|
129
|
+
Best Practices 3, 5, 6, 22 say avoid them.
|
|
130
|
+
|
|
131
|
+
## Performance and DoS
|
|
132
|
+
|
|
133
|
+
Canonicalization is O(N) for tree size. XSLT and complex XPath are
|
|
134
|
+
not supported, eliminating the most common DoS vectors (Best Practices
|
|
135
|
+
3, 5, 6). Wrap calls in a timeout if processing untrusted input:
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
require "timeout"
|
|
139
|
+
|
|
140
|
+
Timeout.timeout(5) do
|
|
141
|
+
Moxml::Signature.verify(context: ctx, document: untrusted_doc, key:)
|
|
142
|
+
end
|
|
143
|
+
```
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Example: auto-extract the verification key from a KeyInfo that
|
|
4
|
+
# contains an embedded X509Certificate. The Verifier derives the
|
|
5
|
+
# OpenSSL key without application help.
|
|
6
|
+
#
|
|
7
|
+
# Run with: bundle exec ruby examples/signature/auto_key_extraction.rb
|
|
8
|
+
|
|
9
|
+
require "moxml"
|
|
10
|
+
require "moxml/signature"
|
|
11
|
+
|
|
12
|
+
ctx = Moxml.new(:nokogiri)
|
|
13
|
+
|
|
14
|
+
fixture = File.expand_path(
|
|
15
|
+
"../../spec/fixtures/xmldsig/sign3-result.xml",
|
|
16
|
+
__dir__,
|
|
17
|
+
)
|
|
18
|
+
xml = File.read(fixture)
|
|
19
|
+
doc = ctx.parse(xml)
|
|
20
|
+
|
|
21
|
+
# No explicit key — Verifier auto-extracts from X509Certificate.
|
|
22
|
+
result = Moxml::Signature.verify(context: ctx, document: doc)
|
|
23
|
+
puts "Auto-extracted from X509Certificate: #{result.valid?}"
|
|
24
|
+
puts "Signature count: #{result.signature_count}"
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Example: enveloped RSA-SHA256 signature with verification.
|
|
4
|
+
#
|
|
5
|
+
# Run with: bundle exec ruby examples/signature/enveloped_rsa.rb
|
|
6
|
+
|
|
7
|
+
require "moxml"
|
|
8
|
+
require "moxml/signature"
|
|
9
|
+
require "openssl"
|
|
10
|
+
|
|
11
|
+
ctx = Moxml.new(:nokogiri)
|
|
12
|
+
key = OpenSSL::PKey::RSA.generate(2048)
|
|
13
|
+
|
|
14
|
+
doc = ctx.parse("<doc><greeting>Hello, World!</greeting></doc>")
|
|
15
|
+
puts "=== Before signing ==="
|
|
16
|
+
puts doc.to_xml
|
|
17
|
+
|
|
18
|
+
signature = Moxml::Signature.sign(
|
|
19
|
+
context: ctx,
|
|
20
|
+
document: doc,
|
|
21
|
+
key: key,
|
|
22
|
+
signature_method: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
|
|
23
|
+
canonicalization_method: "http://www.w3.org/2001/10/xml-exc-c14n#",
|
|
24
|
+
digest_method: "http://www.w3.org/2001/04/xmlenc#sha256",
|
|
25
|
+
reference_uri: "",
|
|
26
|
+
transforms: ["http://www.w3.org/2000/09/xmldsig#enveloped-signature"],
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
serialized = Moxml::Signature::Serializer.new(context: ctx).serialize(signature)
|
|
30
|
+
doc.root.add_child(serialized.root)
|
|
31
|
+
|
|
32
|
+
puts ""
|
|
33
|
+
puts "=== After signing ==="
|
|
34
|
+
puts doc.to_xml
|
|
35
|
+
|
|
36
|
+
result = Moxml::Signature.verify(context: ctx, document: doc, key: key)
|
|
37
|
+
puts ""
|
|
38
|
+
puts "=== Verification ==="
|
|
39
|
+
puts "Valid: #{result.valid?}"
|
|
40
|
+
|
|
41
|
+
# Tamper test
|
|
42
|
+
doc2 = ctx.parse(doc.to_xml(indent: 0))
|
|
43
|
+
doc2.at_xpath("//greeting").text = "Goodbye!"
|
|
44
|
+
tampered = Moxml::Signature.verify(context: ctx, document: doc2, key: key)
|
|
45
|
+
puts "Tampered: #{tampered.valid?} (expected false)"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Example: HMAC-SHA256 signature with a shared secret.
|
|
4
|
+
#
|
|
5
|
+
# Run with: bundle exec ruby examples/signature/hmac.rb
|
|
6
|
+
|
|
7
|
+
require "moxml"
|
|
8
|
+
require "moxml/signature"
|
|
9
|
+
|
|
10
|
+
ctx = Moxml.new(:nokogiri)
|
|
11
|
+
secret = "super-secret-shared-key"
|
|
12
|
+
|
|
13
|
+
doc = ctx.parse("<message><body>hello</body></message>")
|
|
14
|
+
|
|
15
|
+
signature = Moxml::Signature.sign(
|
|
16
|
+
context: ctx,
|
|
17
|
+
document: doc,
|
|
18
|
+
key: secret,
|
|
19
|
+
signature_method: "http://www.w3.org/2001/04/xmldsig-more#hmac-sha256",
|
|
20
|
+
canonicalization_method: "http://www.w3.org/2001/10/xml-exc-c14n#",
|
|
21
|
+
digest_method: "http://www.w3.org/2001/04/xmlenc#sha256",
|
|
22
|
+
reference_uri: "",
|
|
23
|
+
transforms: ["http://www.w3.org/2000/09/xmldsig#enveloped-signature"],
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
serialized = Moxml::Signature::Serializer.new(context: ctx).serialize(signature)
|
|
27
|
+
doc.root.add_child(serialized.root)
|
|
28
|
+
|
|
29
|
+
result = Moxml::Signature.verify(context: ctx, document: doc, key: secret)
|
|
30
|
+
puts "Valid: #{result.valid?}"
|
|
31
|
+
|
|
32
|
+
wrong = Moxml::Signature.verify(context: ctx, document: doc, key: "wrong")
|
|
33
|
+
puts "Wrong secret: #{wrong.valid?} (expected false)"
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moxml
|
|
4
|
+
module C14n
|
|
5
|
+
# Attribute axis handler for inclusive C14N.
|
|
6
|
+
# Implements W3C C14N 1.0 §2.4 / 1.1 §2.4 attribute rendering,
|
|
7
|
+
# including resolution of simple inheritable attributes (xml:lang,
|
|
8
|
+
# xml:space) for document subsets.
|
|
9
|
+
class AttributeHandler
|
|
10
|
+
attr_reader :encoder
|
|
11
|
+
|
|
12
|
+
def initialize(encoder)
|
|
13
|
+
@encoder = encoder
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def process_attributes(element, output, omitted_ancestors = [])
|
|
17
|
+
return unless element.in_node_set?
|
|
18
|
+
|
|
19
|
+
attributes = collect_attributes(element, omitted_ancestors)
|
|
20
|
+
attributes.each do |attr|
|
|
21
|
+
output << " "
|
|
22
|
+
output << attr.qname
|
|
23
|
+
output << '="'
|
|
24
|
+
output << encoder.encode_attribute(attr.value)
|
|
25
|
+
output << '"'
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def collect_attributes(element, omitted_ancestors)
|
|
32
|
+
attributes = element.sorted_attribute_nodes.select(&:in_node_set?)
|
|
33
|
+
|
|
34
|
+
return attributes if omitted_ancestors.empty?
|
|
35
|
+
|
|
36
|
+
inherited = collect_inherited_attributes(element, omitted_ancestors)
|
|
37
|
+
merge_attributes(attributes, inherited)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Walk omitted ancestors to collect simple inheritable attributes
|
|
41
|
+
# not already declared on the element. Per C14N 1.1 §2.4, these
|
|
42
|
+
# are inherited from the nearest ancestor in which they are declared.
|
|
43
|
+
def collect_inherited_attributes(element, omitted_ancestors)
|
|
44
|
+
inherited = []
|
|
45
|
+
seen = Set.new
|
|
46
|
+
|
|
47
|
+
element.attribute_nodes.each do |attr|
|
|
48
|
+
seen.add(attr.name) if attr.simple_inheritable?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
omitted_ancestors.reverse_each do |ancestor|
|
|
52
|
+
ancestor.attribute_nodes.each do |attr|
|
|
53
|
+
next unless attr.simple_inheritable?
|
|
54
|
+
next if seen.include?(attr.name)
|
|
55
|
+
|
|
56
|
+
inherited << attr
|
|
57
|
+
seen.add(attr.name)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
inherited
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def merge_attributes(element_attrs, inherited_attrs)
|
|
65
|
+
(element_attrs + inherited_attrs).sort_by do |attr|
|
|
66
|
+
[attr.namespace_uri.to_s, attr.local_name]
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moxml
|
|
4
|
+
module C14n
|
|
5
|
+
# Character encoder for C14N output. Handles the entity escaping
|
|
6
|
+
# mandated by W3C C14N 1.0 §2.4 / 1.1 §2.4 for text and attribute values.
|
|
7
|
+
class CharacterEncoder
|
|
8
|
+
TEXT_ESCAPES = {
|
|
9
|
+
"&" => "&",
|
|
10
|
+
"<" => "<",
|
|
11
|
+
">" => ">",
|
|
12
|
+
"\r" => "
",
|
|
13
|
+
}.freeze
|
|
14
|
+
ATTRIBUTE_ESCAPES = TEXT_ESCAPES.merge(
|
|
15
|
+
'"' => """,
|
|
16
|
+
"\t" => "	",
|
|
17
|
+
"\n" => "
",
|
|
18
|
+
).freeze
|
|
19
|
+
|
|
20
|
+
def encode_text(text)
|
|
21
|
+
text.to_s.gsub(/[&<>\r]/, TEXT_ESCAPES)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def encode_attribute(value)
|
|
25
|
+
value.to_s.gsub(/[&<"\t\n\r]/, ATTRIBUTE_ESCAPES)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|