xades 0.1.0 → 0.2.0
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/CHANGELOG.md +32 -0
- data/README.md +88 -14
- data/lib/xades/algorithms.rb +16 -0
- data/lib/xades/bes.rb +10 -2
- data/lib/xades/c14n.rb +19 -5
- data/lib/xades/certificate.rb +54 -4
- data/lib/xades/digest.rb +21 -1
- data/lib/xades/errors.rb +19 -1
- data/lib/xades/signer.rb +26 -2
- data/lib/xades/verifier.rb +61 -25
- data/lib/xades/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7dbb47d18cc2a03f990e291863d60b689eafb5543e55635a992c347dcb113889
|
|
4
|
+
data.tar.gz: 21d1f51cb0f1457c65d85839ef7491398f48fe8bfb6ef3051cf9f0bc3bc73a00
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 58e517750101151df62e4e97dda35a2086ef714695a50e9b0f9c9de6431e569ec60d7d7704f5c87219560079f6829ba33f34273ed2db4794522dfea9c94ac984
|
|
7
|
+
data.tar.gz: 051f5d6c6ee2691a8693958e4e7d249299393751fc4ccfa03614751c5b5b60005af5a9acb23bb7441431c458b84f460a91f7d166d43703a2a8d4f2f811d45881
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.2.0] - 2026-09-27
|
|
4
|
+
|
|
5
|
+
- Added `Xades::Bes.verify!`/`Xades::Verifier.verify!`: raises `Xades::VerificationError` (message
|
|
6
|
+
joins every `Result#errors` entry) instead of returning a `Result` you have to check `.valid?`
|
|
7
|
+
on. Returns `true` on success.
|
|
8
|
+
- `signing_time:` now accepts anything that responds to `#to_time` (`Date`, `DateTime`,
|
|
9
|
+
`ActiveSupport::TimeWithZone`, ...) in addition to `Time`, normalized up front with a clear
|
|
10
|
+
`ArgumentError` for anything else -- previously a bare `Date` failed with a `NoMethodError` on
|
|
11
|
+
`#utc` deep inside a builder.
|
|
12
|
+
- **Breaking**: `Xades::Certificate.new` (and therefore `.from_pem`/`.from_pkcs12`) now validates
|
|
13
|
+
that the private key actually matches the certificate's public key
|
|
14
|
+
(`Xades::CertificateKeyMismatchError` if not) and that an RSA key is at least 2048 bits
|
|
15
|
+
(`Xades::UnsupportedKeyError` if not, matching KSeF's documented minimum). Previously a
|
|
16
|
+
mismatched cert/key pair was accepted silently and produced a signature that could never verify.
|
|
17
|
+
- **Breaking**: `Xades::Bes.sign`/`Signer#sign` now raise `Xades::CertificateValidityError` if the
|
|
18
|
+
certificate is expired or not yet valid at `signing_time`, unless
|
|
19
|
+
`allow_invalid_certificate_period: true` is passed. New `Certificate#expired?`,
|
|
20
|
+
`#not_yet_valid?`, `#valid_at?` for checking this yourself.
|
|
21
|
+
- **Breaking**: now requires Ruby >= 4.0 (was >= 3.2). CI matrix and `.rubocop.yml`'s
|
|
22
|
+
`TargetRubyVersion` updated to match.
|
|
23
|
+
- `Verifier` now reads and respects a document's actually-declared canonicalization method
|
|
24
|
+
(Exclusive C14N, plain C14N 1.0, or C14N 1.1, including the XMLDSig-implicit-default cases),
|
|
25
|
+
digest algorithm (SHA-1/256/384/512) and signature algorithm (RSA/ECDSA with any of those
|
|
26
|
+
digests), instead of assuming its own signing defaults. Found and fixed via real-world
|
|
27
|
+
signatures from Denmark, the UK and Spain (Facturae) -- see `spec/fixtures/interop/SOURCES.md`.
|
|
28
|
+
- Fixed: `Verifier` picked the wrong `Reference` as "the document" on signatures that carry more
|
|
29
|
+
than one untyped `Reference` (e.g. a real Facturae signature that also protects `KeyInfo`); it
|
|
30
|
+
now specifically looks for `URI=""`.
|
|
31
|
+
- Added unit specs for `C14N`, `Digest`, `Util`, and a from-scratch `Verifier` spec covering every
|
|
32
|
+
supported signature-method/digest combination directly (not only via `Signer`, which only ever
|
|
33
|
+
produces SHA-256).
|
|
34
|
+
|
|
3
35
|
## [0.1.0] - 2026-09-27
|
|
4
36
|
|
|
5
37
|
- Initial release
|
data/README.md
CHANGED
|
@@ -7,13 +7,28 @@ XAdES-BES / BASELINE-B (ETSI EN 319 132) XML signing and verification for pure R
|
|
|
7
7
|
Ruby's built-in `openssl` (RSA / ECDSA).
|
|
8
8
|
|
|
9
9
|
This exists because Ruby has never had a maintained, general-purpose XAdES gem, while several
|
|
10
|
-
eIDAS-adjacent flows require one
|
|
11
|
-
|
|
12
|
-
XAdES-BES
|
|
10
|
+
eIDAS-adjacent flows require one. XAdES-BES is an EU-wide standard, not a Polish one -- **Poland's
|
|
11
|
+
KSeF 2.0** e-invoicing system is this gem's primary driver (authenticating with a certificate means
|
|
12
|
+
signing an `AuthTokenRequest` document with XAdES-BES, see
|
|
13
|
+
[CIRFMF/ksef-docs](https://github.com/CIRFMF/ksef-docs)), but the same signer/verifier works for
|
|
14
|
+
any XAdES-BES use case: Spain's Facturae, generic eIDAS-adjacent e-invoicing, Peppol, etc.
|
|
15
|
+
|
|
16
|
+
### Tested against real-world signatures from other countries
|
|
17
|
+
|
|
18
|
+
`Xades::Bes.verify` doesn't just round-trip against its own output -- the test suite includes real,
|
|
19
|
+
independently-produced signatures from **Denmark, the United Kingdom, Spain (Facturae), Slovakia,
|
|
20
|
+
Hungary and Austria**, plus generic ETSI plugtest vectors, vendored from other projects' test
|
|
21
|
+
suites (see [`spec/fixtures/interop/SOURCES.md`](spec/fixtures/interop/SOURCES.md) for exactly
|
|
22
|
+
which file, from where, and what it's for). These aren't decorative: verifying against them during
|
|
23
|
+
development caught real bugs -- Verifier used to hardcode Exclusive C14N and SHA-256 and assume the
|
|
24
|
+
first untyped `Reference` was always "the document," none of which held for every one of these
|
|
25
|
+
countries' tools. Verifier now reads the canonicalization, digest and signature algorithm a
|
|
26
|
+
document actually declares (KSeF's own docs list several of each) rather than assuming its own
|
|
27
|
+
defaults.
|
|
13
28
|
|
|
14
29
|
## What this is (and isn't)
|
|
15
30
|
|
|
16
|
-
|
|
31
|
+
**Signing** always produces XAdES-BES / BASELINE-B in one specific, KSeF-compatible shape:
|
|
17
32
|
|
|
18
33
|
- Enveloped signatures (the signature is inserted as the last child of the signed document's root)
|
|
19
34
|
- Exclusive C14N (`http://www.w3.org/2001/10/xml-exc-c14n#`)
|
|
@@ -22,11 +37,19 @@ Implements **XAdES-BES / BASELINE-B** only:
|
|
|
22
37
|
- `SigningCertificate` (V1) or `SigningCertificateV2` (default; RFC 5035 `IssuerSerialV2`)
|
|
23
38
|
- PEM and PKCS#12 key/certificate loading
|
|
24
39
|
|
|
25
|
-
**
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
40
|
+
**Verification** is deliberately more permissive, since a document you didn't produce may
|
|
41
|
+
legitimately use a different (still XAdES-BES-conformant) combination: it reads and respects the
|
|
42
|
+
actual declared canonicalization method (Exclusive C14N, plain C14N 1.0 or C14N 1.1, including the
|
|
43
|
+
implicit-default cases the XMLDSig spec allows), digest algorithm (SHA-1/256/384/512), and
|
|
44
|
+
signature algorithm (RSA or ECDSA with any of those digests) rather than assuming its own defaults.
|
|
45
|
+
|
|
46
|
+
**Not implemented** (out of scope for this MVP -- see "What's next" below for why): XAdES-T/-LT/-LTA
|
|
47
|
+
(timestamps, revocation data, archival), CAdES/PAdES, detached signatures, HSM integration, and
|
|
48
|
+
certificate trust/chain/revocation validation. `Xades::Bes.verify` checks that the signature is
|
|
49
|
+
*structurally and cryptographically correct against the certificate embedded in the document* -- it
|
|
50
|
+
does not tell you whether that certificate should be trusted. Enveloping (as opposed to enveloped)
|
|
51
|
+
signatures can be *verified* if they happen to use a `URI=""`-style reference, but this gem cannot
|
|
52
|
+
yet *produce* them.
|
|
30
53
|
|
|
31
54
|
**Status**: pre-1.0, early. The test suite includes real-world interop fixtures from other
|
|
32
55
|
projects (see `spec/fixtures/interop/SOURCES.md`) and a golden fixture matching KSeF's published
|
|
@@ -34,6 +57,32 @@ projects (see `spec/fixtures/interop/SOURCES.md`) and a golden fixture matching
|
|
|
34
57
|
third-party validator (`xmlsec1`, esig/dss) or a live KSeF test environment. Please verify against
|
|
35
58
|
your target system before relying on it for anything regulated, and open an issue if you do.
|
|
36
59
|
|
|
60
|
+
## What's next
|
|
61
|
+
|
|
62
|
+
Roughly in priority order, based on what would make this gem more useful versus what would turn it
|
|
63
|
+
into a different (and already well-served, e.g. by esig/dss) product:
|
|
64
|
+
|
|
65
|
+
1. **Enveloping signature support** (signing, not just verifying). KSeF's own docs explicitly
|
|
66
|
+
accept enveloping alongside enveloped, and Spain's Facturae typically produces enveloping
|
|
67
|
+
signatures too -- this is the one gap that's both cheap-ish to close and clearly wanted by more
|
|
68
|
+
than one target platform.
|
|
69
|
+
2. **A small CLI** (`xades sign in.xml --cert ... --key ... -o out.xml` / `xades verify signed.xml`)
|
|
70
|
+
so the gem is usable without writing Ruby, e.g. from a shell script or another language's CI job.
|
|
71
|
+
3. **More golden fixtures against live test environments**, not just published schemas -- starting
|
|
72
|
+
with actually exchanging a signed `AuthTokenRequest` with KSeF's test API, and ideally a Facturae
|
|
73
|
+
test endpoint too.
|
|
74
|
+
4. **XAdES-T** (RFC 3161 timestamping) as an *opt-in* addition on top of an existing BES signature,
|
|
75
|
+
kept separate from the core `sign`/`verify` API. This is the most-requested "next tier" feature
|
|
76
|
+
for any XAdES library, but full -LT/-LTA support is a much bigger, fundamentally different scope
|
|
77
|
+
(revocation data embedding, and -LTA specifically requires *periodically re-timestamping* a
|
|
78
|
+
signature over its lifetime -- an ongoing operational process, not a one-shot `sign` call) --
|
|
79
|
+
likely a separate gem rather than a feature of this one, if it happens at all.
|
|
80
|
+
5. **Deliberately not planned**: certificate trust/chain/revocation validation. Doing this properly
|
|
81
|
+
means an EU Trusted List (LOTL/EUTL) integration, OCSP/CRL fetching, and trust anchor management
|
|
82
|
+
-- at that point you're rebuilding a slice of esig/dss. A partial version would be worse than
|
|
83
|
+
none: it would look like a real trust check while giving false confidence. If you need this,
|
|
84
|
+
pair `Xades::Bes.verify`'s structural/cryptographic check with a dedicated validator instead.
|
|
85
|
+
|
|
37
86
|
## Installation
|
|
38
87
|
|
|
39
88
|
```bash
|
|
@@ -56,6 +105,9 @@ signed_xml = Xades::Bes.sign(xml, certificate: certificate)
|
|
|
56
105
|
result = Xades::Bes.verify(signed_xml)
|
|
57
106
|
result.valid? # => true
|
|
58
107
|
result.errors # => []
|
|
108
|
+
|
|
109
|
+
# Or, if you'd rather raise than check .valid? yourself:
|
|
110
|
+
Xades::Bes.verify!(signed_xml) # => true, or raises Xades::VerificationError
|
|
59
111
|
```
|
|
60
112
|
|
|
61
113
|
`Xades::Bes.sign` accepts:
|
|
@@ -63,11 +115,29 @@ result.errors # => []
|
|
|
63
115
|
| option | default | |
|
|
64
116
|
|---|---|---|
|
|
65
117
|
| `certificate:` | *(required)* | an `Xades::Certificate` |
|
|
66
|
-
| `signing_time:` | `Time.now.utc` | embedded as `xades:SigningTime` |
|
|
118
|
+
| `signing_time:` | `Time.now.utc` | embedded as `xades:SigningTime`; a `Time`, or anything with `#to_time` (`Date`, `DateTime`, `ActiveSupport::TimeWithZone`, ...) |
|
|
67
119
|
| `signing_certificate_version:` | `:v2` | `:v2` (`SigningCertificateV2`/`IssuerSerialV2`) or `:v1` (`SigningCertificate`/`IssuerSerial`) |
|
|
120
|
+
| `allow_invalid_certificate_period:` | `false` | set `true` to sign anyway with a certificate that's expired or not yet valid at `signing_time` (see below) |
|
|
68
121
|
|
|
69
122
|
The certificate's key type (RSA or EC) determines the signature algorithm automatically.
|
|
70
123
|
|
|
124
|
+
### Certificate validation
|
|
125
|
+
|
|
126
|
+
`Xades::Certificate.new`/`.from_pem`/`.from_pkcs12` reject obviously-broken input immediately,
|
|
127
|
+
rather than producing a certificate that would silently fail to verify (or be rejected by KSeF)
|
|
128
|
+
much later, opaquely:
|
|
129
|
+
|
|
130
|
+
- the private key must actually correspond to the certificate's public key (`Xades::CertificateKeyMismatchError`) --
|
|
131
|
+
catches the common mistake of pairing the wrong cert/key files
|
|
132
|
+
- an RSA key must be at least 2048 bits (`Xades::UnsupportedKeyError`), KSeF's documented minimum
|
|
133
|
+
|
|
134
|
+
`Xades::Bes.sign` additionally checks the certificate's validity period (`not_before`/`not_after`)
|
|
135
|
+
against `signing_time` and raises `Xades::CertificateValidityError` if it's expired or not yet
|
|
136
|
+
valid; pass `allow_invalid_certificate_period: true` if you need to sign anyway (e.g. in a test).
|
|
137
|
+
`certificate.valid_at?(time)`, `#expired?`, and `#not_yet_valid?` are available for checking this
|
|
138
|
+
yourself beforehand. None of this validates trust/chain/revocation -- see "What this is (and
|
|
139
|
+
isn't)" above.
|
|
140
|
+
|
|
71
141
|
### KSeF example
|
|
72
142
|
|
|
73
143
|
```ruby
|
|
@@ -95,13 +165,17 @@ The test suite (`spec/`) combines three kinds of coverage, modeled on how other
|
|
|
95
165
|
XAdES libraries test themselves (`signxml` in Python, `xadesjs` in Node, `esig/dss` in Java --
|
|
96
166
|
see `spec/fixtures/interop/SOURCES.md`):
|
|
97
167
|
|
|
98
|
-
- **Unit specs** (`spec/xades/`) for each internal component
|
|
168
|
+
- **Unit specs** (`spec/xades/`) for each internal component, including a from-scratch (not via
|
|
169
|
+
`Signer`) matrix in `verifier_spec.rb` proving every RSA/ECDSA x SHA-1/256/384/512 combination
|
|
170
|
+
`Verifier` claims to support actually verifies
|
|
99
171
|
- **Conformance specs** (`spec/conformance/`), asserting the exact BASELINE-B structural
|
|
100
172
|
requirements a signature must (and must not) satisfy, mirroring `esig/dss`'s
|
|
101
173
|
`XAdESBaselineBTest` pattern
|
|
102
|
-
- **Interop specs** (`spec/interop/`)
|
|
103
|
-
|
|
104
|
-
|
|
174
|
+
- **Interop specs** (`spec/interop/`): a golden fixture built against KSeF's published
|
|
175
|
+
`AuthTokenRequest` schema, plus real-world signatures vendored from other countries/tools
|
|
176
|
+
(`multi_country_fixtures_spec.rb`) and from other projects' own test suites
|
|
177
|
+
(`vendored_fixtures_spec.rb`) -- see `spec/fixtures/interop/SOURCES.md` for what each file is and
|
|
178
|
+
what interop bug (if any) it caught during development
|
|
105
179
|
|
|
106
180
|
## Contributing
|
|
107
181
|
|
data/lib/xades/algorithms.rb
CHANGED
|
@@ -7,11 +7,27 @@ module Xades
|
|
|
7
7
|
XADES_SIGNED_PROPERTIES_TYPE = "http://uri.etsi.org/01903#SignedProperties"
|
|
8
8
|
|
|
9
9
|
C14N_EXCLUSIVE = "http://www.w3.org/2001/10/xml-exc-c14n#"
|
|
10
|
+
C14N_1_0 = "http://www.w3.org/TR/2001/REC-xml-c14n-20010315"
|
|
11
|
+
C14N_1_1 = "http://www.w3.org/2006/12/xml-c14n11"
|
|
10
12
|
ENVELOPED_SIGNATURE = "http://www.w3.org/2000/09/xmldsig#enveloped-signature"
|
|
11
13
|
|
|
12
14
|
DIGEST_SHA256 = "http://www.w3.org/2001/04/xmlenc#sha256"
|
|
13
15
|
|
|
14
16
|
SIGNATURE_RSA_SHA256 = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"
|
|
15
17
|
SIGNATURE_ECDSA_SHA256 = "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256"
|
|
18
|
+
|
|
19
|
+
# This gem always *signs* with RSA/ECDSA-SHA256, but a document we *verify* may legally use
|
|
20
|
+
# any of these (KSeF's own docs list all of them; RSA-PSS and SHA-3 variants are not yet
|
|
21
|
+
# supported -- see README limitations).
|
|
22
|
+
SIGNATURE_METHODS = {
|
|
23
|
+
"http://www.w3.org/2000/09/xmldsig#rsa-sha1" => { key_type: :rsa, digest: "SHA1" },
|
|
24
|
+
SIGNATURE_RSA_SHA256 => { key_type: :rsa, digest: "SHA256" },
|
|
25
|
+
"http://www.w3.org/2001/04/xmldsig-more#rsa-sha384" => { key_type: :rsa, digest: "SHA384" },
|
|
26
|
+
"http://www.w3.org/2001/04/xmldsig-more#rsa-sha512" => { key_type: :rsa, digest: "SHA512" },
|
|
27
|
+
"http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha1" => { key_type: :ecdsa, digest: "SHA1" },
|
|
28
|
+
SIGNATURE_ECDSA_SHA256 => { key_type: :ecdsa, digest: "SHA256" },
|
|
29
|
+
"http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384" => { key_type: :ecdsa, digest: "SHA384" },
|
|
30
|
+
"http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512" => { key_type: :ecdsa, digest: "SHA512" }
|
|
31
|
+
}.freeze
|
|
16
32
|
end
|
|
17
33
|
end
|
data/lib/xades/bes.rb
CHANGED
|
@@ -8,16 +8,24 @@ module Xades
|
|
|
8
8
|
# result = Xades::Bes.verify(signed_xml)
|
|
9
9
|
# result.valid? # => true
|
|
10
10
|
module Bes
|
|
11
|
-
def self.sign(xml, certificate:, signing_time: Time.now.utc, signing_certificate_version: :v2
|
|
11
|
+
def self.sign(xml, certificate:, signing_time: Time.now.utc, signing_certificate_version: :v2,
|
|
12
|
+
allow_invalid_certificate_period: false)
|
|
12
13
|
Signer.new(
|
|
13
14
|
certificate: certificate,
|
|
14
15
|
signing_time: signing_time,
|
|
15
|
-
signing_certificate_version: signing_certificate_version
|
|
16
|
+
signing_certificate_version: signing_certificate_version,
|
|
17
|
+
allow_invalid_certificate_period: allow_invalid_certificate_period
|
|
16
18
|
).sign(xml)
|
|
17
19
|
end
|
|
18
20
|
|
|
19
21
|
def self.verify(xml)
|
|
20
22
|
Verifier.verify(xml)
|
|
21
23
|
end
|
|
24
|
+
|
|
25
|
+
# Like .verify, but raises Xades::VerificationError instead of returning a Result to check
|
|
26
|
+
# .valid? on. Returns true on success.
|
|
27
|
+
def self.verify!(xml)
|
|
28
|
+
Verifier.verify!(xml)
|
|
29
|
+
end
|
|
22
30
|
end
|
|
23
31
|
end
|
data/lib/xades/c14n.rb
CHANGED
|
@@ -1,13 +1,27 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Xades
|
|
4
|
-
# Wraps Nokogiri's native
|
|
5
|
-
# the
|
|
4
|
+
# Wraps Nokogiri's native XML canonicalization. This gem always *signs* with Exclusive C14N
|
|
5
|
+
# (the mode KSeF and modern XAdES-BASELINE-B generators use), but real-world documents we may
|
|
6
|
+
# need to *verify* can legally declare plain C14N 1.0 or C14N 1.1 instead (KSeF's own docs list
|
|
7
|
+
# all three as acceptable) -- callers that verify third-party documents should pass the
|
|
8
|
+
# algorithm URI actually declared in that document, not assume Exclusive C14N.
|
|
6
9
|
module C14N
|
|
7
|
-
|
|
10
|
+
MODES = {
|
|
11
|
+
Algorithms::C14N_EXCLUSIVE => Nokogiri::XML::XML_C14N_EXCLUSIVE_1_0,
|
|
12
|
+
Algorithms::C14N_1_0 => Nokogiri::XML::XML_C14N_1_0,
|
|
13
|
+
Algorithms::C14N_1_1 => Nokogiri::XML::XML_C14N_1_1
|
|
14
|
+
}.freeze
|
|
8
15
|
|
|
9
|
-
def self.canonicalize(node)
|
|
10
|
-
|
|
16
|
+
def self.canonicalize(node, algorithm_uri = Algorithms::C14N_EXCLUSIVE)
|
|
17
|
+
mode = MODES.fetch(algorithm_uri) do
|
|
18
|
+
raise UnsupportedAlgorithmError, "Unsupported canonicalization algorithm: #{algorithm_uri.inspect}"
|
|
19
|
+
end
|
|
20
|
+
node.canonicalize(mode)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.supported?(algorithm_uri)
|
|
24
|
+
MODES.key?(algorithm_uri)
|
|
11
25
|
end
|
|
12
26
|
end
|
|
13
27
|
end
|
data/lib/xades/certificate.rb
CHANGED
|
@@ -5,11 +5,14 @@ module Xades
|
|
|
5
5
|
class Certificate
|
|
6
6
|
attr_reader :x509, :key
|
|
7
7
|
|
|
8
|
+
# KSeF's docs require a minimum 2048-bit RSA key; a weaker key would otherwise be accepted
|
|
9
|
+
# silently here and only rejected much later, opaquely, by whatever system verifies it.
|
|
10
|
+
MINIMUM_RSA_KEY_BITS = 2048
|
|
11
|
+
|
|
8
12
|
def initialize(x509:, key:)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
end
|
|
13
|
+
validate_key_type!(key)
|
|
14
|
+
validate_key_strength!(key)
|
|
15
|
+
validate_key_matches_certificate!(x509, key)
|
|
13
16
|
|
|
14
17
|
@x509 = x509
|
|
15
18
|
@key = key
|
|
@@ -57,6 +60,18 @@ module Xades
|
|
|
57
60
|
x509.serial.to_s
|
|
58
61
|
end
|
|
59
62
|
|
|
63
|
+
def expired?(at: Time.now)
|
|
64
|
+
at > x509.not_after
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def not_yet_valid?(at: Time.now)
|
|
68
|
+
at < x509.not_before
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def valid_at?(time)
|
|
72
|
+
!expired?(at: time) && !not_yet_valid?(at: time)
|
|
73
|
+
end
|
|
74
|
+
|
|
60
75
|
# DER-encoded IssuerSerial (RFC 5035 IssuerSerial: SEQUENCE { GeneralNames, CertificateSerialNumber }),
|
|
61
76
|
# base64-encoded, as used by xades:IssuerSerialV2 (SigningCertificate V2). Verified byte-for-byte against
|
|
62
77
|
# a real DSS-generated fixture during development.
|
|
@@ -72,5 +87,40 @@ module Xades
|
|
|
72
87
|
issuer_serial = OpenSSL::ASN1::Sequence.new([general_names, serial_asn1])
|
|
73
88
|
Base64.strict_encode64(issuer_serial.to_der)
|
|
74
89
|
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def validate_key_type!(key)
|
|
94
|
+
return if key.is_a?(OpenSSL::PKey::RSA) || key.is_a?(OpenSSL::PKey::EC)
|
|
95
|
+
|
|
96
|
+
raise UnsupportedKeyError, "key must be an RSA or EC private key, got #{key.class}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def validate_key_strength!(key)
|
|
100
|
+
return unless key.is_a?(OpenSSL::PKey::RSA)
|
|
101
|
+
return if key.n.num_bits >= MINIMUM_RSA_KEY_BITS
|
|
102
|
+
|
|
103
|
+
raise UnsupportedKeyError, "RSA key is #{key.n.num_bits} bits, minimum is #{MINIMUM_RSA_KEY_BITS}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def validate_key_matches_certificate!(x509, key)
|
|
107
|
+
return if public_key_der(x509.public_key) == public_key_der(key)
|
|
108
|
+
|
|
109
|
+
raise CertificateKeyMismatchError, "the certificate's public key does not match the given private key"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# A private key's own #public_key and a certificate's #public_key aren't directly comparable
|
|
113
|
+
# for EC (the former is a bare OpenSSL::PKey::EC::Point, the latter a full OpenSSL::PKey::EC),
|
|
114
|
+
# so we normalize both down to the raw EC point encoding; for RSA, #to_der on the public key
|
|
115
|
+
# portion of either is already directly comparable.
|
|
116
|
+
def public_key_der(key_or_cert_public_key)
|
|
117
|
+
if key_or_cert_public_key.is_a?(OpenSSL::PKey::EC)
|
|
118
|
+
key_or_cert_public_key.public_key.to_bn.to_s(2)
|
|
119
|
+
elsif key_or_cert_public_key.is_a?(OpenSSL::PKey::RSA)
|
|
120
|
+
key_or_cert_public_key.public_key.to_der
|
|
121
|
+
else
|
|
122
|
+
key_or_cert_public_key.to_der
|
|
123
|
+
end
|
|
124
|
+
end
|
|
75
125
|
end
|
|
76
126
|
end
|
data/lib/xades/digest.rb
CHANGED
|
@@ -3,8 +3,28 @@
|
|
|
3
3
|
module Xades
|
|
4
4
|
# @api private
|
|
5
5
|
module Digest
|
|
6
|
+
# This gem always *signs* with SHA-256 (the only algorithm KSeF requires), but documents we
|
|
7
|
+
# need to *verify* may legally use any of these (KSeF's own docs list SHA-1/256/384/512).
|
|
8
|
+
ALGORITHMS = {
|
|
9
|
+
"http://www.w3.org/2000/09/xmldsig#sha1" => OpenSSL::Digest::SHA1,
|
|
10
|
+
"http://www.w3.org/2001/04/xmlenc#sha256" => OpenSSL::Digest::SHA256,
|
|
11
|
+
"http://www.w3.org/2001/04/xmldsig-more#sha384" => OpenSSL::Digest::SHA384,
|
|
12
|
+
"http://www.w3.org/2001/04/xmlenc#sha512" => OpenSSL::Digest::SHA512
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
6
15
|
def self.sha256_base64(bytes)
|
|
7
|
-
|
|
16
|
+
base64(bytes, "http://www.w3.org/2001/04/xmlenc#sha256")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.base64(bytes, algorithm_uri)
|
|
20
|
+
digest_class = ALGORITHMS.fetch(algorithm_uri) do
|
|
21
|
+
raise UnsupportedAlgorithmError, "Unsupported digest algorithm: #{algorithm_uri.inspect}"
|
|
22
|
+
end
|
|
23
|
+
Base64.strict_encode64(digest_class.digest(bytes))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.supported?(algorithm_uri)
|
|
27
|
+
ALGORITHMS.key?(algorithm_uri)
|
|
8
28
|
end
|
|
9
29
|
end
|
|
10
30
|
end
|
data/lib/xades/errors.rb
CHANGED
|
@@ -3,9 +3,27 @@
|
|
|
3
3
|
module Xades
|
|
4
4
|
class Error < StandardError; end
|
|
5
5
|
|
|
6
|
-
# Raised when a key type or curve isn't supported (only RSA and EC P-256/P-384/P-521 are)
|
|
6
|
+
# Raised when a key type or curve isn't supported (only RSA and EC P-256/P-384/P-521 are), or
|
|
7
|
+
# an RSA key is below the minimum size KSeF's docs require (2048 bits).
|
|
7
8
|
class UnsupportedKeyError < Error; end
|
|
8
9
|
|
|
10
|
+
# Raised when a certificate's public key doesn't match the given private key -- almost always a
|
|
11
|
+
# sign of mismatched cert/key files, which would otherwise silently produce a signature that can
|
|
12
|
+
# never verify.
|
|
13
|
+
class CertificateKeyMismatchError < Error; end
|
|
14
|
+
|
|
15
|
+
# Raised by Signer when the certificate is not valid (not yet valid, or expired) at the given
|
|
16
|
+
# signing time. Can be bypassed with Signer.new(..., allow_invalid_certificate_period: true).
|
|
17
|
+
class CertificateValidityError < Error; end
|
|
18
|
+
|
|
19
|
+
# Raised when a document declares a canonicalization or digest algorithm this gem doesn't
|
|
20
|
+
# implement (e.g. C14N 1.1, or a digest other than SHA-256/384/512/SHA-1).
|
|
21
|
+
class UnsupportedAlgorithmError < Error; end
|
|
22
|
+
|
|
9
23
|
# Raised when the input XML cannot be parsed or is missing an expected signature structure.
|
|
10
24
|
class MalformedDocumentError < Error; end
|
|
25
|
+
|
|
26
|
+
# Raised by Verifier.verify!/Bes.verify! (as opposed to .verify, which returns a Result) when the
|
|
27
|
+
# signature is structurally or cryptographically invalid.
|
|
28
|
+
class VerificationError < Error; end
|
|
11
29
|
end
|
data/lib/xades/signer.rb
CHANGED
|
@@ -13,14 +13,18 @@ module Xades
|
|
|
13
13
|
# patch its real digest into the (still embedded) SignedInfo/Reference.
|
|
14
14
|
# 4. Canonicalize the now-final SignedInfo node -- that's the exact byte sequence signed.
|
|
15
15
|
class Signer
|
|
16
|
-
def initialize(certificate:, signing_time: Time.now.utc, signing_certificate_version: :v2, id_prefix: "xades"
|
|
16
|
+
def initialize(certificate:, signing_time: Time.now.utc, signing_certificate_version: :v2, id_prefix: "xades",
|
|
17
|
+
allow_invalid_certificate_period: false)
|
|
17
18
|
@certificate = certificate
|
|
18
|
-
@signing_time = signing_time
|
|
19
|
+
@signing_time = normalize_signing_time(signing_time)
|
|
19
20
|
@signing_certificate_version = signing_certificate_version
|
|
20
21
|
@id_prefix = id_prefix
|
|
22
|
+
@allow_invalid_certificate_period = allow_invalid_certificate_period
|
|
21
23
|
end
|
|
22
24
|
|
|
23
25
|
def sign(xml)
|
|
26
|
+
validate_certificate_period!
|
|
27
|
+
|
|
24
28
|
doc = Nokogiri::XML(xml.to_s)
|
|
25
29
|
raise MalformedDocumentError, "document has no root element" unless doc.root
|
|
26
30
|
|
|
@@ -39,6 +43,26 @@ module Xades
|
|
|
39
43
|
|
|
40
44
|
private
|
|
41
45
|
|
|
46
|
+
# Accepts Time directly, and anything else that can become one (Date, DateTime,
|
|
47
|
+
# ActiveSupport::TimeWithZone all implement #to_time) -- rather than failing later with a bare
|
|
48
|
+
# NoMethodError on #utc deep inside a builder for, say, a plain Date.
|
|
49
|
+
def normalize_signing_time(time)
|
|
50
|
+
return time if time.is_a?(Time)
|
|
51
|
+
return time.to_time if time.respond_to?(:to_time)
|
|
52
|
+
|
|
53
|
+
raise ArgumentError, "signing_time must be a Time (or respond to #to_time), got #{time.class}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def validate_certificate_period!
|
|
57
|
+
return if @allow_invalid_certificate_period
|
|
58
|
+
return if @certificate.valid_at?(@signing_time)
|
|
59
|
+
|
|
60
|
+
reason = @certificate.expired?(at: @signing_time) ? "expired" : "not yet valid"
|
|
61
|
+
raise CertificateValidityError,
|
|
62
|
+
"certificate is #{reason} at #{@signing_time} (validity: #{@certificate.x509.not_before} .. " \
|
|
63
|
+
"#{@certificate.x509.not_after}); pass allow_invalid_certificate_period: true to sign anyway"
|
|
64
|
+
end
|
|
65
|
+
|
|
42
66
|
Ids = Struct.new(:signature, :document_reference, :signed_properties, :key_info, :signature_value)
|
|
43
67
|
|
|
44
68
|
def generate_ids
|
data/lib/xades/verifier.rb
CHANGED
|
@@ -5,6 +5,11 @@ module Xades
|
|
|
5
5
|
# document/SignedProperties digests match and that SignatureValue matches SignedInfo under the
|
|
6
6
|
# embedded certificate's public key.
|
|
7
7
|
#
|
|
8
|
+
# Unlike Signer (which always produces Exclusive C14N / SHA-256 / RSA-SHA256|ECDSA-SHA256, the
|
|
9
|
+
# combination KSeF and modern generators use), Verifier reads and respects whatever
|
|
10
|
+
# canonicalization, digest and signature algorithms a document actually declares -- KSeF's own
|
|
11
|
+
# docs, and real-world signatures from other EU countries, allow several of each. See README.
|
|
12
|
+
#
|
|
8
13
|
# This does NOT validate certificate trust, chain, revocation or validity period -- that is a
|
|
9
14
|
# separate concern (deliberately out of scope for BES-level verification; see the gem's README).
|
|
10
15
|
class Verifier
|
|
@@ -18,6 +23,15 @@ module Xades
|
|
|
18
23
|
new(xml).verify
|
|
19
24
|
end
|
|
20
25
|
|
|
26
|
+
# Like .verify, but raises Xades::VerificationError (with all of Result#errors joined into the
|
|
27
|
+
# message) instead of returning a Result you have to check .valid? on. Returns true on success.
|
|
28
|
+
def self.verify!(xml)
|
|
29
|
+
result = verify(xml)
|
|
30
|
+
return true if result.valid?
|
|
31
|
+
|
|
32
|
+
raise VerificationError, "XAdES signature is invalid: #{result.errors.join("; ")}"
|
|
33
|
+
end
|
|
34
|
+
|
|
21
35
|
def initialize(xml)
|
|
22
36
|
@doc = Nokogiri::XML(xml.to_s)
|
|
23
37
|
end
|
|
@@ -45,14 +59,40 @@ module Xades
|
|
|
45
59
|
Result.new(false, [message])
|
|
46
60
|
end
|
|
47
61
|
|
|
62
|
+
# The algorithm actually used to turn a Reference's node-set into octets is whichever C14N
|
|
63
|
+
# transform is last in its Transforms list. Per the XMLDSig spec, when none of a Reference's
|
|
64
|
+
# transforms is itself a canonicalization method (whether because Transforms is absent, as in a
|
|
65
|
+
# real UK ASC-signed document's SignedProperties Reference, or because the only transform is
|
|
66
|
+
# something else entirely, like enveloped-signature, as in a real Spanish Facturae document's
|
|
67
|
+
# document Reference), the *implicit* default is plain Canonical XML 1.0 -- never Exclusive
|
|
68
|
+
# C14N, regardless of what other non-c14n transforms are present.
|
|
69
|
+
def reference_c14n_algorithm(reference_node)
|
|
70
|
+
transforms = reference_node.xpath(".//*[local-name()='Transform']").map { |t| t["Algorithm"] }
|
|
71
|
+
transforms.reverse.find { |uri| Xades::C14N.supported?(uri) } || Algorithms::C14N_1_0
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def reference_digest(reference_node, canonical_bytes, errors, label)
|
|
75
|
+
algorithm_uri = reference_node.at_xpath("./*[local-name()='DigestMethod']")&.[]("Algorithm")
|
|
76
|
+
return Xades::Digest.base64(canonical_bytes, algorithm_uri) if Xades::Digest.supported?(algorithm_uri)
|
|
77
|
+
|
|
78
|
+
errors << "unsupported #{label} DigestMethod: #{algorithm_uri.inspect}"
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# URI="" is the standard way an enveloped signature's Reference names "the whole document" --
|
|
83
|
+
# our own Signer always emits it this way. Matching on that (rather than just "the first
|
|
84
|
+
# Reference with no Type") matters for documents that carry additional untyped references, e.g.
|
|
85
|
+
# a Reference protecting KeyInfo/the certificate (seen in real-world Facturae signatures).
|
|
48
86
|
def verify_document_digest(_signature_node, signed_info_node, errors)
|
|
49
|
-
reference = signed_info_node.at_xpath(".//*[local-name()='Reference'][
|
|
87
|
+
reference = signed_info_node.at_xpath(".//*[local-name()='Reference'][@URI='']")
|
|
50
88
|
return errors << "missing document Reference in SignedInfo" unless reference
|
|
51
89
|
|
|
52
90
|
expected = reference.at_xpath("./*[local-name()='DigestValue']")&.text
|
|
53
91
|
clone = @doc.dup(1)
|
|
54
92
|
clone.at_xpath("//*[local-name()='Signature']").unlink
|
|
55
|
-
|
|
93
|
+
canonical = Xades::C14N.canonicalize(clone.root, reference_c14n_algorithm(reference))
|
|
94
|
+
actual = reference_digest(reference, canonical, errors, "document")
|
|
95
|
+
return unless actual
|
|
56
96
|
|
|
57
97
|
errors << "document digest mismatch" unless expected == actual
|
|
58
98
|
end
|
|
@@ -67,48 +107,43 @@ module Xades
|
|
|
67
107
|
return errors << "missing SignedProperties element" unless signed_properties_node
|
|
68
108
|
|
|
69
109
|
expected = reference.at_xpath("./*[local-name()='DigestValue']")&.text
|
|
70
|
-
|
|
110
|
+
canonical = Xades::C14N.canonicalize(signed_properties_node, reference_c14n_algorithm(reference))
|
|
111
|
+
actual = reference_digest(reference, canonical, errors, "SignedProperties")
|
|
112
|
+
return unless actual
|
|
71
113
|
|
|
72
114
|
errors << "SignedProperties digest mismatch" unless expected == actual
|
|
73
115
|
end
|
|
74
116
|
|
|
75
|
-
KNOWN_SIGNATURE_METHODS = [Algorithms::SIGNATURE_RSA_SHA256, Algorithms::SIGNATURE_ECDSA_SHA256].freeze
|
|
76
|
-
|
|
77
117
|
def verify_signature_value(signature_node, signed_info_node, errors)
|
|
78
118
|
signature_value_node = signature_node.at_xpath("./*[local-name()='SignatureValue']")
|
|
79
119
|
cert_text = signature_node.at_xpath(".//*[local-name()='X509Certificate']")&.text
|
|
80
120
|
return errors << "missing SignatureValue or X509Certificate" unless signature_value_node && cert_text
|
|
81
121
|
|
|
82
122
|
signature_method = signed_info_node.at_xpath("./*[local-name()='SignatureMethod']")&.[]("Algorithm")
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
end
|
|
123
|
+
algorithm = Algorithms::SIGNATURE_METHODS[signature_method]
|
|
124
|
+
return errors << "unsupported SignatureMethod: #{signature_method.inspect}" unless algorithm
|
|
86
125
|
|
|
87
126
|
cert = OpenSSL::X509::Certificate.new(Base64.decode64(cert_text))
|
|
88
127
|
raw_signature = Base64.decode64(signature_value_node.text)
|
|
89
|
-
|
|
128
|
+
c14n_algorithm = signed_info_node.at_xpath("./*[local-name()='CanonicalizationMethod']")&.[]("Algorithm")
|
|
129
|
+
bytes = Xades::C14N.canonicalize(signed_info_node, c14n_algorithm || Algorithms::C14N_EXCLUSIVE)
|
|
90
130
|
|
|
91
|
-
|
|
92
|
-
|
|
131
|
+
valid = signature_valid?(cert, algorithm, raw_signature, bytes)
|
|
132
|
+
errors << "signature does not match certificate" unless valid
|
|
93
133
|
end
|
|
94
134
|
|
|
95
|
-
def signature_valid?(cert,
|
|
96
|
-
|
|
97
|
-
|
|
135
|
+
def signature_valid?(cert, algorithm, raw_signature, bytes)
|
|
136
|
+
digest = OpenSSL::Digest.new(algorithm[:digest])
|
|
137
|
+
der = to_der_signature(algorithm, raw_signature)
|
|
138
|
+
cert.public_key.verify(digest, der, bytes)
|
|
98
139
|
end
|
|
99
140
|
|
|
100
|
-
def to_der_signature(
|
|
101
|
-
return raw_signature unless
|
|
141
|
+
def to_der_signature(algorithm, raw_signature)
|
|
142
|
+
return raw_signature unless algorithm[:key_type] == :ecdsa
|
|
102
143
|
|
|
103
144
|
EcdsaSignature.raw_to_der(raw_signature, raw_signature.bytesize / 2)
|
|
104
145
|
end
|
|
105
146
|
|
|
106
|
-
DIGEST_ALGORITHMS = {
|
|
107
|
-
"http://www.w3.org/2001/04/xmlenc#sha256" => OpenSSL::Digest::SHA256,
|
|
108
|
-
"http://www.w3.org/2001/04/xmlenc#sha512" => OpenSSL::Digest::SHA512,
|
|
109
|
-
"http://www.w3.org/2000/09/xmldsig#sha1" => OpenSSL::Digest::SHA1
|
|
110
|
-
}.freeze
|
|
111
|
-
|
|
112
147
|
CERT_DIGEST_XPATH = ".//*[local-name()='SigningCertificate' or local-name()='SigningCertificateV2']" \
|
|
113
148
|
"//*[local-name()='CertDigest']"
|
|
114
149
|
|
|
@@ -130,11 +165,12 @@ module Xades
|
|
|
130
165
|
|
|
131
166
|
def verify_cert_digest(cert_digest_node, cert_text, errors)
|
|
132
167
|
algorithm_uri = cert_digest_node.at_xpath("./*[local-name()='DigestMethod']")&.[]("Algorithm")
|
|
133
|
-
|
|
134
|
-
|
|
168
|
+
unless Xades::Digest.supported?(algorithm_uri)
|
|
169
|
+
return errors << "unsupported CertDigest algorithm: #{algorithm_uri.inspect}"
|
|
170
|
+
end
|
|
135
171
|
|
|
136
172
|
expected = cert_digest_node.at_xpath("./*[local-name()='DigestValue']")&.text
|
|
137
|
-
actual =
|
|
173
|
+
actual = Xades::Digest.base64(Base64.decode64(cert_text), algorithm_uri)
|
|
138
174
|
|
|
139
175
|
errors << "SigningCertificate digest does not match the embedded certificate" unless expected == actual
|
|
140
176
|
end
|
data/lib/xades/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: xades
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Chris Hasinski
|
|
@@ -83,7 +83,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
83
83
|
requirements:
|
|
84
84
|
- - ">="
|
|
85
85
|
- !ruby/object:Gem::Version
|
|
86
|
-
version:
|
|
86
|
+
version: 4.0.0
|
|
87
87
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
88
88
|
requirements:
|
|
89
89
|
- - ">="
|