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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 73fcbd3237d890cc28a31a94cff79c09f8fe7eadb7a0f453f8a45c78791a8d52
4
- data.tar.gz: dae7a067ede1c4d6567efab5654a40bbf9d7675a4c8174cfc23f6ddfaf16231b
3
+ metadata.gz: 7dbb47d18cc2a03f990e291863d60b689eafb5543e55635a992c347dcb113889
4
+ data.tar.gz: 21d1f51cb0f1457c65d85839ef7491398f48fe8bfb6ef3051cf9f0bc3bc73a00
5
5
  SHA512:
6
- metadata.gz: 4fbf97d128b02319f277d72a99e7a7891443f2f371cb62ab63d5d5f424657b72bc91c55125a936edd8686d636395fcd676a4b573a608da94079b897c23cfcade
7
- data.tar.gz: 63ccb7b27ea142234fb3e8fc889ff5194d7a2f9504921c817f998c2dd26f1222218c59bc6d882286f676ab3f384e5ee9ed2577e874211c53ba4276a02ae16298
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 -- most concretely, **Poland's KSeF 2.0** e-invoicing system,
11
- where authenticating with a certificate means signing an `AuthTokenRequest` document with
12
- XAdES-BES (see [CIRFMF/ksef-docs](https://github.com/CIRFMF/ksef-docs)).
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
- Implements **XAdES-BES / BASELINE-B** only:
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
- **Not implemented** (out of scope for this MVP): XAdES-T/-LT/-LTA (timestamps, revocation data,
26
- archival), CAdES/PAdES, enveloping or detached signature formats, HSM integration, and certificate
27
- trust/chain/revocation validation. `Xades::Bes.verify` checks that the signature is *structurally
28
- and cryptographically correct against the certificate embedded in the document* -- it does not
29
- tell you whether that certificate should be trusted.
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/`), verifying real-world XAdES documents vendored from other
103
- projects' test suites, plus a golden fixture built against KSeF's published `AuthTokenRequest`
104
- schema and XAdES parameter constraints
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
 
@@ -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 Exclusive XML Canonicalization (C14N 1.0, no comments),
5
- # the only canonicalization method KSeF / ETSI XAdES-BASELINE-B require.
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
- MODE = Nokogiri::XML::XML_C14N_EXCLUSIVE_1_0
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
- node.canonicalize(MODE)
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
@@ -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
- unless key.is_a?(OpenSSL::PKey::RSA) || key.is_a?(OpenSSL::PKey::EC)
10
- raise UnsupportedKeyError,
11
- "key must be an RSA or EC private key"
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
- Base64.strict_encode64(OpenSSL::Digest::SHA256.digest(bytes))
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
@@ -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'][not(@Type)]")
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
- actual = Xades::Digest.sha256_base64(Xades::C14N.canonicalize(clone.root))
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
- actual = Xades::Digest.sha256_base64(Xades::C14N.canonicalize(signed_properties_node))
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
- unless KNOWN_SIGNATURE_METHODS.include?(signature_method)
84
- return errors << "unsupported SignatureMethod: #{signature_method.inspect}"
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
- bytes = Xades::C14N.canonicalize(signed_info_node)
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
- errors << "signature does not match certificate" unless signature_valid?(cert, signature_method, raw_signature,
92
- bytes)
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, signature_method, raw_signature, bytes)
96
- der = to_der_signature(signature_method, raw_signature)
97
- cert.public_key.verify(OpenSSL::Digest.new("SHA256"), der, bytes)
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(signature_method, raw_signature)
101
- return raw_signature unless signature_method == Algorithms::SIGNATURE_ECDSA_SHA256
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
- digest_class = DIGEST_ALGORITHMS[algorithm_uri]
134
- return errors << "unsupported CertDigest algorithm: #{algorithm_uri.inspect}" unless digest_class
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 = Base64.strict_encode64(digest_class.digest(Base64.decode64(cert_text)))
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Xades
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
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.1.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: 3.2.0
86
+ version: 4.0.0
87
87
  required_rubygems_version: !ruby/object:Gem::Requirement
88
88
  requirements:
89
89
  - - ">="