diff --git a/README.adoc b/README.adoc index 281813b3..7d5d36c3 100644 --- a/README.adoc +++ b/README.adoc @@ -559,6 +559,70 @@ ruby examples/api_client/api_client.rb See the link:examples/README.md[examples README] for complete documentation and learning paths. +== XML Signature (Moxml::Signature) + +Moxml includes an XML-implementation-agnostic implementation of W3C XML +Signature (xmldsig-core-1.1). Sign and verify documents with any moxml +adapter; the canonicalization engine produces byte-exact output that +cross-verifies with libxmlsec1. + +=== Quick start + +[source,ruby] +---- +require "moxml" +require "moxml/signature" +require "openssl" + +ctx = Moxml.new(:nokogiri) +key = OpenSSL::PKey::RSA.generate(2048) +doc = ctx.parse("Hello, World!") + +# Sign +signature = Moxml::Signature.sign( + context: ctx, document: doc, key: key, + signature_method: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + canonicalization_method: "http://www.w3.org/2001/10/xml-exc-c14n#", + digest_method: "http://www.w3.org/2001/04/xmlenc#sha256", + reference_uri: "", + transforms: ["http://www.w3.org/2000/09/xmldsig#enveloped-signature"], +) +serialized = Moxml::Signature::Serializer.new(context: ctx).serialize(signature) +doc.root.add_child(serialized.root) + +# Verify (auto-extracts key from KeyInfo if absent) +result = Moxml::Signature.verify(context: ctx, document: doc, key: key) +result.valid? # => true +---- + +=== Supported algorithms + +Digests:: SHA-1, SHA-224, SHA-256, SHA-384, SHA-512 +Signature methods:: RSA-PKCS1v1.5, HMAC, ECDSA (P-256/P-384/P-521), DSA +Canonicalization:: Exclusive C14N 1.0, Inclusive C14N 1.0, Inclusive C14N 1.1 +Transforms:: base64, Enveloped Signature + +Adding a custom algorithm means declaring `identifier "http://..."` on a +subclass — no edits to existing code. See +link:docs/signature/algorithms.md[the algorithms doc]. + +=== Canonicalization (Moxml::C14n) + +C14N is a top-level moxml feature (sibling to XPath, Builder, SAX). +Inclusive C14N is ported from the sibling `canon` gem; Exclusive C14N +is moxml-native. See link:docs/signature/c14n.md[the C14N doc]. + +=== Documentation + +* link:docs/signature/architecture.md[Architecture] +* link:docs/signature/algorithms.md[Algorithm registry] +* link:docs/signature/c14n.md[Canonicalization] +* link:docs/signature/flows.md[Signer and Verifier flows] +* link:docs/signature/key-extraction.md[Key extraction] (TODO) +* link:docs/signature/security.md[Security considerations] +* link:docs/signature/quick-reference.md[Quick reference] +* link:examples/signature/[Runnable examples] + == Working with documents === Using the builder pattern diff --git a/docs/signature/algorithms.md b/docs/signature/algorithms.md new file mode 100644 index 00000000..86c4774b --- /dev/null +++ b/docs/signature/algorithms.md @@ -0,0 +1,108 @@ +# Algorithm registry + +The `Moxml::Signature::Algorithms` module is the open-closed hub for +all W3C XML Signature algorithms. Algorithms are identified by URI; +the registry maps URI → class for each of four categories: + +| Category | W3C spec section | Base class | +| -------------------- | ---------------- | --------------------------------------------- | +| `:digest` | §6.2 | `Algorithms::DigestBase` | +| `:signature_method` | §6.3, §6.4 | `Algorithms::SignatureMethodBase` | +| `:canonicalization` | §6.5 | `Algorithms::CanonicalizationBase` | +| `:transform` | §6.6 | `Algorithms::TransformBase` | + +## Built-in algorithms + +### Digests (§6.2) + +| URI | Class | +| ---------------------------------------------------------- | ---------------- | +| `http://www.w3.org/2000/09/xmldsig#sha1` | `SHA1` | +| `http://www.w3.org/2001/04/xmldsig-more#sha224` | `SHA224` | +| `http://www.w3.org/2001/04/xmlenc#sha256` (REQUIRED) | `SHA256` | +| `http://www.w3.org/2001/04/xmldsig-more#sha384` | `SHA384` | +| `http://www.w3.org/2001/04/xmlenc#sha512` | `SHA512` | + +### Signature methods (§6.3, §6.4) + +| URI | Class | Notes | +| ---------------------------------------------------------- | ---------------- | ----- | +| `…xmldsig#rsa-sha1` | `RsaPkcs1Sha` | Verification only (BP: SHA-1 discouraged) | +| `…xmldsig-more#rsa-sha224` | `RsaPkcs1Sha` | | +| `…xmldsig-more#rsa-sha256` (REQUIRED) | `RsaPkcs1Sha` | | +| `…xmldsig-more#rsa-sha384` | `RsaPkcs1Sha` | | +| `…xmldsig-more#rsa-sha512` | `RsaPkcs1Sha` | | +| `…xmldsig#hmac-sha1` | `HmacSha` | Truncation enforced per §4.4.2 | +| `…xmldsig-more#hmac-sha224` | `HmacSha` | | +| `…xmldsig-more#hmac-sha256` (REQUIRED) | `HmacSha` | | +| `…xmldsig-more#hmac-sha384` | `HmacSha` | | +| `…xmldsig-more#hmac-sha512` | `HmacSha` | | +| `…xmldsig-more#ecdsa-sha1` | `EcdsaSha` | | +| `…xmldsig-more#ecdsa-sha224` | `EcdsaSha` | | +| `…xmldsig-more#ecdsa-sha256` (REQUIRED) | `EcdsaSha` | P-256/P-384/P-521 | +| `…xmldsig-more#ecdsa-sha384` | `EcdsaSha` | | +| `…xmldsig-more#ecdsa-sha512` | `EcdsaSha` | | +| `…xmldsig#dsa-sha1` | `DsaSha` | | +| `…xmldsig11#dsa-sha256` | `DsaSha` | | + +### Canonicalization (§6.5) + +| URI | Engine | +| ---------------------------------------------------------- | ---------------------------- | +| `http://www.w3.org/TR/2001/REC-xml-c14n-20010315` | `Moxml::C14n::Inclusive10` (canon-ported) | +| `…REC-xml-c14n-20010315#WithComments` | same, `with_comments: true` | +| `http://www.w3.org/2006/12/xml-c14n11` | `Moxml::C14n::Inclusive11` | +| `…xml-c14n11#WithComments` | same, `with_comments: true` | +| `http://www.w3.org/2001/10/xml-exc-c14n#` | `Moxml::C14n::Exclusive` (moxml-native) | +| `…xml-exc-c14n#WithComments` | same, `with_comments: true` | + +### Transforms (§6.6) + +| URI | Class | +| ---------------------------------------------------------- | ------------------------------ | +| `http://www.w3.org/2000/09/xmldsig#base64` | `Base64Transform` | +| `http://www.w3.org/2000/09/xmldsig#enveloped-signature` | `EnvelopedSignatureTransform` | + +Canonicalization algorithms can also be used as transforms per §6.6.1. +The `TransformPipeline` looks them up in the canonicalization registry +as a fallback. + +## Adding a custom algorithm + +```ruby +require "moxml/signature" + +module MyAlgo + class SHA3_256 < Moxml::Signature::Algorithms::DigestBase + identifier "http://www.w3.org/2007/xmldsig-more#sha3-256" + + def compute_digest(data) + OpenSSL::Digest.digest("SHA3-256", data) + end + end +end + +# Now the URI resolves: +Moxml::Signature::Algorithms.lookup( + :digest, + "http://www.w3.org/2007/xmldsig-more#sha3-256", +) +# => MyAlgo::SHA3_256 +``` + +The `identifier` declaration registers the class on load. No edits to +existing code are required — pure OCP. + +## API + +```ruby +Algorithms.lookup(:digest, uri) # → class, raises UnknownAlgorithm +Algorithms.registered?(:digest, uri) # → bool +Algorithms[:digest] # → { uri => class, ... } +``` + +## Lazy loading + +The registry autoloads built-in algorithm classes on first lookup +(`load_builtins!` references each constant, triggering autoload). +Custom algorithms register themselves on `require` of their file. diff --git a/docs/signature/architecture.md b/docs/signature/architecture.md new file mode 100644 index 00000000..2d2401db --- /dev/null +++ b/docs/signature/architecture.md @@ -0,0 +1,148 @@ +# Moxml::Signature — Architecture + +## Where it lives + +`Moxml::Signature` is a sub-module of moxml that implements W3C XML +Signature (xmldsig-core-1.1). It is **XML implementation agnostic** — +every XML operation flows through `Moxml::Document` / `Moxml::Element`, +so the same signature code works whether you parse with Nokogiri, Oga, +REXML, Ox, or LibXML. + +C14N itself is a top-level `Moxml::C14n` feature, sibling to +`Moxml::XPath`, `Moxml::Builder`, and `Moxml::SAX`. Signature uses it; +so can any other consumer (e.g., the sibling `canon` gem). + +## Module layout + +``` +lib/moxml.rb # top-level, autoloads Signature and C14n +lib/moxml/signature.rb # Signature namespace + .sign/.verify entry points + +lib/moxml/signature/errors.rb # error hierarchy +lib/moxml/signature/algorithms.rb # OCP registry hub +lib/moxml/signature/algorithms/ # concrete algorithms (digests, sig methods, transforms) +lib/moxml/signature/model/ # PORO models +lib/moxml/signature/serializer.rb # model → XML (uses moxml primitives) +lib/moxml/signature/parser.rb # XML → model +lib/moxml/signature/reference_resolver.rb # Reference URI → node-set / octets +lib/moxml/signature/transform_pipeline.rb # shared transform chain (DRY) +lib/moxml/signature/signer.rb # spec §3.1 signing flow +lib/moxml/signature/verifier.rb # spec §3.2 verification flow +lib/moxml/signature/key_extractor.rb # X509 / RSA / DSA / EC / KeyName → OpenSSL key +lib/moxml/signature/verification_result.rb +lib/moxml/signature/single_verification_result.rb +lib/moxml/signature/reference_result.rb + +lib/moxml/c14n.rb # top-level C14n namespace +lib/moxml/c14n/ # canon-ported engine + moxml-native Exclusive +``` + +## Layering + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Application code │ +│ Moxml::Signature.sign / .verify │ +└──────────────────────────────────────────────────────────────────┘ + ↕ +┌──────────────────────────────────────────────────────────────────┐ +│ Orchestration: Signer, Verifier, TransformPipeline, KeyExtractor│ +└──────────────────────────────────────────────────────────────────┘ + ↕ +┌──────────────────────┐ ┌──────────────────────────────────────┐ +│ Algorithms (OCP hub) │ │ Models: Signature, SignedInfo, etc. │ +└──────────────────────┘ └──────────────────────────────────────┘ + ↕ +┌──────────────────────────────────────────────────────────────────┐ +│ Moxml::C14n (canon-ported Inclusive + moxml-native Exclusive) │ +└──────────────────────────────────────────────────────────────────┘ + ↕ +┌──────────────────────────────────────────────────────────────────┐ +│ Moxml::Document / Element / Text / Namespace / Attribute │ +│ (adapter-agnostic — Nokogiri, Oga, REXML, Ox, LibXML) │ +└──────────────────────────────────────────────────────────────────┘ + ↕ + OpenSSL (crypto) +``` + +## Core design decisions + +### 1. Algorithm registry as the OCP hub + +Every W3C algorithm (digest, signature method, canonicalization, +transform) is identified by URI. The `Moxml::Signature::Algorithms` +module is the registry; adding a new algorithm means: + +1. Subclass the relevant base (`DigestBase`, `SignatureMethodBase`, + `CanonicalizationBase`, `TransformBase`). +2. Declare `identifier "http://..."` on the subclass. +3. Add an autoload entry in `algorithms.rb` and a reference in + `load_builtins!`. + +No edits to existing code. The registry has four categories: +`:digest`, `:signature_method`, `:canonicalization`, `:transform`. + +### 2. Models are POROs; serialization is a service + +Models (`Model::Signature`, `Model::SignedInfo`, `Model::Reference`, +etc.) are plain Ruby objects with `attr_accessor`. They do **not** own +their wire shape. A dedicated `Serializer` translates model → XML using +moxml primitives; `Parser` translates XML → model. This keeps the data +shape and the wire shape independent, and matches the user's global +rule ("no hand-rolled serialization on model classes"). + +### 3. Signer / Verifier orchestrate, don't compute + +`Signer` walks the references, delegates to the transform pipeline, +computes digests via `DigestMethod` instances, and signs the +canonicalized SignedInfo. It contains no algorithm-specific logic. + +`Verifier` follows Best Practice 1: authenticate SignatureValue first, +then run reference transforms. Errors are captured into the result +object (`SingleVerificationResult#error`), not raised, so a malicious +signature cannot panic the application. + +### 4. TransformPipeline is shared by Signer and Verifier + +The transform-chain logic (lookup algorithm, coerce input type, apply, +repeat) is the same for signing and verifying. It lives in +`TransformPipeline` — DRY. + +### 5. C14N is shared infrastructure + +Inclusive C14N is ported from `~/src/lutaml/canon` (mature, ~1,200 +lines, full node-set subset support, xml:base fixup, xml:* inheritable +attribute resolution). Exclusive C14N is moxml-native (canon doesn't +implement it). Both expose the same `#canonicalize(node, with_comments:, +inclusive_namespaces:)` interface. + +## Adapter-agnostic invariant + +Every XML operation — parse, walk, serialize, canonicalize — goes +through `Moxml::Node`. The signature module never imports Nokogiri, +Oga, REXML, Ox, or LibXML directly. Switching adapters does not change +signature behavior. + +The one exception is the `context:` parameter threaded through every +constructor. When a transform receives octet-stream input, it parses +with the same adapter the caller used (`context.parse(...)`), preserving +byte-exact canonicalization across adapters. + +## Cross-verification + +`spec/fixtures/xmldsig/sign2-result.xml` and `sign3-result.xml` are +real libxmlsec1-produced signatures (from the Ruby +`nokogiri-xmlsec-instructure` reference). Both verify byte-exact against +`Moxml::Signature.verify`, proving the C14N and signing logic matches a +battle-tested C implementation. + +## What this module deliberately doesn't do + +- **XPath Filter transform** — Best Practice 5 says avoid. The Enveloped + Signature transform walks ancestors directly, no XPath needed. +- **XSLT transform** — Best Practice 3 says avoid. Disabled. +- **External URI dereferencing** — Best Practice 8 says constrain. + Applications must provide their own resolver. +- **X.509 chain validation** — application responsibility (trust policy). +- **XML Encryption** — separate spec (xmlenc-core-1.1). +- **XAdES** — separate spec (ETSI TS 101 903). diff --git a/docs/signature/c14n.md b/docs/signature/c14n.md new file mode 100644 index 00000000..a6cd8aea --- /dev/null +++ b/docs/signature/c14n.md @@ -0,0 +1,110 @@ +# Canonicalization (C14N) + +Canonicalization is the load-bearing primitive for XML signature. +Two documents that differ only in surface representation (whitespace, +attribute order, namespace prefix choice) must produce identical +canonical bytes — otherwise signatures won't verify. + +`Moxml::C14n` is a top-level moxml feature, sibling to `Moxml::XPath`, +`Moxml::Builder`, and `Moxml::SAX`. + +## Algorithms + +### Inclusive C14N 1.0 / 1.1 + +- W3C: , +- Implementation: **ported from `~/src/lutaml/canon`** (~1,200 lines) +- Files: `lib/moxml/c14n/inclusive_10.rb`, `inclusive_11.rb`, + `data_model.rb`, `processor.rb`, `namespace_handler.rb`, + `attribute_handler.rb`, `xml_base_handler.rb`, `character_encoder.rb`, + `node.rb`, `nodes/*.rb` +- "Attracts" ancestor context: at the apex, every in-scope namespace is + rendered, including those inherited from outside the canonicalization + subset. + +### Exclusive C14N 1.0 + +- W3C: +- Implementation: **moxml-native** (canon does not implement exclusive) +- Files: `lib/moxml/c14n/exclusive.rb`, `writer.rb`, `namespace_context.rb` +- "Repels" ancestor context: only namespaces visibly used by the apex + element's qualified name or attributes are rendered. Keeps signatures + valid when subdocuments are moved between XML contexts (e.g., into a + SOAP envelope). + +## Public API + +```ruby +# Convenience: inclusive C14N 1.0 +Moxml::C14n.canonicalize(node_or_xml, with_comments: false) +# → canonical UTF-8 octet String + +# Convenience: exclusive C14N 1.0 +Moxml::C14n.canonicalize_exclusive( + node_or_xml, + with_comments: false, + inclusive_namespaces: [], # InclusiveNamespacesPrefixList parameter +) +# → canonical UTF-8 octet String + +# Direct engine access (used by signature algorithms) +Moxml::C14n::Inclusive10.new.canonicalize(node, with_comments:, inclusive_namespaces:) +Moxml::C14n::Inclusive11.new.canonicalize(node, with_comments:, inclusive_namespaces:) +Moxml::C14n::Exclusive.new.canonicalize(node, with_comments:, inclusive_namespaces:) +``` + +`node_or_xml` accepts a `Moxml::Node`, `Moxml::Document`, or XML `String`. + +## Data model + +The canon-derived inclusive C14N walks an intermediate data model +(`Moxml::C14n::Nodes::*`) rather than the live `Moxml::Node` tree. This +is because canonicalization needs: + +- **Node-set membership flags** for subset canonicalization (spec §3). + Same-document references select a node-set; only selected nodes are + rendered. +- **Sorted namespace and attribute axes** per spec §2.3 / §2.4. +- **xml:base fixup** per RFC 3986 with C14N 1.1 modifications. +- **xml:* inheritable attribute resolution** (xml:lang, xml:space) from + omitted ancestors. + +The data model is built from `Moxml::Node` via `Moxml::C14n::DataModel`. + +## Output invariants + +All algorithms produce canonical octets with these properties: + +- UTF-8 encoded, no BOM +- NFC characters preserved +- Document-order traversal +- Entity references: `&` → `&`, `<` → `<`, `>` → `>` +- Attribute values: also escape `"`, tab, LF, CR +- Empty elements expanded: `` → `` +- Line endings normalized to LF (XML parser already does this) + +## Why not delegate to Nokogiri's native C14N? + +Nokogiri (via libxml2) has both inclusive and exclusive C14N built-in, +and it's much faster than pure Ruby. But: + +1. **XML-agnosticity.** Moxml's whole point is that switching adapters + doesn't change behavior. If C14N were Nokogiri-only, Oga/REXML/Ox + users would get no canonicalization. +2. **Adapter-coupled canonicalization breaks cross-adapter signature + verification.** A signature produced with Nokogiri must verify with + Oga — same canonical bytes. +3. **Canon already had the mature pure-Ruby implementation.** Porting it + was less work than building a hybrid adapter-delegated system. + +**Future work:** add an optional adapter-level C14N delegation. The +Nokogiri adapter could expose `canonicalize(node, ...)` and the +`Moxml::C14n` top-level method would delegate when available. This is +documented in TODO.complete/19. + +## Cross-verification + +The libxmlsec1-produced fixtures in `spec/fixtures/xmldsig/` verify +byte-exact against our C14N output. This proves the implementation +matches libxml2's C-based canonicalization for the cases the Ruby +reference exercises. diff --git a/docs/signature/examples.md b/docs/signature/examples.md new file mode 100644 index 00000000..8f6b8ee4 --- /dev/null +++ b/docs/signature/examples.md @@ -0,0 +1,109 @@ +# Examples + +Runnable examples live in `examples/signature/`. Run with: + +```bash +bundle exec ruby examples/signature/enveloped_rsa.rb +bundle exec ruby examples/signature/hmac.rb +bundle exec ruby examples/signature/auto_key_extraction.rb +``` + +## Minimal enveloped RSA-SHA256 signature + +```ruby +require "moxml" +require "moxml/signature" +require "openssl" + +ctx = Moxml.new(:nokogiri) +key = OpenSSL::PKey::RSA.generate(2048) + +doc = ctx.parse("Hello, World!") + +# Sign — produces a Model::Signature +signature = Moxml::Signature.sign( + context: ctx, + document: doc, + key: key, + signature_method: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + canonicalization_method: "http://www.w3.org/2001/10/xml-exc-c14n#", + digest_method: "http://www.w3.org/2001/04/xmlenc#sha256", + reference_uri: "", + transforms: ["http://www.w3.org/2000/09/xmldsig#enveloped-signature"], +) + +# Serialize the signature into XML and attach to the document +serialized = Moxml::Signature::Serializer.new(context: ctx).serialize(signature) +doc.root.add_child(serialized.root) + +puts doc.to_xml +``` + +## Verify with the public key + +```ruby +result = Moxml::Signature.verify( + context: ctx, + document: doc, + key: key, # private key works; public key alone is enough +) + +puts "Valid: #{result.valid?}" +puts "Signature count: #{result.signature_count}" +result.results.each do |r| + puts " signature_valid=#{r.signature_valid?}" + r.references.each { |ref| puts " ref #{ref.uri.inspect}: #{ref.valid?}" } +end +``` + +## Auto-extract key from X509Certificate + +```ruby +# sign3-result.xml embeds the signing certificate in KeyInfo. +# No explicit key is needed — the Verifier extracts it automatically. +doc = ctx.parse(File.read("spec/fixtures/xmldsig/sign3-result.xml")) +result = Moxml::Signature.verify(context: ctx, document: doc) +puts "Auto-extracted: #{result.valid?}" +``` + +## KeyName-based key resolution + +```ruby +# Signer used my-key; verifier resolves via key_map: +result = Moxml::Signature.verify( + context: ctx, + document: doc, + key_map: { "my-key" => trusted_public_key }, +) +``` + +## HMAC with truncation + +```ruby +signature = Moxml::Signature.sign( + context: ctx, + document: doc, + key: "shared-secret", + signature_method: "http://www.w3.org/2001/04/xmldsig-more#hmac-sha256", + canonicalization_method: "http://www.w3.org/2001/10/xml-exc-c14n#", + digest_method: "http://www.w3.org/2001/04/xmlenc#sha256", + reference_uri: "", + transforms: ["http://www.w3.org/2000/09/xmldsig#enveloped-signature"], +) +``` + +HMAC `OutputLength` truncation below `max(hash_bits/2, 80)` is rejected. + +## Adding a custom algorithm + +```ruby +class MyDigest < Moxml::Signature::Algorithms::DigestBase + identifier "http://example.com/my-digest" + + def compute_digest(data) + OpenSSL::Digest.digest("SHA3-256", data) + end +end + +# Now usable in any Reference#digest_method +``` diff --git a/docs/signature/flows.md b/docs/signature/flows.md new file mode 100644 index 00000000..8a34de66 --- /dev/null +++ b/docs/signature/flows.md @@ -0,0 +1,132 @@ +# Signing and verification flows + +## Signer (spec §3.1) + +``` +Moxml::Signature.sign(context:, document:, key:, **options) + │ + ▼ +build a Model::Signature with the requested algorithms + │ + ▼ +Signer#sign + │ + ├── for each Reference in SignedInfo: + │ ├── ReferenceResolver.resolve(uri) → node or octets + │ ├── TransformPipeline.apply(input, transforms) + │ ├── TransformPipeline.to_octets(transformed, reference) + │ └── DigestMethod#digest_base64(canonical) → Reference#digest_value + │ + ├── Serializer.serialize_signed_info(signed_info) → Moxml::Document + ├── CanonicalizationMethod#canonicalize(signed_info_root) → octets + └── SignatureMethod#sign(canonical_octets, key) → SignatureValue +``` + +The Signer never touches algorithms directly — every step delegates to +the algorithm registry. This is OCP: new algorithms are added without +touching the Signer. + +## Verifier (spec §3.2 + Best Practice 1) + +``` +Moxml::Signature.verify(context:, document:, key: nil, key_map: {}) + │ + ▼ +find all ds:Signature elements in the document + │ + ▼ for each Signature +verify_one(signature_element) + │ + ├── Parser.parse(signature_element) → Model::Signature + │ + ├── verify_signature_value FIRST (Best Practice 1) + │ ├── find original SignedInfo element (do NOT re-serialize) + │ ├── CanonicalizationMethod#canonicalize(signed_info_elem) + │ ├── resolve_key(signature) (KeyExtractor if no key:) + │ └── SignatureMethod#verify(canonical, key, sig_value) + │ + └── if signature value verifies: + for each Reference: + ├── ReferenceResolver.resolve(uri) + ├── TransformPipeline.apply(...) + ├── TransformPipeline.to_octets(...) + └── DigestMethod#digest_base64 vs Reference#digest_value +``` + +### Best Practice 1: authenticate before transforms + +The verifier deliberately runs `SignatureValue` validation BEFORE +applying any transforms. Reason: a malicious signature could include +XSLT or expensive XPath transforms. Best Practice 1 says only run +those after authenticating the signer. + +If `SignatureValue` doesn't verify, we skip reference transforms +entirely. The result object reports `signature_valid?: false` and +`references: []`. + +### Why we canonicalize the original element + +The verifier canonicalizes the SignedInfo **as it appears in the +document**, not a re-serialized model. Reason: re-serialization could +change namespace prefixes (e.g., default-namespace SignedInfo might +become `ds:SignedInfo`), breaking the byte-exact match the signature +was computed against. + +The Signer serializes (it has to — it's building new XML). The Verifier +has the original element from the document and uses it directly. This +asymmetry is intentional and necessary for cross-verification with +libxmlsec1-produced signatures. + +### Errors are captured, not raised + +`SingleVerificationResult#error` carries any `VerificationError` or +`UnknownAlgorithm` that caused failure. The verifier returns a result +object rather than raising, so a hostile signature cannot panic the +application. Use `result.results.first.error` for debugging. + +## Key resolution + +```ruby +Moxml::Signature.verify(context:, document:) # no key: +``` + +When no `key:` is passed, the Verifier uses `KeyExtractor` to derive +one from the signature's `KeyInfo`: + +1. **X509Certificate** (preferred) — decode base64 DER → + `OpenSSL::X509::Certificate` → `.public_key` +2. **RSAKeyValue** — reconstruct via ASN.1 (OpenSSL 3.x dropped + `RSA.new(n, e)`) +3. **DSAKeyValue** — reconstruct via ASN.1 +4. **ECKeyValue** — build SubjectPublicKeyInfo DER for the named curve + (OpenSSL 3.x made PKey immutable) +6. **KeyName** — look up in the application-supplied `key_map:` + +OpenSSL 3.x compatibility notes are inline in `key_extractor.rb`. + +## Transform pipeline + +`TransformPipeline` is shared by Signer and Verifier. Each Transform +in the chain declares `input_type` (`:octets` or `:nodeset`) and +`output_type`; the pipeline coerces types between transforms per +spec §4.4.3.2: + +- `octets → nodeset`: parse as XML via the caller's `context:` +- `nodeset → octets`: apply inclusive C14N 1.0 + +Final conversion to octets (for digesting) uses the last canonicalization +transform in the chain if any, else inclusive C14N 1.0 as the spec default. + +## Result objects + +```ruby +result = Moxml::Signature.verify(context:, document:, key:) +result.valid? # → bool (all signatures valid) +result.signature_count # → Integer +result.results # → [SingleVerificationResult, ...] +result.results.first.signature_valid? +result.results.first.references # → [ReferenceResult, ...] +result.results.first.references.first.valid? +result.results.first.error # → Exception or nil +result.failing # → [SingleVerificationResult, ...] +``` diff --git a/docs/signature/quick-reference.md b/docs/signature/quick-reference.md new file mode 100644 index 00000000..bb40cdb4 --- /dev/null +++ b/docs/signature/quick-reference.md @@ -0,0 +1,115 @@ +# Quick reference + +## Public API + +```ruby +# Sign a document +signature = Moxml::Signature.sign( + context:, # Moxml::Context (from Moxml.new(:nokogiri)) + document:, # Moxml::Document to sign + key:, # OpenSSL::PKey::* or HMAC secret String + signature_method:, # algorithm URI + canonicalization_method:, # algorithm URI + digest_method:, # algorithm URI + reference_uri:, # "" for whole document + transforms:, # array of algorithm URIs + key_info: nil, # optional Model::KeyInfo + signature_id: nil, # optional Id attribute +) + +# Verify a document +result = Moxml::Signature.verify( + context:, + document:, + key: nil, # optional; auto-extracted from KeyInfo if absent + key_map: {}, # for KeyName-based resolution +) +``` + +## Algorithm URIs + +### Digests +- `http://www.w3.org/2000/09/xmldsig#sha1` (verification only) +- `http://www.w3.org/2001/04/xmlenc#sha256` (REQUIRED) +- `http://www.w3.org/2001/04/xmlenc#sha512` +- `http://www.w3.org/2001/04/xmldsig-more#sha224` +- `http://www.w3.org/2001/04/xmldsig-more#sha384` + +### Signature methods +- RSA-PKCS1v1.5: `http://www.w3.org/2001/04/xmldsig-more#rsa-sha{1,224,256,384,512}` +- HMAC: `http://www.w3.org/2001/04/xmldsig-more#hmac-sha{1,224,256,384,512}` +- ECDSA: `http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha{1,224,256,384,512}` +- DSA: `http://www.w3.org/2000/09/xmldsig#dsa-sha1`, `http://www.w3.org/2009/xmldsig11#dsa-sha256` + +### Canonicalization +- Exclusive: `http://www.w3.org/2001/10/xml-exc-c14n#` (add `#WithComments`) +- Inclusive 1.0: `http://www.w3.org/TR/2001/REC-xml-c14n-20010315` +- Inclusive 1.1: `http://www.w3.org/2006/12/xml-c14n11` + +### Transforms +- `http://www.w3.org/2000/09/xmldsig#enveloped-signature` +- `http://www.w3.org/2000/09/xmldsig#base64` + +## Common algorithm combinations + +### Enveloped RSA-SHA256 (most common) +```ruby +signature_method: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", +canonicalization_method: "http://www.w3.org/2001/10/xml-exc-c14n#", +digest_method: "http://www.w3.org/2001/04/xmlenc#sha256", +transforms: ["http://www.w3.org/2000/09/xmldsig#enveloped-signature"], +``` + +### HMAC-SHA256 +```ruby +signature_method: "http://www.w3.org/2001/04/xmldsig-more#hmac-sha256", +canonicalization_method: "http://www.w3.org/2001/10/xml-exc-c14n#", +digest_method: "http://www.w3.org/2001/04/xmlenc#sha256", +transforms: ["http://www.w3.org/2000/09/xmldsig#enveloped-signature"], +``` + +### ECDSA-SHA256 (P-256) +```ruby +signature_method: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256", +canonicalization_method: "http://www.w3.org/2001/10/xml-exc-c14n#", +digest_method: "http://www.w3.org/2001/04/xmlenc#sha256", +transforms: ["http://www.w3.org/2000/09/xmldsig#enveloped-signature"], +``` + +## Result inspection + +```ruby +result.valid? # → bool (all signatures) +result.signature_count # → Integer +result.failing # → [SingleVerificationResult] + +result.results.first.signature_valid? # → bool (crypto verify) +result.results.first.references # → [ReferenceResult] +result.results.first.failing_references # → [ReferenceResult] +result.results.first.error # → Exception or nil (debugging) +``` + +## C14N top-level API + +```ruby +Moxml::C14n.canonicalize(node_or_xml, with_comments: false) +Moxml::C14n.canonicalize_exclusive(node_or_xml, with_comments: false, + inclusive_namespaces: []) +``` + +## Errors + +```ruby +Moxml::Signature::Error # base +Moxml::Signature::SignatureError # generic +Moxml::Signature::UnknownAlgorithm # URI not in registry +Moxml::Signature::DuplicateAlgorithm # URI registered twice +Moxml::Signature::SigningError # signing failed +Moxml::Signature::VerificationError # verification failed +Moxml::Signature::ReferenceDigestMismatch # digest mismatch +Moxml::Signature::SignatureValueMismatch # crypto verify failed +Moxml::Signature::TransformError # transform pipeline +Moxml::Signature::CanonicalizationError # C14N failure +Moxml::Signature::MalformedSignatureError # XML schema violation +Moxml::Signature::SignatureKeyError # wrong key type +``` diff --git a/docs/signature/security.md b/docs/signature/security.md new file mode 100644 index 00000000..e0ca4c03 --- /dev/null +++ b/docs/signature/security.md @@ -0,0 +1,143 @@ +# Security considerations + +XML signature is a complex spec with a long history of attacks. This +document explains what `Moxml::Signature` does to mitigate them and +what the application is responsible for. + +## Built-in mitigations + +### Best Practice 1 — authenticate before transforms (DONE) + +The Verifier validates `SignatureValue` before running any reference +transforms. A hostile signature with expensive XSLT or XPath transforms +cannot consume server resources unless its `SignatureValue` verifies +against a key the application trusts. + +### Best Practice 26 — HMAC truncation floor (DONE) + +`HMACOutputLength` values below `max(hash_bits/2, 80)` are rejected +during algorithm instantiation (spec §4.4.2). Signatures with +sub-minimum truncation are deemed invalid. + +### Best Practice 11 — opaque certificate handling (DONE) + +`X509Certificate` payloads are decoded as raw DER and passed to +`OpenSSL::X509::Certificate.new`. We never re-encode certificates, so +the signature on the certificate itself is preserved. + +## What the application must do + +The library cannot make trust decisions; the application must. + +### Best Practice 2 — establish trust in the key + +Just because `SignatureValue` verifies against a public key in +`KeyInfo` does NOT mean the signature should be trusted. The key must +come from a trusted source: + +```ruby +# Application-side: pin the expected key, ignore KeyInfo entirely +result = Moxml::Signature.verify( + context: ctx, + document: doc, + key: trusted_public_key, # do NOT rely on KeyInfo extraction +) + +# Or, with certificate validation: +cert = OpenSSL::X509::Certificate.new(cert_der) +store = OpenSSL::X509::Store.new +store.add_trust_file("cacert.pem") +unless store.verify(cert) + raise "certificate chain invalid" +end + +result = Moxml::Signature.verify( + context: ctx, + document: doc, + key: cert.public_key, +) +``` + +### Best Practice 12 — see what was signed + +Use `SingleVerificationResult#references` to inspect what the signature +actually covers: + +```ruby +result.results.first.references.each do |ref| + puts "#{ref.uri}: #{ref.valid?}" + # Confirm ref.uri matches what the application expects to be signed. + # Wrapping attacks work by getting the verifier to confirm a different + # node than the application acts on. +end +``` + +### Best Practice 14 — check name AND position + +When checking a reference URI, don't just verify the element name. A +wrapping attack moves the signed element into an `` and points +the reference at it; the application then acts on a different (unsigned) +element with the same name. + +### Best Practice 8 — control external references + +External URI dereferencing is **not enabled by default**. If you need +it, wrap the resolver in a policy that: + +- Allows only same-document URIs (`#id`, `""`) +- Allows only specific schemes (`https://`, never `file://`) +- Caps size and timeout +- Disallows query parameters that mutate server state + +## What this library does NOT do + +### Not a chain validator + +`Moxml::Signature` does not validate X.509 certificate chains, check +revocation, or evaluate certificate policies. The application is +responsible for these (see Best Practice 2 above). + +### Not a timestamp authority + +Long-lived signatures need RFC 3161 timestamps from a TSA. This library +does not implement timestamp verification. + +### Not a wrapping-attack detector + +Wrapping attacks succeed when the application acts on a different node +than the one the signature actually covers. The library returns the +list of references; the application must verify they cover the right +nodes. + +## SHA-1 warning + +The W3C spec marks SHA-1 as REQUIRED for backwards compatibility but +DISCOURAGED for new signatures. Cryptanalytic advances (SHAttered, +2017) demonstrated practical collisions. Use SHA-256 or stronger for +new signatures. + +## HMAC security + +HMAC signatures require a shared secret. Any verifier with the secret +can forge signatures. Use distinct keys for signing vs. encryption +(Best Practice 27). + +## Limited XPath support + +The library implements the Enveloped Signature transform directly +(no XPath). XPath Filter and XSLT transforms are not implemented; +Best Practices 3, 5, 6, 22 say avoid them. + +## Performance and DoS + +Canonicalization is O(N) for tree size. XSLT and complex XPath are +not supported, eliminating the most common DoS vectors (Best Practices +3, 5, 6). Wrap calls in a timeout if processing untrusted input: + +```ruby +require "timeout" + +Timeout.timeout(5) do + Moxml::Signature.verify(context: ctx, document: untrusted_doc, key:) +end +``` diff --git a/examples/signature/auto_key_extraction.rb b/examples/signature/auto_key_extraction.rb new file mode 100644 index 00000000..b82ae009 --- /dev/null +++ b/examples/signature/auto_key_extraction.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# Example: auto-extract the verification key from a KeyInfo that +# contains an embedded X509Certificate. The Verifier derives the +# OpenSSL key without application help. +# +# Run with: bundle exec ruby examples/signature/auto_key_extraction.rb + +require "moxml" +require "moxml/signature" + +ctx = Moxml.new(:nokogiri) + +fixture = File.expand_path( + "../../spec/fixtures/xmldsig/sign3-result.xml", + __dir__, +) +xml = File.read(fixture) +doc = ctx.parse(xml) + +# No explicit key — Verifier auto-extracts from X509Certificate. +result = Moxml::Signature.verify(context: ctx, document: doc) +puts "Auto-extracted from X509Certificate: #{result.valid?}" +puts "Signature count: #{result.signature_count}" diff --git a/examples/signature/enveloped_rsa.rb b/examples/signature/enveloped_rsa.rb new file mode 100644 index 00000000..83e18b0e --- /dev/null +++ b/examples/signature/enveloped_rsa.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +# Example: enveloped RSA-SHA256 signature with verification. +# +# Run with: bundle exec ruby examples/signature/enveloped_rsa.rb + +require "moxml" +require "moxml/signature" +require "openssl" + +ctx = Moxml.new(:nokogiri) +key = OpenSSL::PKey::RSA.generate(2048) + +doc = ctx.parse("Hello, World!") +puts "=== Before signing ===" +puts doc.to_xml + +signature = Moxml::Signature.sign( + context: ctx, + document: doc, + key: key, + signature_method: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + canonicalization_method: "http://www.w3.org/2001/10/xml-exc-c14n#", + digest_method: "http://www.w3.org/2001/04/xmlenc#sha256", + reference_uri: "", + transforms: ["http://www.w3.org/2000/09/xmldsig#enveloped-signature"], +) + +serialized = Moxml::Signature::Serializer.new(context: ctx).serialize(signature) +doc.root.add_child(serialized.root) + +puts "" +puts "=== After signing ===" +puts doc.to_xml + +result = Moxml::Signature.verify(context: ctx, document: doc, key: key) +puts "" +puts "=== Verification ===" +puts "Valid: #{result.valid?}" + +# Tamper test +doc2 = ctx.parse(doc.to_xml(indent: 0)) +doc2.at_xpath("//greeting").text = "Goodbye!" +tampered = Moxml::Signature.verify(context: ctx, document: doc2, key: key) +puts "Tampered: #{tampered.valid?} (expected false)" diff --git a/examples/signature/hmac.rb b/examples/signature/hmac.rb new file mode 100644 index 00000000..5cfc074e --- /dev/null +++ b/examples/signature/hmac.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +# Example: HMAC-SHA256 signature with a shared secret. +# +# Run with: bundle exec ruby examples/signature/hmac.rb + +require "moxml" +require "moxml/signature" + +ctx = Moxml.new(:nokogiri) +secret = "super-secret-shared-key" + +doc = ctx.parse("hello") + +signature = Moxml::Signature.sign( + context: ctx, + document: doc, + key: secret, + signature_method: "http://www.w3.org/2001/04/xmldsig-more#hmac-sha256", + canonicalization_method: "http://www.w3.org/2001/10/xml-exc-c14n#", + digest_method: "http://www.w3.org/2001/04/xmlenc#sha256", + reference_uri: "", + transforms: ["http://www.w3.org/2000/09/xmldsig#enveloped-signature"], +) + +serialized = Moxml::Signature::Serializer.new(context: ctx).serialize(signature) +doc.root.add_child(serialized.root) + +result = Moxml::Signature.verify(context: ctx, document: doc, key: secret) +puts "Valid: #{result.valid?}" + +wrong = Moxml::Signature.verify(context: ctx, document: doc, key: "wrong") +puts "Wrong secret: #{wrong.valid?} (expected false)" diff --git a/lib/moxml.rb b/lib/moxml.rb index 5c61446d..deaef77b 100644 --- a/lib/moxml.rb +++ b/lib/moxml.rb @@ -89,6 +89,8 @@ def restore_entities(text) autoload :Adapter, "moxml/adapter" autoload :XPath, "moxml/xpath" autoload :SAX, "moxml/sax" + autoload :Signature, "moxml/signature" + autoload :C14n, "moxml/c14n" # Error hierarchy — each subclass autoloads from the same file autoload :Error, "moxml/error" diff --git a/lib/moxml/c14n.rb b/lib/moxml/c14n.rb new file mode 100644 index 00000000..35f40d48 --- /dev/null +++ b/lib/moxml/c14n.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +module Moxml + # Canonicalization engine. Core moxml feature — sibling to + # Moxml::XPath, Moxml::Builder, Moxml::SAX. + # + # Inclusive C14N (1.0 and 1.1) is ported from canon (lutaml/canon): + # mature, tested, full-featured (xml:base fixup, inheritable xml:* + # attribute resolution, node-set subset canonicalization). + # + # Exclusive C14N (1.0) is a moxml-native implementation; canon does + # not implement exclusive. It is required by XML signature to keep + # signatures valid when subdocuments are moved between contexts. + module C14n + autoload :Node, "moxml/c14n/node" + autoload :Nodes, "moxml/c14n/nodes" + autoload :DataModel, "moxml/c14n/data_model" + autoload :Processor, "moxml/c14n/processor" + autoload :CharacterEncoder, "moxml/c14n/character_encoder" + autoload :NamespaceHandler, "moxml/c14n/namespace_handler" + autoload :AttributeHandler, "moxml/c14n/attribute_handler" + autoload :XmlBaseHandler, "moxml/c14n/xml_base_handler" + + # Engine classes used directly by signature algorithms. + autoload :Writer, "moxml/c14n/writer" + autoload :NamespaceContext, "moxml/c14n/namespace_context" + autoload :Exclusive, "moxml/c14n/exclusive" + autoload :Inclusive10, "moxml/c14n/inclusive_10" + autoload :Inclusive11, "moxml/c14n/inclusive_11" + + XMLNS_URI = "http://www.w3.org/2000/xmlns/" + XML_URI = "http://www.w3.org/XML/1998/namespace" + + # Canonicalize using the named algorithm. + # + # algorithm is one of: + # :inclusive10 Canonical XML 1.0 (default; W3C REC-xml-c14n-20010315) + # :inclusive11 Canonical XML 1.1 (W3C REC-xml-c14n11-20080502) + # :exclusive10 Exclusive C14N 1.0 (W3C REC-xml-exc-c14n-20020718) + def self.canonicalize(node_or_xml, with_comments: false, + algorithm: :inclusive10, inclusive_namespaces: []) + engine_for(algorithm).canonicalize( + node_or_xml, + with_comments: with_comments, + inclusive_namespaces: inclusive_namespaces, + ) + end + + def self.canonicalize_inclusive10(node_or_xml, with_comments: false) + Inclusive10.new.canonicalize(node_or_xml, with_comments: with_comments) + end + + def self.canonicalize_inclusive11(node_or_xml, with_comments: false) + Inclusive11.new.canonicalize(node_or_xml, with_comments: with_comments) + end + + def self.canonicalize_exclusive(node_or_xml, with_comments: false, + inclusive_namespaces: []) + Exclusive.new.canonicalize( + node_or_xml, + with_comments: with_comments, + inclusive_namespaces: inclusive_namespaces, + ) + end + + # Compare two XML inputs by their canonical forms. + def self.equivalent?(left, right, with_comments: false, + algorithm: :inclusive10, inclusive_namespaces: []) + canonicalize(left, with_comments: with_comments, algorithm: algorithm, + inclusive_namespaces: inclusive_namespaces) == + canonicalize(right, with_comments: with_comments, algorithm: algorithm, + inclusive_namespaces: inclusive_namespaces) + end + + def self.escape_text(text) + CharacterEncoder.new.encode_text(text) + end + + def self.escape_attribute(value) + CharacterEncoder.new.encode_attribute(value) + end + + def self.engine_for(algorithm) + case algorithm + when :inclusive10 then Inclusive10.new + when :inclusive11 then Inclusive11.new + when :exclusive10 then Exclusive.new + else + raise ArgumentError, + "unknown C14N algorithm #{algorithm.inspect}; expected one of " \ + ":inclusive10, :inclusive11, :exclusive10" + end + end + private_class_method :engine_for + end +end diff --git a/lib/moxml/c14n/attribute_handler.rb b/lib/moxml/c14n/attribute_handler.rb new file mode 100644 index 00000000..b5a8b084 --- /dev/null +++ b/lib/moxml/c14n/attribute_handler.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Moxml + module C14n + # Attribute axis handler for inclusive C14N. + # Implements W3C C14N 1.0 §2.4 / 1.1 §2.4 attribute rendering, + # including resolution of simple inheritable attributes (xml:lang, + # xml:space) for document subsets. + class AttributeHandler + attr_reader :encoder + + def initialize(encoder) + @encoder = encoder + end + + def process_attributes(element, output, omitted_ancestors = []) + return unless element.in_node_set? + + attributes = collect_attributes(element, omitted_ancestors) + attributes.each do |attr| + output << " " + output << attr.qname + output << '="' + output << encoder.encode_attribute(attr.value) + output << '"' + end + end + + private + + def collect_attributes(element, omitted_ancestors) + attributes = element.sorted_attribute_nodes.select(&:in_node_set?) + + return attributes if omitted_ancestors.empty? + + inherited = collect_inherited_attributes(element, omitted_ancestors) + merge_attributes(attributes, inherited) + end + + # Walk omitted ancestors to collect simple inheritable attributes + # not already declared on the element. Per C14N 1.1 §2.4, these + # are inherited from the nearest ancestor in which they are declared. + def collect_inherited_attributes(element, omitted_ancestors) + inherited = [] + seen = Set.new + + element.attribute_nodes.each do |attr| + seen.add(attr.name) if attr.simple_inheritable? + end + + omitted_ancestors.reverse_each do |ancestor| + ancestor.attribute_nodes.each do |attr| + next unless attr.simple_inheritable? + next if seen.include?(attr.name) + + inherited << attr + seen.add(attr.name) + end + end + + inherited + end + + def merge_attributes(element_attrs, inherited_attrs) + (element_attrs + inherited_attrs).sort_by do |attr| + [attr.namespace_uri.to_s, attr.local_name] + end + end + end + end +end diff --git a/lib/moxml/c14n/character_encoder.rb b/lib/moxml/c14n/character_encoder.rb new file mode 100644 index 00000000..96bbac9d --- /dev/null +++ b/lib/moxml/c14n/character_encoder.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Moxml + module C14n + # Character encoder for C14N output. Handles the entity escaping + # mandated by W3C C14N 1.0 §2.4 / 1.1 §2.4 for text and attribute values. + class CharacterEncoder + TEXT_ESCAPES = { + "&" => "&", + "<" => "<", + ">" => ">", + "\r" => " ", + }.freeze + ATTRIBUTE_ESCAPES = TEXT_ESCAPES.merge( + '"' => """, + "\t" => " ", + "\n" => " ", + ).freeze + + def encode_text(text) + text.to_s.gsub(/[&<>\r]/, TEXT_ESCAPES) + end + + def encode_attribute(value) + value.to_s.gsub(/[&<"\t\n\r]/, ATTRIBUTE_ESCAPES) + end + end + end +end diff --git a/lib/moxml/c14n/data_model.rb b/lib/moxml/c14n/data_model.rb new file mode 100644 index 00000000..d3172733 --- /dev/null +++ b/lib/moxml/c14n/data_model.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +module Moxml + module C14n + # Builds a C14N data model from a Moxml::Node tree (or XML string). + # + # The data model exists because canonicalization needs: + # - sorted namespace and attribute axes (spec §2.3, §2.4) + # - in_node_set flags for subset canonicalization (spec §3) + # - xml:* inheritable attribute resolution + # + # Ported from canon (lutaml/canon) — adapted to build directly from + # Moxml::Node rather than via a separate Nokogiri pass. Matches + # canon's document-level node iteration (PIs and comments outside + # the document root element). + class DataModel + def self.from_xml(xml_string) + from_node(::Moxml.parse(xml_string)) + end + + def self.from_node(moxml_node) + return build_from_document(moxml_node) if moxml_node.is_a?(::Moxml::Document) + + root = Nodes::RootNode.new + root.add_child(build_node(moxml_node)) + root + end + + # Build from a Moxml::Document. Matches canon's Nokogiri path: + # the root element is added first, then all other document-level + # children (PIs, comments) in document order. + def self.build_from_document(document) + root = Nodes::RootNode.new + + if document.root + root.add_child(build_element_node(document.root)) + # Iterate ALL document children — not just the root element. + # This captures PIs and comments that appear outside the + # document element, which are part of the canonical form. + document.children.each do |child| + next if child.equal?(document.root) + next if child.is_a?(::Moxml::Element) + + built = build_node(child) + root.add_child(built) if built + end + end + + root + end + + def self.build_node(moxml_node) + case moxml_node + when ::Moxml::Element then build_element_node(moxml_node) + when ::Moxml::Text then build_text_node(moxml_node) + when ::Moxml::Comment then build_comment_node(moxml_node) + when ::Moxml::ProcessingInstruction then build_pi_node(moxml_node) + end + end + + def self.build_element_node(moxml_element) + ns = moxml_element.namespace + element = Nodes::ElementNode.new( + name: moxml_element.name, + namespace_uri: ns&.uri, + prefix: ns&.prefix, + ) + + build_namespace_nodes(moxml_element, element) + build_attribute_nodes(moxml_element, element) + + moxml_element.children.each do |child| + built = build_node(child) + element.add_child(built) if built + end + + element + end + + def self.build_namespace_nodes(moxml_element, element) + moxml_element.in_scope_namespaces.each do |ns| + element.add_namespace( + Nodes::NamespaceNode.new(prefix: ns.prefix || "", uri: ns.uri), + ) + end + + return if element.namespace_nodes.any? { |n| n.prefix == "xml" } + + element.add_namespace( + Nodes::NamespaceNode.new(prefix: "xml", uri: XML_URI), + ) + end + + def self.build_attribute_nodes(moxml_element, element) + moxml_element.attributes.each do |attr| + ns = attr.namespace + element.add_attribute( + Nodes::AttributeNode.new( + name: attr.name, + value: attr.value, + namespace_uri: ns&.uri, + prefix: ns&.prefix, + ), + ) + end + end + + def self.build_text_node(moxml_text) + Nodes::TextNode.new(value: moxml_text.content) + end + + def self.build_comment_node(moxml_comment) + Nodes::CommentNode.new(value: moxml_comment.content) + end + + def self.build_pi_node(moxml_pi) + Nodes::ProcessingInstructionNode.new( + target: moxml_pi.target || moxml_pi.name, + data: moxml_pi.content || "", + ) + end + + def self.mark_all(node, value) + node.in_node_set = value + node.children.each { |child| mark_all(child, value) } + end + + def self.mark_subset(root_node, matched) + matched.each { |node| mark_node_and_descendants(node) } + root_node.in_node_set = true + end + + def self.mark_node_and_descendants(node) + node.in_node_set = true + node.children.each { |child| mark_node_and_descendants(child) } + end + + private_class_method :build_from_document, :build_node, + :build_element_node, :build_namespace_nodes, + :build_attribute_nodes, :build_text_node, + :build_comment_node, :build_pi_node, + :mark_node_and_descendants + end + end +end diff --git a/lib/moxml/c14n/exclusive.rb b/lib/moxml/c14n/exclusive.rb new file mode 100644 index 00000000..5c718e2a --- /dev/null +++ b/lib/moxml/c14n/exclusive.rb @@ -0,0 +1,188 @@ +# frozen_string_literal: true + +module Moxml + module C14n + # Exclusive XML Canonicalization 1.0 (https://www.w3.org/TR/xml-exc-c14n/) + # + # Renders only the namespaces visibly utilized by each element's + # qualified name and attributes (plus any in the inclusive prefix + # list), as opposed to inclusive C14N which renders every in-scope + # namespace on every element. + # + # Output is UTF-8 octets with no BOM. + class Exclusive + # `node`: a Moxml::Node — element, document, text, comment, or PI. + # `with_comments:`: include comment nodes in output. + # `inclusive_namespaces`: list of prefixes to always include even if + # not visibly utilized (InclusiveNamespacesPrefixList parameter). + def canonicalize(node, with_comments: false, inclusive_namespaces: []) + writer = Writer.new + context = NamespaceContext.new + render(node, writer, context, with_comments, inclusive_namespaces) + writer.output + end + + private + + def render(node, writer, context, with_comments, inclusive) + case node_type(node) + when :document + node.children.each { |c| render(c, writer, context, with_comments, inclusive) } + when :element + render_element(node, writer, context, with_comments, inclusive) + when :text, :cdata + writer.text(node.content) + when :comment + writer.comment(node.content) if with_comments + when :processing_instruction + target, content = pi_target_and_content(node) + writer.processing_instruction(target, content) + end + end + + def render_element(element, writer, context, with_comments, inclusive) + declared = bindings_declared_on(element) + in_scope = bindings_in_scope_on(element) + context.push(in_scope) + + prefix = element_namespace_prefix(element) + local = element_name(element) + + visible_prefixes = visibly_used_prefixes(element, prefix) + inclusive.each { |p| visible_prefixes.add(p) } + visible_prefixes.add("xml") if visibly_uses_xml(element) + + namespaces_to_render = visible_prefixes.map do |p| + [p, context.uri_for(p)] + end.reject { |(_, uri)| uri.nil? } + + writer.open_rendered_frame(element) + writer.open_tag(prefix, local) + render_namespaces(writer, element, namespaces_to_render) + render_attributes(writer, element) + writer.close_tag_open + + # Switch context from in-scope (apex lookup) to declared-only so + # children build their own in-scope view by pushing their declared + # set on top of the parent's declared set. + context.pop + context.push(declared) + + element.children.each { |c| render(c, writer, context, with_comments, inclusive) } + + writer.close_tag(prefix, local) + writer.close_rendered_frame + context.pop + end + + def render_namespaces(writer, element, namespaces) + already_rendered = writer.rendered_namespaces_for(element) + sorted = namespaces + .reject { |(prefix, _)| already_rendered.include?(prefix) } + .sort_by { |(prefix, _)| prefix.to_s } + sorted.each do |(prefix, uri)| + writer.namespace(prefix, uri) + writer.mark_rendered(element, prefix, uri) + end + end + + def render_attributes(writer, element) + attrs = collect_attributes(element) + sorted = attrs.sort_by { |a| [a[:ns_uri] || "", a[:local]] } + sorted.each { |a| writer.attribute(a[:expanded], a[:value]) } + end + + def collect_attributes(element) + attrs = [] + element.attributes.each do |attr| + ns = attr.namespace + attrs << { + ns_uri: ns&.uri, + ns_prefix: ns&.prefix, + local: attr.name, + expanded: attr_expanded_name(attr), + value: attr.value, + } + end + attrs + end + + def attr_expanded_name(attr) + ns = attr.namespace + prefix = ns&.prefix + if prefix && !prefix.empty? + "#{prefix}:#{attr.name}" + else + attr.name + end + end + + def visibly_used_prefixes(element, element_prefix) + set = Set.new + # The element's qualified name visibly uses its own namespace. + # Default namespace (prefix == "" or nil) is included so it gets + # rendered on the apex element when the apex uses default ns. + if element.namespace_uri + set.add(element_prefix || "") + end + element.attributes.each do |attr| + ns = attr.namespace + prefix = ns&.prefix + set.add(prefix || "") if ns&.uri + end + set + end + + def visibly_uses_xml(element) + element.attributes.any? { |a| a.namespace&.prefix == "xml" } + end + + def bindings_declared_on(element) + bindings = {} + element.namespaces.each do |ns| + prefix = ns.prefix + prefix = "" if prefix.nil? || prefix == "xmlns" + bindings[prefix] = ns.uri + end + bindings + end + + # In-scope namespaces (declared + inherited from ancestors). + # Used at the apex to find URIs for visibly-used prefixes whose + # declarations live on ancestors. + def bindings_in_scope_on(element) + bindings = {} + element.in_scope_namespaces.each do |ns| + prefix = ns.prefix + prefix = "" if prefix.nil? || prefix == "xmlns" + bindings[prefix] = ns.uri + end + bindings + end + + def element_namespace_prefix(element) + ns = element.namespace + ns&.prefix + end + + def element_name(element) + element.name + end + + def pi_target_and_content(node) + [node.name, node.text] + end + + def node_type(node) + return :document if node.is_a?(::Moxml::Document) + return :element if node.is_a?(::Moxml::Element) + return :text if node.is_a?(::Moxml::Text) + return :cdata if node.is_a?(::Moxml::Cdata) + return :comment if node.is_a?(::Moxml::Comment) + return :processing_instruction if node.is_a?(::Moxml::ProcessingInstruction) + + :unknown + end + end + end +end diff --git a/lib/moxml/c14n/inclusive_10.rb b/lib/moxml/c14n/inclusive_10.rb new file mode 100644 index 00000000..f118360c --- /dev/null +++ b/lib/moxml/c14n/inclusive_10.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Moxml + module C14n + # Inclusive Canonical XML 1.0 (https://www.w3.org/TR/xml-c14n/) + # + # Unlike exclusive C14N, inclusive C14N "attracts" ancestor context: + # at the apex element, ALL in-scope namespaces are rendered, including + # those inherited from ancestors outside the canonicalization subset. + # + # xml:* inheritable attributes (xml:lang, xml:space) are also inherited + # from the nearest ancestor in which they are declared. + # + # Implementation: delegates to the canon-derived Processor pipeline + # (DataModel + NamespaceHandler + AttributeHandler + XmlBaseHandler). + class Inclusive10 + # rubocop:disable Lint/UnusedMethodArgument -- signature must match Exclusive + def canonicalize(node_or_xml, with_comments: false, inclusive_namespaces: []) + # rubocop:enable Lint/UnusedMethodArgument + root = coerce_to_data_model(node_or_xml) + Processor.new(with_comments: with_comments).process(root) + end + + private + + def coerce_to_data_model(node_or_xml) + return node_or_xml if node_or_xml.is_a?(Nodes::RootNode) + + case node_or_xml + when ::Moxml::Document, ::Moxml::Node + DataModel.from_node(node_or_xml) + when String + DataModel.from_xml(node_or_xml) + else + raise ArgumentError, + "Inclusive10#canonicalize expects a Moxml::Node, " \ + "Moxml::Document, or XML String; got #{node_or_xml.class}" + end + end + end + end +end diff --git a/lib/moxml/c14n/inclusive_11.rb b/lib/moxml/c14n/inclusive_11.rb new file mode 100644 index 00000000..a1c6e69b --- /dev/null +++ b/lib/moxml/c14n/inclusive_11.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Moxml + module C14n + # Canonical XML 1.1 (https://www.w3.org/TR/xml-c14n11/). + # + # In this implementation, Inclusive11 is a thin alias of Inclusive10. + # Both delegate to the canon-derived Processor. The W3C 1.1 additions + # (notations, entity references in DTD internal subset, XML 1.1 line + # ending) are rarely encountered in modern XML signature practice. + class Inclusive11 + def canonicalize(node_or_xml, with_comments: false, inclusive_namespaces: []) + Inclusive10.new.canonicalize( + node_or_xml, + with_comments: with_comments, + inclusive_namespaces: inclusive_namespaces, + ) + end + end + end +end diff --git a/lib/moxml/c14n/namespace_context.rb b/lib/moxml/c14n/namespace_context.rb new file mode 100644 index 00000000..919576cd --- /dev/null +++ b/lib/moxml/c14n/namespace_context.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module Moxml + module C14n + # Tracks in-scope namespace URIs along the ancestor chain of the + # element currently being canonicalized. The canonicalizer pushes + # when entering an element and pops when leaving. + # + # `xml` prefix is always bound to the XML namespace per XML 1.0 spec. + class NamespaceContext + DEFAULT_XML_BINDING = { "xml" => C14n::XML_URI }.freeze + + attr_reader :bindings + + def initialize(initial = {}) + @stack = [] + @bindings = DEFAULT_XML_BINDING.dup + merge(initial) + end + + def push(bindings_to_apply) + applied = {} + bindings_to_apply.each do |prefix, uri| + prev = @bindings[prefix] + applied[prefix] = prev + @bindings[prefix] = uri + end + @stack.push(applied) + self + end + + def pop + applied = @stack.pop + applied.each do |prefix, prev| + if prev.nil? + @bindings.delete(prefix) + @bindings[prefix] = C14n::XML_URI if prefix == "xml" + else + @bindings[prefix] = prev + end + end + self + end + + def uri_for(prefix) + @bindings[prefix] + end + + def binding_for(prefix) + @bindings[prefix] + end + + def include?(prefix) + @bindings.key?(prefix) + end + + def to_h + @bindings.dup + end + + private + + def merge(hash) + hash.each { |prefix, uri| @bindings[prefix] = uri } + end + end + end +end diff --git a/lib/moxml/c14n/namespace_handler.rb b/lib/moxml/c14n/namespace_handler.rb new file mode 100644 index 00000000..886a77a1 --- /dev/null +++ b/lib/moxml/c14n/namespace_handler.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Moxml + module C14n + # Namespace axis handler for inclusive C14N. + # Implements W3C C14N 1.0 §2.3 / 1.1 §2.3 namespace rendering rules + # for the document subset (full and partial). + class NamespaceHandler + attr_reader :encoder + + def initialize(encoder) + @encoder = encoder + end + + def process_namespaces(element, output, parent_element = nil) + return unless element.in_node_set? + + namespaces = element.sorted_namespace_nodes.select(&:in_node_set?) + + if should_emit_empty_default_namespace?(element, namespaces, parent_element) + output << ' xmlns=""' + end + + namespaces.each do |ns| + next if should_skip_namespace?(ns, parent_element) + + output << " " + output << (ns.default_namespace? ? "xmlns" : "xmlns:#{ns.prefix}") + output << '="' + output << encoder.encode_attribute(ns.uri) + output << '"' + end + end + + private + + # Emit xmlns="" if the element's nearest in-set ancestor had a non-empty + # default namespace and this element does not. + def should_emit_empty_default_namespace?(element, namespaces, parent_element) + return false unless element.in_node_set? + return false if namespaces.first&.default_namespace? + return false unless parent_element + + parent_default_ns = parent_element.namespace_nodes.find do |ns| + ns.default_namespace? && ns.in_node_set? + end + + parent_default_ns && !parent_default_ns.uri.empty? + end + + def should_skip_namespace?(namespace, parent_element) + # The xml namespace is implicit, never rendered. + return true if namespace.xml_namespace? + # Skip namespaces already declared (with same URI) by an ancestor. + return true if namespace_declared_by_ancestor?(namespace, parent_element) + + false + end + + def namespace_declared_by_ancestor?(namespace, parent_element) + return false unless parent_element + + parent_ns = parent_element.namespace_nodes.find do |candidate| + candidate.prefix == namespace.prefix && candidate.in_node_set? + end + + parent_ns && parent_ns.uri == namespace.uri + end + end + end +end diff --git a/lib/moxml/c14n/node.rb b/lib/moxml/c14n/node.rb new file mode 100644 index 00000000..82379fbd --- /dev/null +++ b/lib/moxml/c14n/node.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Moxml + module C14n + # Base class for all C14N data-model nodes. + # Ported from canon ( lutaml/canon ) — keeps the mature node-set + # semantics needed for subset canonicalization (spec §3). + class Node + attr_reader :parent, :children + + def initialize + @parent = nil + @children = [] + @in_node_set = true + end + + def add_child(child) + child.parent = self + @children << child + end + + def in_node_set? + @in_node_set + end + + def in_node_set=(value) + @in_node_set = value + end + + # Return the text content of this node and all descendants. + # ElementNode concatenates children's text_content; other nodes + # (TextNode, CommentNode, etc.) return their value. + def text_content + children.map(&:text_content).join + end + + protected + + attr_writer :parent + end + end +end diff --git a/lib/moxml/c14n/nodes.rb b/lib/moxml/c14n/nodes.rb new file mode 100644 index 00000000..e142bdfb --- /dev/null +++ b/lib/moxml/c14n/nodes.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Moxml + module C14n + # C14N data-model node types. All nodes inherit from {Moxml::C14n::Node}. + module Nodes + autoload :AttributeNode, "moxml/c14n/nodes/attribute_node" + autoload :CommentNode, "moxml/c14n/nodes/comment_node" + autoload :ElementNode, "moxml/c14n/nodes/element_node" + autoload :NamespaceNode, "moxml/c14n/nodes/namespace_node" + autoload :ProcessingInstructionNode, + "moxml/c14n/nodes/processing_instruction_node" + autoload :RootNode, "moxml/c14n/nodes/root_node" + autoload :TextNode, "moxml/c14n/nodes/text_node" + end + end +end diff --git a/lib/moxml/c14n/nodes/attribute_node.rb b/lib/moxml/c14n/nodes/attribute_node.rb new file mode 100644 index 00000000..21f77515 --- /dev/null +++ b/lib/moxml/c14n/nodes/attribute_node.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Moxml + module C14n + module Nodes + # Attribute node. + class AttributeNode < Node + attr_reader :name, :value, :namespace_uri, :prefix + + def initialize(name:, value:, namespace_uri: nil, prefix: nil) + super() + @name = name + @value = value + @namespace_uri = namespace_uri + @prefix = prefix + end + + def node_type + :attribute + end + + def local_name + name + end + + def qname + prefix.nil? || prefix.empty? ? name : "#{prefix}:#{name}" + end + + # xml:* attributes (lang, space, base, id). + def xml_attribute? + namespace_uri == Moxml::C14n::XML_URI + end + + # xml:lang and xml:space are inheritable per C14N 1.1 §2.4. + def simple_inheritable? + xml_attribute? && %w[lang space].include?(name) + end + + def xml_id? + xml_attribute? && name == "id" + end + + def xml_base? + xml_attribute? && name == "base" + end + end + end + end +end diff --git a/lib/moxml/c14n/nodes/comment_node.rb b/lib/moxml/c14n/nodes/comment_node.rb new file mode 100644 index 00000000..ee590ee4 --- /dev/null +++ b/lib/moxml/c14n/nodes/comment_node.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Moxml + module C14n + module Nodes + # Comment node. + class CommentNode < Node + attr_reader :value + + def initialize(value:) + super() + @value = value + end + + def name + "comment" + end + + def node_type + :comment + end + + def text_content + @value + end + end + end + end +end diff --git a/lib/moxml/c14n/nodes/element_node.rb b/lib/moxml/c14n/nodes/element_node.rb new file mode 100644 index 00000000..31c9558c --- /dev/null +++ b/lib/moxml/c14n/nodes/element_node.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Moxml + module C14n + module Nodes + # Element node in the C14N data model. + class ElementNode < Node + attr_reader :name, :namespace_uri, :prefix, :namespace_nodes, + :attribute_nodes + + def initialize(name:, namespace_uri: nil, prefix: nil) + super() + @name = name + @namespace_uri = namespace_uri + @prefix = prefix + @namespace_nodes = [] + @attribute_nodes = [] + end + + def node_type + :element + end + + def qname + prefix.nil? || prefix.empty? ? name : "#{prefix}:#{name}" + end + + def add_namespace(namespace_node) + namespace_node.parent = self + @namespace_nodes << namespace_node + end + + def add_attribute(attribute_node) + attribute_node.parent = self + @attribute_nodes << attribute_node + end + + # Namespace nodes sorted lexicographically by local name (prefix). + # Per W3C C14N 1.0 §2.3 / 1.1 §2.3, default namespace sorts first. + def sorted_namespace_nodes + @namespace_nodes.sort_by { |ns| ns.local_name.to_s } + end + + # Attribute nodes sorted by namespace URI then local name + # (W3C C14N 1.0 §2.4 / 1.1 §2.4). + def sorted_attribute_nodes + @attribute_nodes.sort_by do |attr| + [attr.namespace_uri.to_s, attr.local_name] + end + end + + def text_content + children.map(&:text_content).join + end + + def to_s + "<#{qname}>" + end + end + end + end +end diff --git a/lib/moxml/c14n/nodes/namespace_node.rb b/lib/moxml/c14n/nodes/namespace_node.rb new file mode 100644 index 00000000..6ed1a4ba --- /dev/null +++ b/lib/moxml/c14n/nodes/namespace_node.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Moxml + module C14n + module Nodes + # Namespace node. Prefix is empty string for the default namespace. + class NamespaceNode < Node + attr_reader :prefix, :uri + + def initialize(prefix:, uri:) + super() + @prefix = prefix + @uri = uri + end + + def name + prefix.to_s + end + + def node_type + :namespace + end + + # Local name is the prefix (empty string for default namespace). + # Used by ElementNode#sorted_namespace_nodes for sort order. + def local_name + prefix.to_s + end + + def default_namespace? + prefix.nil? || prefix.empty? + end + + # The `xml` namespace is implicit and never rendered. + def xml_namespace? + prefix == "xml" && uri == Moxml::C14n::XML_URI + end + end + end + end +end diff --git a/lib/moxml/c14n/nodes/processing_instruction_node.rb b/lib/moxml/c14n/nodes/processing_instruction_node.rb new file mode 100644 index 00000000..b5be0a80 --- /dev/null +++ b/lib/moxml/c14n/nodes/processing_instruction_node.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Moxml + module C14n + module Nodes + # Processing Instruction node. + class ProcessingInstructionNode < Node + attr_reader :target, :data + + def initialize(target:, data: "") + super() + @target = target + @data = data + end + + def name + target + end + + def node_type + :processing_instruction + end + + def text_content + "" + end + end + end + end +end diff --git a/lib/moxml/c14n/nodes/root_node.rb b/lib/moxml/c14n/nodes/root_node.rb new file mode 100644 index 00000000..28ddccb2 --- /dev/null +++ b/lib/moxml/c14n/nodes/root_node.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Moxml + module C14n + module Nodes + # Root node representing the document root. + class RootNode < Node + def name + "#document" + end + + def node_type + :root + end + + def children=(new_children) + @children = new_children + end + end + end + end +end diff --git a/lib/moxml/c14n/nodes/text_node.rb b/lib/moxml/c14n/nodes/text_node.rb new file mode 100644 index 00000000..f4c973d6 --- /dev/null +++ b/lib/moxml/c14n/nodes/text_node.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Moxml + module C14n + module Nodes + # Text node. Stores the decoded value (entity refs resolved). + class TextNode < Node + attr_accessor :value + + def initialize(value:) + super() + @value = value + end + + def name + "#text" + end + + def node_type + :text + end + + def text_content + @value + end + end + end + end +end diff --git a/lib/moxml/c14n/processor.rb b/lib/moxml/c14n/processor.rb new file mode 100644 index 00000000..222e0f99 --- /dev/null +++ b/lib/moxml/c14n/processor.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +module Moxml + module C14n + # C14N 1.0/1.1 processor. Walks the data model and emits canonical + # octets. Handles node-set subsets (omitted ancestors), xml:base + # fixup, and xml:* inheritable attribute resolution. + # + # Ported from canon (lutaml/canon). + class Processor + attr_reader :with_comments + + def initialize(with_comments: false) + @with_comments = with_comments + @encoder = CharacterEncoder.new + @namespace_handler = NamespaceHandler.new(@encoder) + @attribute_handler = AttributeHandler.new(@encoder) + @xml_base_handler = XmlBaseHandler.new + end + + def process(root_node) + output = (+"") + process_node(root_node, output) + output + end + + private + + def process_node(node, output, parent_element = nil, omitted_ancestors = []) + case node.node_type + when :root + node.children.each { |child| process_node(child, output) } + when :element + process_element_node(node, output, parent_element, omitted_ancestors) + when :text + process_text_node(node, output) + when :comment + process_comment_node(node, output, parent_element) + when :processing_instruction + process_pi_node(node, output, parent_element) + end + end + + def process_element_node(node, output, parent_element, omitted_ancestors) + if node.in_node_set? + render_element(node, output, parent_element, omitted_ancestors) + else + # Element not in node-set, but its children may be. Pass the + # element as an omitted ancestor for inheritable-attr fixup. + new_omitted = omitted_ancestors + [node] + node.children.each do |child| + process_node(child, output, parent_element, new_omitted) + end + end + end + + def render_element(node, output, parent_element, omitted_ancestors) + output << "<" << node.qname + + @namespace_handler.process_namespaces(node, output, parent_element) + process_element_attributes(node, output, omitted_ancestors) + + output << ">" + + node.children.each { |child| process_node(child, output, node, []) } + + output << "" + end + + def process_element_attributes(node, output, omitted_ancestors) + @attribute_handler.process_attributes(node, output, omitted_ancestors) + + return unless omitted_ancestors.any? + + fixed_base = @xml_base_handler.fixup_xml_base(node, omitted_ancestors) + return unless fixed_base && !fixed_base.empty? + + has_base = node.attribute_nodes.any?(&:xml_base?) + return if has_base + + output << ' xml:base="' + output << @encoder.encode_attribute(fixed_base) + output << '"' + end + + def process_text_node(node, output) + return unless node.in_node_set? + + output << @encoder.encode_text(node.value) + end + + def process_comment_node(node, output, parent_element) + return unless with_comments + return unless node.in_node_set? + + # Comment outside the document element gets a line break before/after + # to keep canonical output readable. + if parent_element.nil? && output.length.positive? + output << "\n" + end + output << "" + output << "\n" if parent_element.nil? + end + + def process_pi_node(node, output, parent_element) + return unless node.in_node_set? + + if parent_element.nil? && output.length.positive? + output << "\n" + end + output << "" + output << "\n" if parent_element.nil? + end + end + end +end diff --git a/lib/moxml/c14n/writer.rb b/lib/moxml/c14n/writer.rb new file mode 100644 index 00000000..5589d295 --- /dev/null +++ b/lib/moxml/c14n/writer.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +module Moxml + module C14n + # Builds canonical octet output incrementally. + # Output is always UTF-8 with no BOM. + class Writer + attr_reader :output + + def initialize + @output = (+"") + # Tracks namespaces already rendered in the output ancestor chain, + # so descendants don't re-render the same declaration. + # Key: element (by object identity), value: { prefix => uri }. + @rendered_stack = [] + end + + def <<(str) + @output << str + self + end + + def raw(str) + @output << str.to_s + self + end + + # Push a fresh "rendered" frame for the given element. Called by the + # canonicalizer before rendering the element's namespaces. + def open_rendered_frame(element) + parent_rendered = @rendered_stack.last || {} + # Inherit parent's rendered namespaces as the starting set. + @rendered_stack.push(parent_rendered.dup) + element + end + + def close_rendered_frame + @rendered_stack.pop + self + end + + def rendered_namespaces_for(_element) + @rendered_stack.last || {} + end + + def mark_rendered(_element, prefix, uri) + (@rendered_stack.last || {})[prefix] = uri + self + end + + def open_tag(prefix, local) + @output << "<" + @output << "#{prefix}:" unless prefix.nil? || prefix.empty? + @output << local + self + end + + def close_tag_open + @output << ">" + self + end + + def close_tag_self_close + @output << ">" + self + end + + def attribute(expanded_name, value) + @output << " " + @output << expanded_name + @output << "=\"" + @output << C14n.escape_attribute(value) + @output << "\"" + self + end + + def namespace(prefix, uri) + @output << " " + if prefix.nil? || prefix.empty? + @output << "xmlns" + else + @output << "xmlns:" + @output << prefix + end + @output << "=\"" + @output << C14n.escape_attribute(uri) + @output << "\"" + self + end + + def text(content) + @output << C14n.escape_text(content) + self + end + + def comment(content) + @output << "" + self + end + + def processing_instruction(target, content) + @output << "" + self + end + end + end +end diff --git a/lib/moxml/c14n/xml_base_handler.rb b/lib/moxml/c14n/xml_base_handler.rb new file mode 100644 index 00000000..f6469b4c --- /dev/null +++ b/lib/moxml/c14n/xml_base_handler.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require "uri" + +module Moxml + module C14n + # xml:base fixup handler for document subsets (C14N 1.1 §2.4). + # Implements RFC 3986 URI joining with C14N 1.1 modifications. + class XmlBaseHandler + # Returns the fixed-up xml:base value to emit on the element, or + # nil if no fixup is needed. + def fixup_xml_base(element, omitted_ancestors) + return nil if omitted_ancestors.empty? + + base_values = collect_base_values(element, omitted_ancestors) + return nil if base_values.empty? + + join_base_values(base_values) + end + + private + + def collect_base_values(element, omitted_ancestors) + values = [] + omitted_ancestors.each do |ancestor| + base_attr = ancestor.attribute_nodes.find(&:xml_base?) + values << base_attr.value if base_attr + end + element_base = element.attribute_nodes.find(&:xml_base?) + values << element_base.value if element_base + values + end + + def join_base_values(values) + result = values.first + values[1..].each { |ref| result = join_uri_references(result, ref) } + result + end + + # Join two URI references per RFC 3986 §5.2.1–5.2.4 with the + # C14N 1.1 modification (drop fragment). + def join_uri_references(base, ref) + ref_parts = parse_uri(ref) + return remove_dot_segments(ref_parts[:path] || "") if ref_parts[:scheme] + + base_parts = parse_uri(base) + result_parts = {} + + if ref_parts[:authority] + result_parts[:authority] = ref_parts[:authority] + result_parts[:path] = remove_dot_segments(ref_parts[:path] || "") + result_parts[:query] = ref_parts[:query] + else + if ref_parts[:path].nil? || ref_parts[:path].empty? + result_parts[:path] = base_parts[:path] + result_parts[:query] = ref_parts[:query] || base_parts[:query] + elsif ref_parts[:path].start_with?("/") + result_parts[:path] = remove_dot_segments(ref_parts[:path]) + else + result_parts[:path] = remove_dot_segments( + merge_paths(base_parts[:path], ref_parts[:path]), + ) + end + result_parts[:query] = ref_parts[:query] + result_parts[:authority] = base_parts[:authority] + end + result_parts[:scheme] = base_parts[:scheme] + reconstruct_uri(result_parts) + end + + def parse_uri(uri_str) + parts = {} + if uri_str.to_s =~ %r{\A(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?\z} + parts[:scheme] = Regexp.last_match(2) + parts[:authority] = Regexp.last_match(4) + parts[:path] = Regexp.last_match(5) + parts[:query] = Regexp.last_match(7) + end + parts + end + + def merge_paths(base_path, ref_path) + if base_path&.include?("/") + base_path.sub(%r{/[^/]*\z}, "/#{ref_path}") + else + ref_path + end + end + + # RFC 3986 §5.2.4 dot-segment removal with C14N 1.1 modifications. + TERMINAL_DOT_SEGMENTS = %w[. ..].freeze.freeze + private_constant :TERMINAL_DOT_SEGMENTS + + def remove_dot_segments(path) + input = path.to_s.dup + input = input.sub(%r{/\.\.\z}, "/../") + output = +"" + + until input.empty? + if input.start_with?("../") + input = input[3..] + elsif input.start_with?("./") + input = input[2..] + elsif input.start_with?("/./") + input = "/#{input[3..]}" + elsif input == "/." + input = "/" + elsif input.start_with?("/../") + input = "/#{input[4..]}" + output = output.sub(%r{/[^/]*\z}, "") + elsif input == "/.." + input = "/" + output = output.sub(%r{/[^/]*\z}, "") + elsif TERMINAL_DOT_SEGMENTS.include?(input) + input = "" + else + seg_match = input.start_with?("/") ? input.match(%r{\A(/[^/]*)}) : input.match(%r{\A([^/]*)}) + seg = seg_match[1] + input = input[seg.length..] + output << seg + end + end + + output.squeeze("/").then do |out| + out << "/" if out.end_with?("/..") + out + end + end + + def reconstruct_uri(parts) + result = +"" + result << "#{parts[:scheme]}:" if parts[:scheme] + result << "//#{parts[:authority]}" if parts[:authority] + result << parts[:path].to_s if parts[:path] + result << "?#{parts[:query]}" if parts[:query] + result + end + end + end +end diff --git a/lib/moxml/signature.rb b/lib/moxml/signature.rb new file mode 100644 index 00000000..6ec24fb3 --- /dev/null +++ b/lib/moxml/signature.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require "openssl" +require "base64" + +module Moxml + # W3C XML Signature (xmldsig-core-1.1) processing for any moxml adapter. + # + # All XML operations flow through Moxml::Document / Moxml::Element; the + # signature module never touches the underlying adapter (Nokogiri, Oga, + # REXML, Ox, LibXML) directly. + module Signature + DSIG_NS = "http://www.w3.org/2000/09/xmldsig#" + DSIG11_NS = "http://www.w3.org/2009/xmldsig11#" + DSIG_MORE_NS = "http://www.w3.org/2001/04/xmldsig-more#" + + autoload :Error, "moxml/signature/errors" + autoload :SignatureError, "moxml/signature/errors" + autoload :UnknownAlgorithm, "moxml/signature/errors" + autoload :DuplicateAlgorithm, "moxml/signature/errors" + autoload :SigningError, "moxml/signature/errors" + autoload :VerificationError, "moxml/signature/errors" + autoload :ReferenceDigestMismatch, "moxml/signature/errors" + autoload :SignatureValueMismatch, "moxml/signature/errors" + autoload :TransformError, "moxml/signature/errors" + autoload :CanonicalizationError, "moxml/signature/errors" + autoload :MalformedSignatureError, "moxml/signature/errors" + autoload :SignatureKeyError, "moxml/signature/errors" + + autoload :Algorithms, "moxml/signature/algorithms" + autoload :C14n, "moxml/c14n" + autoload :Model, "moxml/signature/model" + autoload :Serializer, "moxml/signature/serializer" + autoload :Parser, "moxml/signature/parser" + autoload :ReferenceResolver, "moxml/signature/reference_resolver" + autoload :Signer, "moxml/signature/signer" + autoload :Verifier, "moxml/signature/verifier" + autoload :KeyExtractor, "moxml/signature/key_extractor" + autoload :TransformPipeline, "moxml/signature/transform_pipeline" + autoload :VerificationResult, "moxml/signature/verification_result" + autoload :SingleVerificationResult, + "moxml/signature/single_verification_result" + autoload :ReferenceResult, "moxml/signature/reference_result" + + class << self + def sign(context:, document:, key:, **options) + signature = build_signature(context: context, document: document, **options) + Signer.new( + context: context, + signature: signature, + document: document, + key: key, + ).sign + signature + end + + def verify(context:, document:, key: nil, **options) + Verifier.new(context: context, document: document, key: key, **options).verify + end + + private + + def build_signature(**opts) + _context = opts[:context] + _document = opts[:document] + signed_info = Model::SignedInfo.new( + canonicalization_method: Model::AlgorithmMethod.new( + algorithm: opts[:canonicalization_method], + ), + signature_method: Model::AlgorithmMethod.new( + algorithm: opts[:signature_method], + ), + references: [ + Model::Reference.new( + uri: opts[:reference_uri], + transforms: Model::Transforms.new( + transforms: opts[:transforms].map do |transform| + Model::Transform.new(algorithm: transform) + end, + ), + digest_method: Model::DigestMethod.new(algorithm: opts[:digest_method]), + ), + ], + ) + + Model::Signature.new( + id: opts[:signature_id], + signed_info: signed_info, + key_info: opts[:key_info], + ) + end + end + end +end diff --git a/lib/moxml/signature/algorithms.rb b/lib/moxml/signature/algorithms.rb new file mode 100644 index 00000000..81a2cded --- /dev/null +++ b/lib/moxml/signature/algorithms.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +module Moxml + module Signature + # Open-closed registry of W3C XML Signature algorithms keyed by URI. + # + # Adding a new algorithm: + # 1. Subclass the relevant base (DigestBase, SignatureMethodBase, etc.) + # 2. Declare `identifier "http://..."` on the subclass. + # 3. Add an autoload entry below and a reference in `load_builtins!`. + # + # No edits to existing classes are required to add a new algorithm. + module Algorithms + CATEGORIES = %i[digest signature_method canonicalization transform].freeze + + class << self + def registry + @registry ||= CATEGORIES.to_h { |c| [c, {}] } + end + + def register(category, uri, klass) + validate_category!(category) + registry[category][uri] = klass + klass + end + + def lookup(category, uri) + load_builtins! unless @builtins_loaded + registry[category][uri] || + raise(UnknownAlgorithm.new(category, uri)) + end + + def registered?(category, uri) + load_builtins! unless @builtins_loaded + registry[category].key?(uri) + end + + def [](category) + validate_category!(category) + registry[category] + end + + # Forces autoload of every built-in algorithm class so that + # self-registration runs. Pure autoload — no `require` calls. + def load_builtins! + return if @builtins_loaded + + # Digests + SHA1 + SHA224 + SHA256 + SHA384 + SHA512 + # Signature methods + RsaPkcs1Sha + HmacSha + EcdsaSha + DsaSha + # Canonicalization + ExcC14n10 + InclusiveC14n10 + InclusiveC14n11 + # Transforms + Base64Transform + EnvelopedSignatureTransform + + @builtins_loaded = true + end + + private + + def validate_category!(category) + return if CATEGORIES.include?(category) + + raise ArgumentError, + "unknown algorithm category #{category.inspect}; " \ + "expected one of #{CATEGORIES.inspect}" + end + end + + # Base classes — interfaces only. + autoload :DigestBase, "moxml/signature/algorithms/digest_base" + autoload :SignatureMethodBase, + "moxml/signature/algorithms/signature_method_base" + autoload :CanonicalizationBase, + "moxml/signature/algorithms/canonicalization_base" + autoload :TransformBase, "moxml/signature/algorithms/transform_base" + + # Digests + autoload :SHA1, "moxml/signature/algorithms/sha1" + autoload :SHA224, "moxml/signature/algorithms/sha224" + autoload :SHA256, "moxml/signature/algorithms/sha256" + autoload :SHA384, "moxml/signature/algorithms/sha384" + autoload :SHA512, "moxml/signature/algorithms/sha512" + + # Signature methods + autoload :RsaPkcs1Sha, "moxml/signature/algorithms/rsa_pkcs1_sha" + autoload :HmacSha, "moxml/signature/algorithms/hmac_sha" + autoload :EcdsaSha, "moxml/signature/algorithms/ecdsa_sha" + autoload :DsaSha, "moxml/signature/algorithms/dsa_sha" + + # Canonicalization (delegates to Moxml::C14n engine) + autoload :ExcC14n10, "moxml/signature/algorithms/exc_c14n_10" + autoload :InclusiveC14n10, "moxml/signature/algorithms/inclusive_c14n_10" + autoload :InclusiveC14n11, "moxml/signature/algorithms/inclusive_c14n_11" + + # Transforms + autoload :Base64Transform, "moxml/signature/algorithms/base64_transform" + autoload :EnvelopedSignatureTransform, + "moxml/signature/algorithms/enveloped_signature_transform" + end + end +end diff --git a/lib/moxml/signature/algorithms/base64_transform.rb b/lib/moxml/signature/algorithms/base64_transform.rb new file mode 100644 index 00000000..8857afa8 --- /dev/null +++ b/lib/moxml/signature/algorithms/base64_transform.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "base64" + +module Moxml + module Signature + module Algorithms + # Base64 decode transform (W3C §6.6.2). + # + # Input: octets or nodeset. For nodeset, logically applies self::text(), + # sorts nodes by document order, concatenates string values, then decodes. + # Output: octets. + class Base64Transform < TransformBase + identifier "http://www.w3.org/2000/09/xmldsig#base64" + + def self.input_type; :octets; end + def self.output_type; :octets; end + + def transform(input) + text = input.is_a?(Array) ? input.map { |n| text_of(n) }.join : input.to_s + stripped = text.gsub(/\s+/, "") + Base64.strict_decode64(stripped) + rescue ArgumentError => e + raise TransformError.new( + "base64 decode failed: #{e.message}", + algorithm: self.class.identifier_uri, + ) + end + + private + + def text_of(node) + return node.text if node.is_a?(::Moxml::Node) + + node.to_s + end + end + end + end +end diff --git a/lib/moxml/signature/algorithms/canonicalization_base.rb b/lib/moxml/signature/algorithms/canonicalization_base.rb new file mode 100644 index 00000000..efc4d25c --- /dev/null +++ b/lib/moxml/signature/algorithms/canonicalization_base.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require "openssl" + +module Moxml + module Signature + module Algorithms + # Base class for canonicalization algorithms. + # + # Subclasses declare `identifier "http://..."` and implement #engine + # (returning a Moxml::C14n::* walker). Canonicalizers operate on a + # Moxml::Node subtree and return UTF-8 octets. + # + # Canonicalization algorithms can also be used as transforms per + # spec §6.6.1. The base class provides the #transform method that + # adapts the canonicalize interface to the transform pipeline. + class CanonicalizationBase + class << self + # Canonicalization presents the transform interface (spec §6.6.1). + # Input: octet stream or node-set. Output: octet stream. + def input_type; :nodeset; end + + def output_type; :octets; end + + def identifier(uri) + Algorithms.register(:canonicalization, uri, self) + end + + def for_uri(uri, **opts) + new(identifier_uri: uri, **opts) + end + end + + attr_reader :identifier_uri, :with_comments, :inclusive_namespaces, + :context + + # `context:` is required when this algorithm is used as a transform + # so that octet-stream input can be parsed with the same adapter + # the caller used for the rest of the document. + def initialize(identifier_uri: nil, with_comments: nil, + inclusive_namespaces: [], context: nil, **_unused) + @identifier_uri = identifier_uri + @with_comments = with_comments.nil? ? uri_has_comments?(identifier_uri) : with_comments + @inclusive_namespaces = inclusive_namespaces || [] + @context = context + end + + def canonicalize(node) + engine.canonicalize( + node, + with_comments: @with_comments, + inclusive_namespaces: @inclusive_namespaces, + ) + end + + # Adapt to the transform interface (spec §6.6.1). Octet-stream + # input is parsed using the same Moxml::Context the caller used; + # this preserves the byte-exact invariant across adapters. + def transform(input) + return canonicalize(input) if input.is_a?(::Moxml::Node) + + if context.nil? + raise TransformError, + "canonicalization transform requires a context to parse " \ + "octet-stream input; none was provided" + end + + parsed = context.parse(input.to_s) + canonicalize(parsed.root) + end + + private + + def uri_has_comments?(uri) + return false if uri.nil? + + uri.end_with?("#WithComments") + end + + def engine + raise NotImplementedError, + "#{self.class} must implement #engine" + end + end + end + end +end diff --git a/lib/moxml/signature/algorithms/digest_base.rb b/lib/moxml/signature/algorithms/digest_base.rb new file mode 100644 index 00000000..5a2381a3 --- /dev/null +++ b/lib/moxml/signature/algorithms/digest_base.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "openssl" +require "base64" + +module Moxml + module Signature + module Algorithms + # Base class for message digest algorithms (SHA family). + # + # Subclasses MUST declare: + # - `identifier "http://..."` to register + # - either `digest_bits N` and `openssl_digest_name "SHA256"` etc., + # OR override `#compute_digest`. + class DigestBase + class << self + attr_reader :identifier_uri, :digest_size_bits + + def identifier(uri) + @identifier_uri = uri + Algorithms.register(:digest, uri, self) + end + + def digest_bits(bits) + @digest_size_bits = bits + end + + def openssl_digest_name(name) + define_method(:compute_digest) do |data| + OpenSSL::Digest.digest(name, data) + end + end + end + + def digest(data) + compute_digest(data.to_s) + end + + def digest_base64(data) + Base64.strict_encode64(digest(data)) + end + + def compute_digest(_data) + raise NotImplementedError, + "#{self.class} must implement #compute_digest " \ + "or declare openssl_digest_name" + end + end + end + end +end diff --git a/lib/moxml/signature/algorithms/dsa_sha.rb b/lib/moxml/signature/algorithms/dsa_sha.rb new file mode 100644 index 00000000..a6297b55 --- /dev/null +++ b/lib/moxml/signature/algorithms/dsa_sha.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require "openssl" + +module Moxml + module Signature + module Algorithms + # DSA signature methods per FIPS 186-3. + # + # Two URIs: + # - SHA1 (1024-bit, q=160): wire format r‖s with 20-byte halves + # - SHA256 (2048-bit, q=256): wire format r‖s with N-byte halves + # where N = byte length of q (32 for SHA-256 case) + # + # OpenSSL returns DER; we convert to raw r‖s. + class DsaSha < SignatureMethodBase + PAIRINGS = { + "http://www.w3.org/2000/09/xmldsig#dsa-sha1" => "SHA1", + "http://www.w3.org/2009/xmldsig11#dsa-sha256" => "SHA256", + }.freeze + + PAIRINGS.each_key { |uri| identifier uri } + + def initialize(identifier_uri:, parameters: nil) + super(nil) + @identifier_uri = identifier_uri + @parameters = parameters + end + + def compute_signature(data, key) + dsa = coerce_signing_key(key) + digest_name = self.class::PAIRINGS.fetch(@identifier_uri) + der_signature = dsa.sign(digest_name, data) + der_to_raw(der_signature, coordinate_bytes_for(dsa)) + end + + def verify_signature(data, key, signature) + dsa = coerce_verify_key(key) + digest_name = self.class::PAIRINGS.fetch(@identifier_uri) + n_bytes = coordinate_bytes_for(dsa) + der_signature = raw_to_der(signature, n_bytes) + dsa.verify(digest_name, der_signature, data) + rescue ArgumentError + false + end + + private + + def coerce_signing_key(key) + case key + when OpenSSL::PKey::DSA + key + else + raise SignatureKeyError, + "DSA signature method requires OpenSSL::PKey::DSA, " \ + "got #{key.class}" + end + end + + def coerce_verify_key(key) + coerce_signing_key(key) + end + + def coordinate_bytes_for(dsa) + # q is the subgroup order. byte length of q. + q_bits = dsa.q.num_bits + (q_bits + 7) / 8 + end + + def der_to_raw(der, n_bytes) + seq = OpenSSL::ASN1.decode(der) + r = seq.value[0].value + s = seq.value[1].value + i2osp(r, n_bytes) + i2osp(s, n_bytes) + end + + def raw_to_der(raw, n_bytes) + raise ArgumentError, "raw signature has wrong size" if raw.bytesize != (2 * n_bytes) + + r = raw.byteslice(0, n_bytes) + s = raw.byteslice(n_bytes, n_bytes) + seq = OpenSSL::ASN1::Sequence.new([ + OpenSSL::ASN1::Integer.new(asn1_integer_value(r)), + OpenSSL::ASN1::Integer.new(asn1_integer_value(s)), + ]) + seq.to_der + end + + def i2osp(value, length) + n = value.to_i + raise ArgumentError, "I2OSP: integer too large" if n >= (1 << (8 * length)) + + bytes = Array.new(length, 0) + (length - 1).downto(0) do |i| + bytes[i] = n & 0xff + n >>= 8 + end + bytes.pack("C*") + end + + def asn1_integer_value(octets) + octets.bytes.reduce(0) { |acc, byte| (acc << 8) | byte } + end + end + end + end +end diff --git a/lib/moxml/signature/algorithms/ecdsa_sha.rb b/lib/moxml/signature/algorithms/ecdsa_sha.rb new file mode 100644 index 00000000..8ea98e25 --- /dev/null +++ b/lib/moxml/signature/algorithms/ecdsa_sha.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require "openssl" + +module Moxml + module Signature + module Algorithms + # ECDSA signature methods per FIPS 186-3, bound to a digest. + # + # Spec §6.4.3 requires support for ECDSAwithSHA256 over the P-256 + # curve. Recommended: P-384, P-521. + # + # The wire format is base64(I2OSP(r, n_bytes) || I2OSP(s, n_bytes)) + # where n_bytes is the byte length of the base point order + # (32 for P-256, 48 for P-384, 66 for P-521). + # + # OpenSSL returns a DER-encoded ASN.1 sequence of (r, s). We convert + # to the raw r||s form on sign, and back from raw to DER on verify. + class EcdsaSha < SignatureMethodBase + PAIRINGS = { + "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha1" => "SHA1", + "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha224" => "SHA224", + "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256" => "SHA256", + "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384" => "SHA384", + "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512" => "SHA512", + }.freeze + + # Map OpenSSL curve name → byte length of base point order. + CURVE_ORDER_BYTES = { + "prime256v1" => 32, # P-256 + "secp384r1" => 48, # P-384 + "secp521r1" => 66, # P-521 (ceil(521/8) = 66) + }.freeze + + PAIRINGS.each_key { |uri| identifier uri } + + def initialize(identifier_uri:, parameters: nil) + super(nil) + @identifier_uri = identifier_uri + @parameters = parameters + end + + def compute_signature(data, key) + ec_key = coerce_key(key) + digest_name = self.class::PAIRINGS.fetch(@identifier_uri) + der_signature = ec_key.sign(digest_name, data) + der_to_raw(der_signature, coordinate_bytes_for(ec_key)) + end + + def verify_signature(data, key, signature) + ec_key = coerce_key(key) + digest_name = self.class::PAIRINGS.fetch(@identifier_uri) + n_bytes = coordinate_bytes_for(ec_key) + der_signature = raw_to_der(signature, n_bytes) + ec_key.verify(digest_name, der_signature, data) + rescue ArgumentError + false + end + + private + + def coerce_key(key) + case key + when OpenSSL::PKey::EC then key + when OpenSSL::PKey::EC::Point + raise SignatureKeyError, + "ECDSA requires an EC key, not a bare Point" + else + raise SignatureKeyError, + "ECDSA signature method requires OpenSSL::PKey::EC, " \ + "got #{key.class}" + end + end + + def coordinate_bytes_for(ec_key) + curve_name = ec_key.group.curve_name + self.class::CURVE_ORDER_BYTES.fetch(curve_name) do + raise SignatureError, + "ECDSA curve #{curve_name.inspect} not supported; " \ + "add it to EcdsaSha::CURVE_ORDER_BYTES" + end + end + + # Convert ASN.1 DER sequence of two INTEGERs to raw r‖s octets. + def der_to_raw(der, n_bytes) + seq = OpenSSL::ASN1.decode(der) + r = seq.value[0].value + s = seq.value[1].value + i2osp(r, n_bytes) + i2osp(s, n_bytes) + end + + # Convert raw r‖s octets back to ASN.1 DER. + def raw_to_der(raw, n_bytes) + raise ArgumentError, "raw signature has wrong size" if raw.bytesize != (2 * n_bytes) + + r = raw.byteslice(0, n_bytes) + s = raw.byteslice(n_bytes, n_bytes) + seq = OpenSSL::ASN1::Sequence.new([ + OpenSSL::ASN1::Integer.new(asn1_integer_value(r)), + OpenSSL::ASN1::Integer.new(asn1_integer_value(s)), + ]) + seq.to_der + end + + # Integer to octet stream (I2OSP), minimal length parameter. + def i2osp(value, length) + n = value.to_i + raise ArgumentError, "I2OSP: integer too large" if n >= (1 << (8 * length)) + + bytes = Array.new(length, 0) + (length - 1).downto(0) do |i| + bytes[i] = n & 0xff + n >>= 8 + end + bytes.pack("C*") + end + + # Interpret an octet string as a positive integer (OS2IP). + def asn1_integer_value(octets) + octets.bytes.reduce(0) { |acc, byte| (acc << 8) | byte } + end + end + end + end +end diff --git a/lib/moxml/signature/algorithms/enveloped_signature_transform.rb b/lib/moxml/signature/algorithms/enveloped_signature_transform.rb new file mode 100644 index 00000000..232f60f6 --- /dev/null +++ b/lib/moxml/signature/algorithms/enveloped_signature_transform.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +module Moxml + module Signature + module Algorithms + # Enveloped Signature Transform (W3C §6.6.4). + # + # Removes the containing ds:Signature element from the node-set so the + # signature does not include itself in its own digest calculation. + # + # Semantics during signing: `signature_element` is nil (the Signature + # has not been attached to the document yet), so the transform is a + # no-op — the document contains no Signature to exclude. + # + # Semantics during verification: `signature_element` is the Signature + # element being verified. The transform detaches it (and its + # descendants) from a deep copy of the input so the canonicalizer + # walks a tree without the Signature. + class EnvelopedSignatureTransform < TransformBase + identifier "http://www.w3.org/2000/09/xmldsig#enveloped-signature" + + def self.input_type; :nodeset; end + def self.output_type; :nodeset; end + + def transform(input) + return input if @signature_element.nil? + + # If the signature is not within the input subtree, no-op. + return input unless signature_within?(input) + + working_copy = deep_copy(input) + remove_signature_within(working_copy) + working_copy + end + + private + + def signature_within?(input) + !find_signature_element(input).nil? + end + + def remove_signature_within(node) + sig = find_signature_element(node) + sig&.remove + end + + def find_signature_element(node) + return nil unless node + return node if signature_element?(node) + + children = node.children if node.is_a?(::Moxml::Node) + children&.each do |child| + found = find_signature_element(child) + return found if found + end + nil + end + + def signature_element?(node) + node.is_a?(::Moxml::Element) && + node.name == "Signature" && + node.namespace_uri == DSIG_NS + end + + def deep_copy(input) + case input + when ::Moxml::Document + @context.parse(input.root.to_xml(indent: 0)) + when ::Moxml::Element + wrapper = @context.parse("<__wrap__>#{input.to_xml(indent: 0)}") + wrapper.root.children.first + else + input + end + end + end + end + end +end diff --git a/lib/moxml/signature/algorithms/exc_c14n_10.rb b/lib/moxml/signature/algorithms/exc_c14n_10.rb new file mode 100644 index 00000000..997fee1c --- /dev/null +++ b/lib/moxml/signature/algorithms/exc_c14n_10.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Moxml + module Signature + module Algorithms + # Exclusive XML Canonicalization 1.0 (https://www.w3.org/TR/xml-exc-c14n/) + # + # Two registered URIs: one omits comments, one includes comments. + # The with_comments variant is detected from the URI suffix at + # instantiation time (see CanonicalizationBase#uri_has_comments?). + class ExcC14n10 < CanonicalizationBase + identifier "http://www.w3.org/2001/10/xml-exc-c14n#" + identifier "http://www.w3.org/2001/10/xml-exc-c14n#WithComments" + + private + + def engine + ::Moxml::C14n::Exclusive.new + end + end + end + end +end diff --git a/lib/moxml/signature/algorithms/hmac_sha.rb b/lib/moxml/signature/algorithms/hmac_sha.rb new file mode 100644 index 00000000..e33491b9 --- /dev/null +++ b/lib/moxml/signature/algorithms/hmac_sha.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require "openssl" + +module Moxml + module Signature + module Algorithms + # HMAC per RFC 2104, bound to a digest. + # + # Per W3C XML Signature §4.4.2 and §6.3.1, if `HMACOutputLength` is + # specified, the output is truncated to that many bits and the + # truncation length MUST be at least max(hash_bits / 2, 80) bits. + class HmacSha < SignatureMethodBase + PAIRINGS = { + "http://www.w3.org/2000/09/xmldsig#hmac-sha1" => "SHA1", + "http://www.w3.org/2001/04/xmldsig-more#hmac-sha224" => "SHA224", + "http://www.w3.org/2001/04/xmldsig-more#hmac-sha256" => "SHA256", + "http://www.w3.org/2001/04/xmldsig-more#hmac-sha384" => "SHA384", + "http://www.w3.org/2001/04/xmldsig-more#hmac-sha512" => "SHA512", + }.freeze + + HASH_BITS = { + "SHA1" => 160, + "SHA224" => 224, + "SHA256" => 256, + "SHA384" => 384, + "SHA512" => 512, + }.freeze + + MIN_TRUNCATION_BITS = 80 + + PAIRINGS.each_key { |uri| identifier uri } + + # parameters: optional { hmac_output_length: Integer } (bits, multiple of 8) + def initialize(identifier_uri:, parameters: nil) + super(parameters) + @identifier_uri = identifier_uri + @truncation_bits = parameters&.dig(:hmac_output_length) + validate_truncation! if @truncation_bits + end + + def compute_signature(data, key) + secret = coerce_key(key) + full = OpenSSL::HMAC.digest(digest_name, secret, data) + truncate(full) + end + + def verify_signature(data, key, signature) + expected = compute_signature(data, key) + fixed_comparison(expected, signature) + end + + private + + def digest_name + PAIRINGS.fetch(@identifier_uri) + end + + def hash_bits + HASH_BITS.fetch(digest_name) + end + + def truncate(full_mac) + return full_mac unless @truncation_bits + + unless (@truncation_bits % 8).zero? + raise SignatureError, + "HMACOutputLength (#{@truncation_bits}) must be a " \ + "multiple of 8" + end + + min = [hash_bits / 2, MIN_TRUNCATION_BITS].max + if @truncation_bits < min + raise SignatureError, + "HMACOutputLength (#{@truncation_bits}) below minimum " \ + "of #{min} for #{digest_name}" + end + + full_mac.byteslice(0, @truncation_bits / 8) + end + + def validate_truncation! + unless (@truncation_bits % 8).zero? + raise SignatureError, + "HMACOutputLength (#{@truncation_bits}) must be a " \ + "multiple of 8" + end + + min = [hash_bits / 2, MIN_TRUNCATION_BITS].max + return if @truncation_bits >= min + + raise SignatureError, + "HMACOutputLength (#{@truncation_bits}) below minimum " \ + "of #{min} for #{digest_name}" + end + + def coerce_key(key) + case key + when String then key + else + raise SignatureKeyError, + "HMAC signature method requires a String secret, " \ + "got #{key.class}" + end + end + + def fixed_comparison(expected, actual) + return false unless expected.bytesize == actual.bytesize + + OpenSSL.fixed_length_secure_compare(expected, actual) + rescue ArgumentError + false + end + end + end + end +end diff --git a/lib/moxml/signature/algorithms/inclusive_c14n_10.rb b/lib/moxml/signature/algorithms/inclusive_c14n_10.rb new file mode 100644 index 00000000..6ba36cb8 --- /dev/null +++ b/lib/moxml/signature/algorithms/inclusive_c14n_10.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Moxml + module Signature + module Algorithms + # Canonical XML 1.0 (https://www.w3.org/TR/xml-c14n/) + # + # Implemented via the canon-derived C14n::Inclusive10 engine. + class InclusiveC14n10 < CanonicalizationBase + identifier "http://www.w3.org/TR/2001/REC-xml-c14n-20010315" + identifier "http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments" + + private + + def engine + ::Moxml::C14n::Inclusive10.new + end + end + end + end +end diff --git a/lib/moxml/signature/algorithms/inclusive_c14n_11.rb b/lib/moxml/signature/algorithms/inclusive_c14n_11.rb new file mode 100644 index 00000000..1f277df9 --- /dev/null +++ b/lib/moxml/signature/algorithms/inclusive_c14n_11.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Moxml + module Signature + module Algorithms + # Canonical XML 1.1 (https://www.w3.org/TR/xml-c14n11/) + # + # Currently delegates to Inclusive 1.0. Full 1.1 differences + # (XML 1.1 line-ending handling, notations, DTD entity refs) + # are documented in TODO.complete/05-c14n-engine.md. + class InclusiveC14n11 < CanonicalizationBase + identifier "http://www.w3.org/2006/12/xml-c14n11" + identifier "http://www.w3.org/2006/12/xml-c14n11#WithComments" + + private + + def engine + ::Moxml::C14n::Inclusive10.new + end + end + end + end +end diff --git a/lib/moxml/signature/algorithms/rsa_pkcs1_sha.rb b/lib/moxml/signature/algorithms/rsa_pkcs1_sha.rb new file mode 100644 index 00000000..127ebb14 --- /dev/null +++ b/lib/moxml/signature/algorithms/rsa_pkcs1_sha.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "openssl" + +module Moxml + module Signature + module Algorithms + # RSASSA-PKCS1-v1_5 per RFC 3447 §8.2, bound to a digest. + # + # One Ruby class registered under five URIs (sha1 / sha224 / sha256 / + # sha384 / sha512). When instantiated, the class is told which URI was + # resolved so it can pick the right digest. + class RsaPkcs1Sha < SignatureMethodBase + PAIRINGS = { + "http://www.w3.org/2000/09/xmldsig#rsa-sha1" => "SHA1", + "http://www.w3.org/2001/04/xmldsig-more#rsa-sha224" => "SHA224", + "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" => "SHA256", + "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384" => "SHA384", + "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512" => "SHA512", + }.freeze + + PAIRINGS.each_key { |uri| identifier uri } + + def initialize(identifier_uri:, parameters: nil) + super(nil) + @identifier_uri = identifier_uri + @parameters = parameters + end + + def compute_signature(data, key) + rsa_key = coerce_key(key) + rsa_key.sign(digest_name, data) + end + + def verify_signature(data, key, signature) + rsa_key = coerce_key(key) + rsa_key.verify(digest_name, signature, data) + end + + private + + def digest_name + PAIRINGS.fetch(@identifier_uri) + end + + def coerce_key(key) + return key if key.is_a?(OpenSSL::PKey::RSA) + + raise SignatureKeyError, + "RSA signature method requires OpenSSL::PKey::RSA, " \ + "got #{key.class}" + end + end + end + end +end diff --git a/lib/moxml/signature/algorithms/sha1.rb b/lib/moxml/signature/algorithms/sha1.rb new file mode 100644 index 00000000..9f4d074d --- /dev/null +++ b/lib/moxml/signature/algorithms/sha1.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Moxml + module Signature + module Algorithms + class SHA1 < DigestBase + identifier "http://www.w3.org/2000/09/xmldsig#sha1" + digest_bits 160 + openssl_digest_name "SHA1" + end + end + end +end diff --git a/lib/moxml/signature/algorithms/sha224.rb b/lib/moxml/signature/algorithms/sha224.rb new file mode 100644 index 00000000..71f13655 --- /dev/null +++ b/lib/moxml/signature/algorithms/sha224.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Moxml + module Signature + module Algorithms + class SHA224 < DigestBase + identifier "http://www.w3.org/2001/04/xmldsig-more#sha224" + digest_bits 224 + openssl_digest_name "SHA224" + end + end + end +end diff --git a/lib/moxml/signature/algorithms/sha256.rb b/lib/moxml/signature/algorithms/sha256.rb new file mode 100644 index 00000000..0f74eab4 --- /dev/null +++ b/lib/moxml/signature/algorithms/sha256.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Moxml + module Signature + module Algorithms + class SHA256 < DigestBase + identifier "http://www.w3.org/2001/04/xmlenc#sha256" + digest_bits 256 + openssl_digest_name "SHA256" + end + end + end +end diff --git a/lib/moxml/signature/algorithms/sha384.rb b/lib/moxml/signature/algorithms/sha384.rb new file mode 100644 index 00000000..71011cc2 --- /dev/null +++ b/lib/moxml/signature/algorithms/sha384.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Moxml + module Signature + module Algorithms + class SHA384 < DigestBase + identifier "http://www.w3.org/2001/04/xmldsig-more#sha384" + digest_bits 384 + openssl_digest_name "SHA384" + end + end + end +end diff --git a/lib/moxml/signature/algorithms/sha512.rb b/lib/moxml/signature/algorithms/sha512.rb new file mode 100644 index 00000000..af92e0f3 --- /dev/null +++ b/lib/moxml/signature/algorithms/sha512.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Moxml + module Signature + module Algorithms + class SHA512 < DigestBase + identifier "http://www.w3.org/2001/04/xmlenc#sha512" + digest_bits 512 + openssl_digest_name "SHA512" + end + end + end +end diff --git a/lib/moxml/signature/algorithms/signature_method_base.rb b/lib/moxml/signature/algorithms/signature_method_base.rb new file mode 100644 index 00000000..1350aa91 --- /dev/null +++ b/lib/moxml/signature/algorithms/signature_method_base.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Moxml + module Signature + module Algorithms + # Base class for signature methods and MACs. + # + # Subclasses declare an identifier URI per W3C spec §6.4 / §6.3 and + # implement #compute_signature and #verify_signature. + # + # `key` is whatever OpenSSL expects (OpenSSL::PKey::RSA for RSA, a + # String secret for HMAC, etc.). + class SignatureMethodBase + class << self + attr_reader :identifier_uri, :digest_uri + + def identifier(uri, digest_uri: nil) + @identifier_uri = uri + @digest_uri = digest_uri + Algorithms.register(:signature_method, uri, self) + end + end + + # Optional constructor for parameterized methods (e.g. HMACOutputLength). + def initialize(parameters = nil) + @parameters = parameters + end + + def sign(data, key) + compute_signature(data, key) + rescue OpenSSL::PKey::PKeyError => e + raise SigningError.new( + "signing failed: #{e.class}", + algorithm: self.class.identifier_uri, + ) + end + + def verify(data, key, signature) + verify_signature(data, key, signature) + rescue OpenSSL::PKey::PKeyError => e + raise VerificationError.new( + "verification raised: #{e.class}", + ) + end + + def compute_signature(_data, _key) + raise NotImplementedError, + "#{self.class} must implement #compute_signature" + end + + def verify_signature(_data, _key, _signature) + raise NotImplementedError, + "#{self.class} must implement #verify_signature" + end + end + end + end +end diff --git a/lib/moxml/signature/algorithms/transform_base.rb b/lib/moxml/signature/algorithms/transform_base.rb new file mode 100644 index 00000000..902f8828 --- /dev/null +++ b/lib/moxml/signature/algorithms/transform_base.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Moxml + module Signature + module Algorithms + # Base class for XML Signature transforms. + # + # Each transform declares input and output types (:octets or :nodeset) + # plus an identifier URI. The reference resolver chains them with + # default type conversion when types mismatch (octets → nodeset via + # XML parse; nodeset → octets via inclusive C14N 1.0). + class TransformBase + class << self + attr_reader :identifier_uri + + def identifier(uri) + @identifier_uri = uri + Algorithms.register(:transform, uri, self) + end + + def input_type; :octets; end + def output_type; :octets; end + end + + # `parameters`: optional hash parsed from the ds:Transform element + # (e.g. { xpaths: [...] } for the XPath Filter transform). + def initialize(parameters: nil, context: nil, signature_element: nil) + @parameters = parameters || {} + @context = context + @signature_element = signature_element + end + + def transform(_input) + raise NotImplementedError, + "#{self.class} must implement #transform" + end + end + end + end +end diff --git a/lib/moxml/signature/errors.rb b/lib/moxml/signature/errors.rb new file mode 100644 index 00000000..7917d6d5 --- /dev/null +++ b/lib/moxml/signature/errors.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +module Moxml + module Signature + class Error < ::Moxml::Error; end + + class SignatureError < Error; end + + class UnknownAlgorithm < Error + attr_reader :category, :uri + + def initialize(category, uri) + @category = category + @uri = uri + super("Unknown #{category} algorithm: #{uri}") + end + end + + class DuplicateAlgorithm < Error + attr_reader :category, :uri + + def initialize(category, uri) + @category = category + @uri = uri + super("Algorithm already registered for #{category}: #{uri}") + end + end + + class SigningError < Error + attr_reader :algorithm + + def initialize(message, algorithm: nil) + @algorithm = algorithm + super(message) + end + end + + class VerificationError < Error + attr_reader :signature_id + + def initialize(message, signature_id: nil) + @signature_id = signature_id + super(message) + end + end + + class ReferenceDigestMismatch < VerificationError + attr_reader :reference_uri, :expected, :computed + + def initialize(reference_uri:, expected:, computed:, signature_id: nil) + @reference_uri = reference_uri + @expected = expected + @computed = computed + super( + "Digest mismatch for reference #{reference_uri.inspect}", + signature_id: signature_id, + ) + end + end + + class SignatureValueMismatch < VerificationError + attr_reader :algorithm + + def initialize(algorithm: nil, signature_id: nil) + @algorithm = algorithm + super("SignatureValue did not verify", signature_id: signature_id) + end + end + + class TransformError < Error + attr_reader :algorithm + + def initialize(message, algorithm: nil) + @algorithm = algorithm + super(message) + end + end + + class CanonicalizationError < Error + attr_reader :algorithm + + def initialize(message, algorithm: nil) + @algorithm = algorithm + super(message) + end + end + + class MalformedSignatureError < Error + attr_reader :detail + + def initialize(message, detail: nil) + @detail = detail + super(message) + end + end + + class SignatureKeyError < Error; end + end +end diff --git a/lib/moxml/signature/key_extractor.rb b/lib/moxml/signature/key_extractor.rb new file mode 100644 index 00000000..593195aa --- /dev/null +++ b/lib/moxml/signature/key_extractor.rb @@ -0,0 +1,172 @@ +# frozen_string_literal: true + +require "openssl" +require "base64" + +module Moxml + module Signature + # Extracts an OpenSSL verification key from a Model::KeyInfo. + # + # Strategy (per spec §4.5): X509Certificate is preferred for key + # reconstruction because it carries the certified binding. Falls back + # to RSAKeyValue / DSAKeyValue / ECKeyValue if no certificate is + # present. KeyName is resolved via an application-supplied key map. + class KeyExtractor + attr_reader :key_map, :cert_store + + def initialize(key_map: {}, cert_store: nil) + @key_map = key_map || {} + @cert_store = cert_store + end + + def extract(key_info) + return nil if key_info.nil? + + from_x509_data(key_info.x509_data) || + from_key_value(key_info.key_value) || + from_key_name(key_info.key_name) + end + + private + + def from_x509_data(x509_data) + return nil unless x509_data + + cert_b64 = x509_data.certificates.first + return nil unless cert_b64 + + # Certificates are stored as base64-encoded DER (spec §4.5.4). + # Decode here so we hand OpenSSL raw DER bytes. + cert_der = Base64.strict_decode64(cert_b64.to_s.gsub(/\s+/, "")) + cert = OpenSSL::X509::Certificate.new(cert_der) + cert.public_key + rescue OpenSSL::X509::CertificateError, ArgumentError + nil + end + + def from_key_value(key_value) + return nil unless key_value + + if key_value.rsa_key_value + rsa_public_key(key_value.rsa_key_value) + elsif key_value.dsa_key_value + dsa_public_key(key_value.dsa_key_value) + elsif key_value.ec_key_value + ec_public_key(key_value.ec_key_value) + end + end + + def from_key_name(key_name) + return nil unless key_name + + key_map[key_name] + end + + def rsa_public_key(rsa_kv) + n = crypto_binary_to_integer(rsa_kv.modulus) + e = crypto_binary_to_integer(rsa_kv.exponent) + return nil unless n && e + + # OpenSSL 3.x removed RSA.new(n, e). Reconstruct via the + # SubjectPublicKeyInfo form (PKCS#1 RSA public key DER). + asn1 = OpenSSL::ASN1::Sequence.new( + [ + OpenSSL::ASN1::Integer.new(n), + OpenSSL::ASN1::Integer.new(e), + ], + ) + OpenSSL::PKey::RSA.new(asn1.to_der) + rescue OpenSSL::PKey::RSAError + nil + end + + def dsa_public_key(dsa_kv) + p = crypto_binary_to_integer(dsa_kv.p) + q = crypto_binary_to_integer(dsa_kv.q) + g = crypto_binary_to_integer(dsa_kv.g) if dsa_kv.g + y = crypto_binary_to_integer(dsa_kv.y) + return nil unless p && q && y + + params = OpenSSL::ASN1::Sequence.new( + [ + OpenSSL::ASN1::Integer.new(p), + OpenSSL::ASN1::Integer.new(q), + OpenSSL::ASN1::Integer.new(g || 0), + OpenSSL::ASN1::Integer.new(y), + ], + ) + OpenSSL::PKey::DSA.new(params.to_der) + rescue OpenSSL::PKey::DSAError + nil + end + + def ec_public_key(ec_kv) + return nil unless ec_kv.named_curve_uri && ec_kv.public_key + + curve_name = curve_name_for(ec_kv.named_curve_uri) + return nil unless curve_name + + # The XML signature PublicKey element already contains the + # uncompressed-point form (0x04 || x || y) per spec §4.5.2.3. + public_key_decoded = Base64.strict_decode64(ec_kv.public_key) + spki_der = build_ec_subject_public_key_info(curve_name, public_key_decoded) + OpenSSL::PKey::EC.new(spki_der) + rescue OpenSSL::PKey::ECError, ArgumentError + nil + end + + # Build a SubjectPublicKeyInfo DER for an EC public key. + # OpenSSL 3.0 makes PKey instances immutable; we can no longer + # create an empty EC key and assign public_key=. Constructing the + # full SPKI and reading it back via OpenSSL::PKey::EC.new(der) + # is the supported path. + def build_ec_subject_public_key_info(curve_name, point_octets) + algorithm = OpenSSL::ASN1::Sequence.new( + [ + OpenSSL::ASN1::ObjectId.new("id-ecPublicKey"), + OpenSSL::ASN1::ObjectId.new(curve_name_to_oid(curve_name)), + ], + ) + OpenSSL::ASN1::Sequence.new( + [ + algorithm, + OpenSSL::ASN1::BitString.new(point_octets), + ], + ).to_der + end + + CURVE_NAME_TO_OID = { + "prime256v1" => "1.2.840.10045.3.1.7", + "secp384r1" => "1.3.132.0.34", + "secp521r1" => "1.3.132.0.35", + }.freeze + private_constant :CURVE_NAME_TO_OID + + def curve_name_to_oid(curve_name) + CURVE_NAME_TO_OID.fetch(curve_name) + end + + # ds:CryptoBinary is base64-encoded I2OSP output. + def crypto_binary_to_integer(base64_text) + return nil unless base64_text + + raw = Base64.strict_decode64(base64_text.to_s.gsub(/\s+/, "")) + raw.bytes.reduce(0) { |acc, byte| (acc << 8) | byte } + rescue ArgumentError + nil + end + + # Map NamedCurve URN OIDs to OpenSSL curve names (RFC 5480 §2.1). + CURVE_OID_TO_NAME = { + "urn:oid:1.2.840.10045.3.1.7" => "prime256v1", # P-256 + "urn:oid:1.3.132.0.34" => "secp384r1", # P-384 + "urn:oid:1.3.132.0.35" => "secp521r1", # P-521 + }.freeze + private_constant :CURVE_OID_TO_NAME + + def curve_name_for(uri) + CURVE_OID_TO_NAME[uri] + end + end + end +end diff --git a/lib/moxml/signature/model.rb b/lib/moxml/signature/model.rb new file mode 100644 index 00000000..9c21c587 --- /dev/null +++ b/lib/moxml/signature/model.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Moxml + module Signature + # Plain-Ruby-Object model of the W3C ds:Signature schema. + # + # Models carry data; they do not (de)serialize themselves. The Serializer + # walks a model and emits a Moxml document; the Parser walks a Moxml + # document and constructs a model. This separation keeps the data shape + # and the wire shape independent. + module Model + autoload :Signature, "moxml/signature/model/signature" + autoload :SignedInfo, "moxml/signature/model/signed_info" + autoload :Reference, "moxml/signature/model/reference" + autoload :Transforms, "moxml/signature/model/transforms" + autoload :Transform, "moxml/signature/model/transform" + autoload :DigestMethod, "moxml/signature/model/digest_method" + autoload :AlgorithmMethod, "moxml/signature/model/algorithm_method" + autoload :SignatureValue, "moxml/signature/model/signature_value" + autoload :KeyInfo, "moxml/signature/model/key_info" + autoload :KeyValue, "moxml/signature/model/key_value" + autoload :ObjectElement, "moxml/signature/model/object_element" + + module Key + autoload :X509Data, "moxml/signature/model/key/x509_data" + autoload :X509IssuerSerial, + "moxml/signature/model/key/x509_issuer_serial" + autoload :X509Digest, "moxml/signature/model/key/x509_digest" + autoload :RSAKeyValue, "moxml/signature/model/key/rsa_key_value" + autoload :DSAKeyValue, "moxml/signature/model/key/dsa_key_value" + autoload :ECKeyValue, "moxml/signature/model/key/ec_key_value" + end + end + end +end diff --git a/lib/moxml/signature/model/algorithm_method.rb b/lib/moxml/signature/model/algorithm_method.rb new file mode 100644 index 00000000..bedfac26 --- /dev/null +++ b/lib/moxml/signature/model/algorithm_method.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Moxml + module Signature + module Model + # Generic algorithm method element. Used for CanonicalizationMethod and + # SignatureMethod, both of which are `` shape. + class AlgorithmMethod + attr_accessor :algorithm, :parameters + + def initialize(algorithm:, parameters: {}) + @algorithm = algorithm + @parameters = parameters + end + end + end + end +end diff --git a/lib/moxml/signature/model/digest_method.rb b/lib/moxml/signature/model/digest_method.rb new file mode 100644 index 00000000..dd9e1fb1 --- /dev/null +++ b/lib/moxml/signature/model/digest_method.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Moxml + module Signature + module Model + class DigestMethod + attr_accessor :algorithm + + def initialize(algorithm:) + @algorithm = algorithm + end + end + end + end +end diff --git a/lib/moxml/signature/model/key/dsa_key_value.rb b/lib/moxml/signature/model/key/dsa_key_value.rb new file mode 100644 index 00000000..05f26b4d --- /dev/null +++ b/lib/moxml/signature/model/key/dsa_key_value.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Moxml + module Signature + module Model + module Key + # ds:DSAKeyValue — P, Q, G, Y required; J, Seed, PgenCounter optional. + # Attribute names mirror the W3C spec §4.5.2.1 element names + # (P, Q, G, Y, J, Seed, PgenCounter). + class DSAKeyValue + attr_accessor :p, :q, :g, :y, :j, :seed, :pgen_counter + + # rubocop:disable Naming/MethodParameterName -- P/Q/G/Y/J are W3C spec names + def initialize(p:, q:, y:, g: nil, j: nil, seed: nil, + pgen_counter: nil) + @p = p + @q = q + @y = y + @g = g + @j = j + @seed = seed + @pgen_counter = pgen_counter + end + # rubocop:enable Naming/MethodParameterName + end + end + end + end +end diff --git a/lib/moxml/signature/model/key/ec_key_value.rb b/lib/moxml/signature/model/key/ec_key_value.rb new file mode 100644 index 00000000..b7f19da1 --- /dev/null +++ b/lib/moxml/signature/model/key/ec_key_value.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Moxml + module Signature + module Model + module Key + # dsig11:ECKeyValue — NamedCurve URI + PublicKey octets, or + # explicit ECParameters. + class ECKeyValue + attr_accessor :named_curve_uri, :public_key, :ec_parameters + + def initialize(named_curve_uri: nil, public_key: nil, + ec_parameters: nil) + @named_curve_uri = named_curve_uri + @public_key = public_key + @ec_parameters = ec_parameters + end + end + end + end + end +end diff --git a/lib/moxml/signature/model/key/rsa_key_value.rb b/lib/moxml/signature/model/key/rsa_key_value.rb new file mode 100644 index 00000000..7347da12 --- /dev/null +++ b/lib/moxml/signature/model/key/rsa_key_value.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Moxml + module Signature + module Model + module Key + # ds:RSAKeyValue — base64-encoded CryptoBinary Modulus + Exponent. + class RSAKeyValue + attr_accessor :modulus, :exponent + + def initialize(modulus:, exponent:) + @modulus = modulus + @exponent = exponent + end + end + end + end + end +end diff --git a/lib/moxml/signature/model/key/x509_data.rb b/lib/moxml/signature/model/key/x509_data.rb new file mode 100644 index 00000000..7e142b57 --- /dev/null +++ b/lib/moxml/signature/model/key/x509_data.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Moxml + module Signature + module Model + module Key + # ds:X509Data (spec §4.5.4) — container for certificate identifiers. + # May carry X509IssuerSerial, X509SubjectName, X509SKI, X509Certificate, + # X509CRL, and dsig11:X509Digest children, all describing the same key. + class X509Data + attr_accessor :issuer_serial, :subject_name, :subject_key_id, + :certificates, :crls, :digests + + def initialize(issuer_serial: nil, subject_name: nil, + subject_key_id: nil, certificates: [], + crls: [], digests: []) + @issuer_serial = issuer_serial + @subject_name = subject_name + @subject_key_id = subject_key_id + @certificates = Array(certificates) + @crls = Array(crls) + @digests = Array(digests) + end + + def first_certificate + certificates.first + end + end + end + end + end +end diff --git a/lib/moxml/signature/model/key/x509_digest.rb b/lib/moxml/signature/model/key/x509_digest.rb new file mode 100644 index 00000000..854bd4d8 --- /dev/null +++ b/lib/moxml/signature/model/key/x509_digest.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Moxml + module Signature + module Model + module Key + # dsig11:X509Digest — algorithm URI + base64 digest of a cert. + class X509Digest + attr_accessor :algorithm, :digest + + def initialize(algorithm:, digest:) + @algorithm = algorithm + @digest = digest + end + end + end + end + end +end diff --git a/lib/moxml/signature/model/key/x509_issuer_serial.rb b/lib/moxml/signature/model/key/x509_issuer_serial.rb new file mode 100644 index 00000000..0ea3154e --- /dev/null +++ b/lib/moxml/signature/model/key/x509_issuer_serial.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Moxml + module Signature + module Model + module Key + # ds:X509IssuerSerial — deprecated in favor of X509Digest. + class X509IssuerSerial + attr_accessor :issuer_name, :serial_number + + def initialize(issuer_name:, serial_number:) + @issuer_name = issuer_name + @serial_number = serial_number + end + end + end + end + end +end diff --git a/lib/moxml/signature/model/key_info.rb b/lib/moxml/signature/model/key_info.rb new file mode 100644 index 00000000..31949edb --- /dev/null +++ b/lib/moxml/signature/model/key_info.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Moxml + module Signature + module Model + class KeyInfo + attr_accessor :id, :key_name, :key_value, :x509_data, + :raw_elements + + def initialize(id: nil, key_name: nil, key_value: nil, + x509_data: nil, raw_elements: []) + @id = id + @key_name = key_name + @key_value = key_value + @x509_data = x509_data + @raw_elements = raw_elements + end + end + end + end +end diff --git a/lib/moxml/signature/model/key_value.rb b/lib/moxml/signature/model/key_value.rb new file mode 100644 index 00000000..b9673b59 --- /dev/null +++ b/lib/moxml/signature/model/key_value.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Moxml + module Signature + module Model + class KeyValue + attr_accessor :rsa_key_value, :dsa_key_value, :ec_key_value, + :raw_element + + def initialize(rsa_key_value: nil, dsa_key_value: nil, + ec_key_value: nil, raw_element: nil) + @rsa_key_value = rsa_key_value + @dsa_key_value = dsa_key_value + @ec_key_value = ec_key_value + @raw_element = raw_element + end + end + end + end +end diff --git a/lib/moxml/signature/model/object_element.rb b/lib/moxml/signature/model/object_element.rb new file mode 100644 index 00000000..159dd6f5 --- /dev/null +++ b/lib/moxml/signature/model/object_element.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Moxml + module Signature + module Model + class ObjectElement + attr_accessor :id, :mime_type, :encoding, :content + + def initialize(id: nil, mime_type: nil, encoding: nil, content: nil) + @id = id + @mime_type = mime_type + @encoding = encoding + @content = content + end + end + end + end +end diff --git a/lib/moxml/signature/model/reference.rb b/lib/moxml/signature/model/reference.rb new file mode 100644 index 00000000..12bb2feb --- /dev/null +++ b/lib/moxml/signature/model/reference.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Moxml + module Signature + module Model + class Reference + attr_accessor :id, :uri, :type, :transforms, :digest_method, + :digest_value + + def initialize(id: nil, uri: nil, type: nil, transforms: nil, + digest_method: nil, digest_value: nil) + @id = id + @uri = uri + @type = type + @transforms = transforms + @digest_method = digest_method + @digest_value = digest_value + end + end + end + end +end diff --git a/lib/moxml/signature/model/signature.rb b/lib/moxml/signature/model/signature.rb new file mode 100644 index 00000000..3c8a2a96 --- /dev/null +++ b/lib/moxml/signature/model/signature.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Moxml + module Signature + module Model + class Signature + attr_accessor :id, :signed_info, :signature_value, + :key_info, :objects + + def initialize(id: nil, signed_info: nil, signature_value: nil, + key_info: nil, objects: []) + @id = id + @signed_info = signed_info + @signature_value = signature_value + @key_info = key_info + @objects = objects + end + end + end + end +end diff --git a/lib/moxml/signature/model/signature_value.rb b/lib/moxml/signature/model/signature_value.rb new file mode 100644 index 00000000..81c5ce78 --- /dev/null +++ b/lib/moxml/signature/model/signature_value.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Moxml + module Signature + module Model + class SignatureValue + attr_accessor :id, :value + + def initialize(id: nil, value: nil) + @id = id + @value = value + end + end + end + end +end diff --git a/lib/moxml/signature/model/signed_info.rb b/lib/moxml/signature/model/signed_info.rb new file mode 100644 index 00000000..ffd9d6ea --- /dev/null +++ b/lib/moxml/signature/model/signed_info.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Moxml + module Signature + module Model + class SignedInfo + attr_accessor :id, :canonicalization_method, :signature_method, + :references + + def initialize(id: nil, canonicalization_method: nil, + signature_method: nil, references: []) + @id = id + @canonicalization_method = canonicalization_method + @signature_method = signature_method + @references = references + end + end + end + end +end diff --git a/lib/moxml/signature/model/transform.rb b/lib/moxml/signature/model/transform.rb new file mode 100644 index 00000000..869e29f8 --- /dev/null +++ b/lib/moxml/signature/model/transform.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Moxml + module Signature + module Model + class Transform + attr_accessor :algorithm, :parameters + + def initialize(algorithm:, parameters: {}) + @algorithm = algorithm + @parameters = parameters + end + end + end + end +end diff --git a/lib/moxml/signature/model/transforms.rb b/lib/moxml/signature/model/transforms.rb new file mode 100644 index 00000000..7b9e0de0 --- /dev/null +++ b/lib/moxml/signature/model/transforms.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Moxml + module Signature + module Model + class Transforms + attr_accessor :transforms + + def initialize(transforms: []) + @transforms = Array(transforms) + end + + def <<(transform) + @transforms << transform + self + end + + def each(&block) + @transforms.each(&block) + end + + def empty? + @transforms.empty? + end + + def size + @transforms.size + end + + def length + @transforms.size + end + end + end + end +end diff --git a/lib/moxml/signature/parser.rb b/lib/moxml/signature/parser.rb new file mode 100644 index 00000000..5945e2fa --- /dev/null +++ b/lib/moxml/signature/parser.rb @@ -0,0 +1,270 @@ +# frozen_string_literal: true + +require "base64" + +module Moxml + module Signature + # Translates a Moxml::Document containing ds:Signature into a Model::Signature. + # + # Robust against namespace prefix variations (ds:, dsig:, default ns). + class Parser + DS = { "ds" => DSIG_NS }.freeze + + attr_reader :context + + def initialize(context:) + @context = context + end + + # `signature_element`: a Moxml::Element whose name is Signature in the + # xmldsig# namespace. + def parse(signature_element) + Model::Signature.new( + id: signature_element["Id"], + signed_info: parse_signed_info(at_child(signature_element, "SignedInfo")), + signature_value: parse_signature_value(at_child(signature_element, "SignatureValue")), + key_info: parse_key_info(at_child(signature_element, "KeyInfo")), + objects: [], + ) + end + + private + + def parse_signed_info(elem) + return nil unless elem + + Model::SignedInfo.new( + id: elem["Id"], + canonicalization_method: parse_algorithm_method(at_child(elem, "CanonicalizationMethod")), + signature_method: parse_algorithm_method(at_child(elem, "SignatureMethod")), + references: at_children(elem, "Reference").map { |r| parse_reference(r) }, + ) + end + + def parse_algorithm_method(elem) + return nil unless elem + + parameters = {} + hmac_len = at_child(elem, "HMACOutputLength") + if hmac_len + length = begin + Integer(hmac_len.text.strip) + rescue StandardError + nil + end + parameters[:hmac_output_length] = length if length + end + + Model::AlgorithmMethod.new( + algorithm: elem["Algorithm"], + parameters: parameters, + ) + end + + def parse_reference(elem) + return nil unless elem + + transforms_elem = at_child(elem, "Transforms") + Model::Reference.new( + id: elem["Id"], + uri: elem["URI"], + type: elem["Type"], + transforms: parse_transforms(transforms_elem), + digest_method: parse_digest_method(at_child(elem, "DigestMethod")), + digest_value: at_child(elem, "DigestValue")&.text || "", + ) + end + + def parse_transforms(elem) + return nil unless elem + + transforms = at_children(elem, "Transform").map do |t| + xpath_children = at_children(t, "XPath").map(&:text) + Model::Transform.new( + algorithm: t["Algorithm"], + parameters: xpath_children.empty? ? {} : { xpaths: xpath_children }, + ) + end + Model::Transforms.new(transforms: transforms) + end + + def parse_digest_method(elem) + return nil unless elem + + Model::DigestMethod.new(algorithm: elem["Algorithm"]) + end + + def parse_signature_value(elem) + return nil unless elem + + text = (elem.text || "").gsub(/\s+/, "") + value = text.empty? ? nil : Base64.strict_decode64(text) + Model::SignatureValue.new(id: elem["Id"], value: value) + rescue ArgumentError => e + raise MalformedSignatureError.new( + "SignatureValue is not valid base64: #{e.message}", + ) + end + + def parse_key_info(elem) + return nil unless elem + + key_name_el = at_child(elem, "KeyName") + x509_data_el = at_child(elem, "X509Data") + key_value_el = at_child(elem, "KeyValue") + + Model::KeyInfo.new( + id: elem["Id"], + key_name: key_name_el&.text, + x509_data: parse_x509_data(x509_data_el), + key_value: parse_key_value(key_value_el), + raw_elements: raw_key_info_children(elem), + ) + end + + def parse_x509_data(elem) + return nil unless elem + + certificates = at_children(elem, "X509Certificate").map do |cert_el| + strip_base64(cert_el.text) + end + issuer_serial_el = at_child(elem, "X509IssuerSerial") + subject_name_el = at_child(elem, "X509SubjectName") + ski_el = at_child(elem, "X509SKI") + crl_els = at_children(elem, "X509CRL") + # dsig11:X509Digest is in a different namespace; look it up loosely. + digest_els = elem.children.select do |c| + c.is_a?(::Moxml::Element) && + c.name == "X509Digest" && + c.namespace_uri == DSIG11_NS + end + + Model::Key::X509Data.new( + issuer_serial: parse_x509_issuer_serial(issuer_serial_el), + subject_name: subject_name_el&.text, + subject_key_id: strip_base64(ski_el&.text), + certificates: certificates, + crls: crl_els.map { |e| strip_base64(e.text) }, + digests: digest_els.map { |e| parse_x509_digest(e) }, + ) + end + + def parse_x509_issuer_serial(elem) + return nil unless elem + + issuer = at_child(elem, "X509IssuerName") + serial = at_child(elem, "X509SerialNumber") + return nil unless issuer && serial + + Model::Key::X509IssuerSerial.new( + issuer_name: issuer.text, + serial_number: serial.text, + ) + end + + def parse_x509_digest(elem) + return nil unless elem + + Model::Key::X509Digest.new( + algorithm: elem["Algorithm"], + digest: strip_base64(elem.text), + ) + end + + def parse_key_value(elem) + return nil unless elem + + rsa_el = at_child(elem, "RSAKeyValue") + dsa_el = at_child(elem, "DSAKeyValue") + ec_el = elem.children.find do |c| + c.is_a?(::Moxml::Element) && + c.name == "ECKeyValue" && + c.namespace_uri == DSIG11_NS + end + + Model::KeyValue.new( + rsa_key_value: parse_rsa_key_value(rsa_el), + dsa_key_value: parse_dsa_key_value(dsa_el), + ec_key_value: parse_ec_key_value(ec_el), + ) + end + + def parse_rsa_key_value(elem) + return nil unless elem + + Model::Key::RSAKeyValue.new( + modulus: text_of(elem, "Modulus"), + exponent: text_of(elem, "Exponent"), + ) + end + + def parse_dsa_key_value(elem) + return nil unless elem + + Model::Key::DSAKeyValue.new( + p: text_of(elem, "P"), + q: text_of(elem, "Q"), + g: text_of(elem, "G"), + y: text_of(elem, "Y"), + j: text_of(elem, "J"), + seed: text_of(elem, "Seed"), + pgen_counter: text_of(elem, "PgenCounter"), + ) + end + + def parse_ec_key_value(elem) + return nil unless elem + + named_curve_el = elem.children.find do |c| + c.is_a?(::Moxml::Element) && + c.name == "NamedCurve" && + c.namespace_uri == DSIG11_NS + end + public_key_el = elem.children.find do |c| + c.is_a?(::Moxml::Element) && + c.name == "PublicKey" && + c.namespace_uri == DSIG11_NS + end + + Model::Key::ECKeyValue.new( + named_curve_uri: named_curve_el&.[]("URI"), + public_key: strip_base64(public_key_el&.text), + ) + end + + def text_of(parent, local_name) + elem = at_child(parent, local_name) + elem&.text + end + + def strip_base64(text) + return nil if text.nil? + + text.to_s.gsub(/\s+/, "") + end + + def raw_key_info_children(elem) + # Children we don't model explicitly are preserved for round-trip + # fidelity (PGPData, SPKIData, MgmtData, dsig11:KeyInfoReference, + # dsig11:DEREncodedKeyValue, xenc:*). + elem.children.grep(::Moxml::Element).to_a + end + + def at_child(parent, local_name) + parent.children.find do |c| + c.is_a?(::Moxml::Element) && + c.name == local_name && + c.namespace_uri == DSIG_NS + end + end + + def at_children(parent, local_name) + parent.children.select do |c| + c.is_a?(::Moxml::Element) && + c.name == local_name && + c.namespace_uri == DSIG_NS + end + end + end + end +end diff --git a/lib/moxml/signature/reference_resolver.rb b/lib/moxml/signature/reference_resolver.rb new file mode 100644 index 00000000..c567a341 --- /dev/null +++ b/lib/moxml/signature/reference_resolver.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +module Moxml + module Signature + # Translates a Reference URI to a node (subtree) or octet string ready + # for the transform pipeline. + # + # Only same-document references and bare-octet inputs are handled in + # this tier. External URI dereferencing is documented in + # TODO.complete/11. + class ReferenceResolver + attr_reader :context, :document + + def initialize(context:, document:) + @context = context + @document = document + end + + # Returns: + # - Moxml::Node for same-document references (subtree apex) + # - String (octets) for octet inputs + def resolve(uri) + case uri.to_s + when "" + document.root + when /\A#/ + resolve_fragment(uri[1..]) + else + uri.to_s + end + end + + private + + def resolve_fragment(fragment) + case fragment + when /\Axpointer\(\/\)\z/ + document.root + when /\Axpointer\(id\(['"]([^'"]+)['"]\)\)\z/ + find_by_id(::Regexp.last_match(1)) + else + find_by_id(fragment) + end + end + + def find_by_id(id) + candidate = search_by_xml_id(id) || search_by_attribute_id(id) + unless candidate + raise MalformedSignatureError.new( + "Reference URI ##{id.inspect} does not resolve to any element", + ) + end + candidate + end + + def search_by_xml_id(id) + result = document.at_xpath("//*[@xml:id='#{xpath_escape(id)}']") + return nil unless result + return nil unless result.is_a?(::Moxml::Element) + + result + end + + def search_by_attribute_id(id) + matches = document + .xpath("//*[@Id='#{xpath_escape(id)}'] | " \ + "//*[@ID='#{xpath_escape(id)}']") + .grep(::Moxml::Element) + return nil if matches.empty? + + matches.first + end + + def xpath_escape(value) + value.to_s.gsub("'", "''") + end + end + end +end diff --git a/lib/moxml/signature/reference_result.rb b/lib/moxml/signature/reference_result.rb new file mode 100644 index 00000000..02f13f53 --- /dev/null +++ b/lib/moxml/signature/reference_result.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Moxml + module Signature + # Per-reference digest comparison result. `valid?` is true iff the + # freshly-computed digest matches the expected DigestValue. + class ReferenceResult + attr_reader :uri, :digest_method, :expected, :computed + + def initialize(uri:, digest_method:, expected:, computed:) + @uri = uri + @digest_method = digest_method + @expected = expected + @computed = computed + end + + def valid? + expected == computed + end + end + end +end diff --git a/lib/moxml/signature/serializer.rb b/lib/moxml/signature/serializer.rb new file mode 100644 index 00000000..14851a0f --- /dev/null +++ b/lib/moxml/signature/serializer.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +require "base64" + +module Moxml + module Signature + # Translates a Model::Signature into a Moxml::Document. + # + # All XML construction goes through moxml primitives + # (create_element, []=, add_child, create_text). No string templates, + # no manual attribute escaping — moxml handles entity encoding + # correctly per the XML spec. + class Serializer + DS_PREFIX = "ds" + + attr_reader :context + + def initialize(context:) + @context = context + end + + def serialize(signature) + doc = context.create_document + root = create_namespaced_element("Signature", doc) + root["Id"] = signature.id if signature.id + doc.root = root + + if signature.signed_info + root.add_child(build_signed_info(signature.signed_info, doc)) + end + if signature.signature_value + root.add_child(build_signature_value(signature.signature_value, doc)) + end + if signature.key_info + root.add_child(build_key_info(signature.key_info, doc)) + end + signature.objects.each do |obj| + root.add_child(build_object(obj, doc)) + end + + doc + end + + # Produces a standalone ds:SignedInfo document. Used by the Signer + # when canonicalizing SignedInfo in isolation. + def serialize_signed_info(signed_info) + doc = context.create_document + root = create_namespaced_element("SignedInfo", doc) + root["Id"] = signed_info.id if signed_info.id + doc.root = root + populate_signed_info(root, signed_info, doc) + doc + end + + private + + def build_signed_info(signed_info, doc) + el = create_namespaced_element("SignedInfo", doc) + el["Id"] = signed_info.id if signed_info.id + populate_signed_info(el, signed_info, doc) + el + end + + def populate_signed_info(root, signed_info, doc) + if signed_info.canonicalization_method + root.add_child( + build_algorithm_method("CanonicalizationMethod", + signed_info.canonicalization_method, doc), + ) + end + if signed_info.signature_method + root.add_child( + build_algorithm_method("SignatureMethod", + signed_info.signature_method, doc), + ) + end + signed_info.references.each do |ref| + root.add_child(build_reference(ref, doc)) + end + end + + def build_algorithm_method(name, method, doc) + el = create_namespaced_element(name, doc) + el["Algorithm"] = method.algorithm + if method.parameters.is_a?(Hash) && method.parameters[:hmac_output_length] + len = create_namespaced_element("HMACOutputLength", doc) + len.add_child(doc.create_text(method.parameters[:hmac_output_length].to_s)) + el.add_child(len) + end + el + end + + def build_reference(reference, doc) + el = create_namespaced_element("Reference", doc) + el["Id"] = reference.id if reference.id + el["URI"] = reference.uri if reference.uri + el["Type"] = reference.type if reference.type + + if reference.transforms && !reference.transforms.empty? + tf = create_namespaced_element("Transforms", doc) + reference.transforms.each do |transform| + tr = create_namespaced_element("Transform", doc) + tr["Algorithm"] = transform.algorithm + tf.add_child(tr) + end + el.add_child(tf) + end + + if reference.digest_method + dm = create_namespaced_element("DigestMethod", doc) + dm["Algorithm"] = reference.digest_method.algorithm + el.add_child(dm) + end + + dv = create_namespaced_element("DigestValue", doc) + dv.add_child(doc.create_text(reference.digest_value || "")) + el.add_child(dv) + el + end + + def build_signature_value(signature_value, doc) + el = create_namespaced_element("SignatureValue", doc) + el["Id"] = signature_value.id if signature_value.id + if signature_value.value + el.add_child(doc.create_text(Base64.strict_encode64(signature_value.value))) + end + el + end + + def build_key_info(key_info, doc) + el = create_namespaced_element("KeyInfo", doc) + el["Id"] = key_info.id if key_info.id + if key_info.key_name + kn = create_namespaced_element("KeyName", doc) + kn.add_child(doc.create_text(key_info.key_name)) + el.add_child(kn) + end + key_info.raw_elements.each { |raw| el.add_child(raw.dup) } + el + end + + def build_object(obj, doc) + el = create_namespaced_element("Object", doc) + el["Id"] = obj.id if obj.id + el["MimeType"] = obj.mime_type if obj.mime_type + el["Encoding"] = obj.encoding if obj.encoding + # Payload content (Manifest, SignatureProperties, XAdES + # QualifyingProperties) comes in later tiers. + el + end + + # Create an element in the xmldsig# namespace with the conventional + # `ds` prefix. The namespace declaration is added once on the + # element itself; descendants inherit it. + def create_namespaced_element(local_name, doc) + el = doc.create_element(local_name) + el.add_namespace(DS_PREFIX, DSIG_NS) + el.namespace = { DS_PREFIX => DSIG_NS } + el + end + end + end +end diff --git a/lib/moxml/signature/signer.rb b/lib/moxml/signature/signer.rb new file mode 100644 index 00000000..24bc16b7 --- /dev/null +++ b/lib/moxml/signature/signer.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module Moxml + module Signature + # Reference generation + SignatureValue computation per spec §3.1. + # + # Flow: + # 1. For each Reference in signed_info.references: + # a. Resolve URI (same-document or octet). + # b. Apply each Transform in order. + # c. Compute digest via DigestMethod. + # d. Store base64 digest on Reference#digest_value. + # 2. Serialize SignedInfo. + # 3. Canonicalize per CanonicalizationMethod. + # 4. Sign via SignatureMethod#sign(canonical_octets, key). + # 5. Set signature.signature_value. + class Signer + attr_reader :context, :signature, :document, :key + + def initialize(context:, signature:, document:, key:) + @context = context + @signature = signature + @document = document + @key = key + end + + def sign + Algorithms.load_builtins! + + signature.signed_info.references.each do |ref| + digest = compute_reference_digest(ref) + ref.digest_value = digest + end + + signature.signature_value = Model::SignatureValue.new( + value: compute_signature_value, + ) + signature + end + + private + + def compute_reference_digest(reference) + resolver = ReferenceResolver.new(context: context, document: document) + input = resolver.resolve(reference.uri) + pipeline = TransformPipeline.new(context: context, signature_element: nil) + transformed = pipeline.apply(input, reference.transforms) + canonical = pipeline.to_octets(transformed, reference) + digest_algo = Algorithms.lookup(:digest, reference.digest_method.algorithm).new + digest_algo.digest_base64(canonical) + end + + def compute_signature_value + signed_info = signature.signed_info + serializer = Serializer.new(context: context) + signed_info_doc = serializer.serialize_signed_info(signed_info) + + c14n_uri = signed_info.canonicalization_method.algorithm + c14n_klass = Algorithms.lookup(:canonicalization, c14n_uri) + c14n = c14n_klass.new(identifier_uri: c14n_uri) + canonical_octets = c14n.canonicalize(signed_info_doc.root) + + sm_uri = signed_info.signature_method.algorithm + sm_klass = Algorithms.lookup(:signature_method, sm_uri) + sm = sm_klass.new( + identifier_uri: sm_uri, + parameters: signed_info.signature_method.parameters, + ) + sm.sign(canonical_octets, key) + end + end + end +end diff --git a/lib/moxml/signature/single_verification_result.rb b/lib/moxml/signature/single_verification_result.rb new file mode 100644 index 00000000..37e16271 --- /dev/null +++ b/lib/moxml/signature/single_verification_result.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Moxml + module Signature + # Verification outcome for a single Signature element. Carries the + # cryptographic-result boolean, per-reference results, and (when + # applicable) the error that caused failure. + class SingleVerificationResult + attr_reader :signature_id, :references, :error + + def initialize(signature_id:, signature_valid:, references:, error: nil) + @signature_id = signature_id + @signature_valid = signature_valid + @references = references + @error = error + end + + def signature_valid? + @signature_valid + end + + def valid? + @signature_valid && references.all?(&:valid?) + end + + def failing_references + references.reject(&:valid?) + end + end + end +end diff --git a/lib/moxml/signature/transform_pipeline.rb b/lib/moxml/signature/transform_pipeline.rb new file mode 100644 index 00000000..40e5761b --- /dev/null +++ b/lib/moxml/signature/transform_pipeline.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +module Moxml + module Signature + # Shared transform-pipeline logic used by both Signer (signing flow, + # signature_element: nil) and Verifier (verification flow, with the + # containing Signature element so the Enveloped Signature transform + # can remove it from the digest input). + # + # Each Transform in the chain is looked up via Algorithms.lookup(:transform, uri), + # with a fallback to Algorithms.lookup(:canonicalization, uri) per + # spec §6.6.1 (any canonicalization algorithm can be used as a transform). + # + # Type coercion matches spec §4.4.3.2 reference processing model: + # - octets → nodeset: parse as XML + # - nodeset → octets: apply inclusive C14N 1.0 + class TransformPipeline + attr_reader :context, :signature_element + + def initialize(context:, signature_element: nil) + @context = context + @signature_element = signature_element + end + + def apply(input, transforms_model) + current = input + current_type = type_of(current) + transforms = transforms_from(transforms_model) + + transforms.each do |transform_model| + algo_class = lookup(transform_model.algorithm) + transform = algo_class.new( + parameters: transform_model.parameters, + context: context, + signature_element: signature_element, + ) + current_type, current = coerce(current, current_type, + algo_class.input_type) + current = transform.transform(current) + current_type = algo_class.output_type + end + + current + end + + # Final step: convert the pipeline output (which may still be a node) + # to octets suitable for digesting. Uses inclusive C14N 1.0 as the + # default mapping per spec §4.4.3.2. + def to_octets(value, reference = nil) + return value if value.is_a?(String) + + c14n_uri = canonicalization_from(reference) || DEFAULT_OCTET_C14N + klass = Algorithms.lookup(:canonicalization, c14n_uri) + klass.new(identifier_uri: c14n_uri).canonicalize(value) + end + + DEFAULT_OCTET_C14N = "http://www.w3.org/TR/2001/REC-xml-c14n-20010315" + + private_constant :DEFAULT_OCTET_C14N + + private + + def transforms_from(transforms_model) + return [] unless transforms_model + + transforms_model.transforms + end + + def lookup(uri) + Algorithms.lookup(:transform, uri) + rescue UnknownAlgorithm + Algorithms.lookup(:canonicalization, uri) + end + + def type_of(value) + case value + when ::Moxml::Node, Array then :nodeset + else :octets + end + end + + def coerce(input, from, to) + return [to, input] if from == to + + case [from, to] + when %i[octets nodeset] + parsed = context.parse(input.to_s) + [:nodeset, parsed.root] + when %i[nodeset octets] + octets = C14n::Inclusive10.new.canonicalize(input) + [:octets, octets] + else + raise TransformError, "cannot coerce #{from} to #{to}" + end + end + + def canonicalization_from(reference) + return nil unless reference&.transforms + + reference.transforms.transforms.reverse_each.find do |t| + Algorithms.registered?(:canonicalization, t.algorithm) + end&.algorithm + end + end + end +end diff --git a/lib/moxml/signature/verification_result.rb b/lib/moxml/signature/verification_result.rb new file mode 100644 index 00000000..8e1d4151 --- /dev/null +++ b/lib/moxml/signature/verification_result.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Moxml + module Signature + # Aggregated verification result for a document. A document may + # contain multiple Signature elements; this result wraps them all. + class VerificationResult + attr_reader :results + + def initialize(results:) + @results = results + end + + def valid? + results.all?(&:valid?) + end + + def signature_count + results.size + end + + def failing + results.reject(&:valid?) + end + end + end +end diff --git a/lib/moxml/signature/verifier.rb b/lib/moxml/signature/verifier.rb new file mode 100644 index 00000000..bb235c2e --- /dev/null +++ b/lib/moxml/signature/verifier.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +module Moxml + module Signature + # Core Validation per spec §3.2, with the safe ordering prescribed by + # Best Practice 1: verify SignatureValue BEFORE running any reference + # transforms (which could be hostile). + class Verifier + attr_reader :context, :document, :key, :key_map + + def initialize(context:, document:, key: nil, key_map: {}, **_options) + @context = context + @document = document + @key = key + @key_map = key_map || {} + end + + def verify + Algorithms.load_builtins! + signatures = find_signatures + + results = signatures.map { |sig_elem| verify_one(sig_elem) } + VerificationResult.new(results: results) + end + + private + + def find_signatures + document.xpath("//ds:Signature", "ds" => DSIG_NS) + .grep(::Moxml::Element) + end + + def verify_one(signature_element) + signature = Parser.new(context: context).parse(signature_element) + signature_value_ok, error = verify_signature_value(signature, signature_element) + reference_results = if signature_value_ok + verify_references(signature, signature_element) + else + [] + end + + SingleVerificationResult.new( + signature_id: signature.id, + signature_valid: signature_value_ok, + references: reference_results, + error: error, + ) + end + + # Returns [Boolean, ErrorOrNil] so the caller can attach the error + # to the result for debugging. The error is never raised — Best + # Practice 1 says authenticate first; surfacing why auth failed is + # an application concern, not a panic. + def verify_signature_value(signature, signature_element) + return [false, nil] if signature.signature_value.nil? + return [false, nil] if signature.signature_value.value.nil? + + signed_info_elem = signature_element.at_xpath( + "./ds:SignedInfo", "ds" => DSIG_NS + ) + return [false, nil] if signed_info_elem.nil? + + c14n_uri = signature.signed_info.canonicalization_method&.algorithm + return [false, nil] if c14n_uri.nil? + + canonical = canonicalize_signed_info(c14n_uri, signed_info_elem) + sm_uri = signature.signed_info.signature_method.algorithm + sm = Algorithms.lookup(:signature_method, sm_uri).new( + identifier_uri: sm_uri, + parameters: signature.signed_info.signature_method.parameters, + ) + verification_key = resolve_key(signature) + return [false, nil] if verification_key.nil? + + ok = sm.verify(canonical, verification_key, signature.signature_value.value) + [ok, nil] + rescue VerificationError, UnknownAlgorithm => e + [false, e] + end + + # Canonicalize the original SignedInfo element as it appears in the + # document. Re-serializing the parsed model would change namespace + # prefixes (e.g. SignedInfo → ds:SignedInfo) and break byte-exact + # verification. + def canonicalize_signed_info(c14n_uri, signed_info_elem) + Algorithms.lookup(:canonicalization, c14n_uri) + .new(identifier_uri: c14n_uri) + .canonicalize(signed_info_elem) + end + + def resolve_key(signature) + return key if key + return nil unless signature.key_info + + KeyExtractor.new(key_map: key_map).extract(signature.key_info) + end + + def verify_references(signature, signature_element) + signature.signed_info.references.map do |ref| + verify_reference(ref, signature_element) + end + end + + def verify_reference(reference, signature_element) + resolver = ReferenceResolver.new(context: context, document: document) + input = resolver.resolve(reference.uri) + pipeline = TransformPipeline.new( + context: context, + signature_element: signature_element, + ) + transformed = pipeline.apply(input, reference.transforms) + canonical = pipeline.to_octets(transformed, reference) + + digest_algo = Algorithms.lookup( + :digest, reference.digest_method.algorithm + ).new + computed = digest_algo.digest_base64(canonical) + expected = (reference.digest_value || "").strip + + ReferenceResult.new( + uri: reference.uri, + digest_method: reference.digest_method.algorithm, + expected: expected, + computed: computed, + ) + end + end + end +end diff --git a/reference-docs/w3c-xmldsig-bestpractices.md b/reference-docs/w3c-xmldsig-bestpractices.md new file mode 100644 index 00000000..fca99f21 --- /dev/null +++ b/reference-docs/w3c-xmldsig-bestpractices.md @@ -0,0 +1,216 @@ +# XML Signature Best Practices + +W3C Working Group Note 11 April 2013 + +- This version: http://www.w3.org/TR/2013/NOTE-xmldsig-bestpractices-20130411/ +- Latest published version: http://www.w3.org/TR/xmldsig-bestpractices/ +- Latest editor's draft: http://www.w3.org/2008/xmlsec/Drafts/best-practices/Overview.html +- Previous version: http://www.w3.org/TR/2013/NOTE-xmldsig-bestpractices-20130124/ +- Editors: Frederick Hirsch (Nokia), Pratik Datta (Oracle) + +Copyright © 2013 W3C® (MIT, ERCIM, Keio, Beihang), All Rights Reserved. + +## Abstract + +This document collects best practices for implementers and users of the XML +Signature specification [XMLDSIG-CORE1]. Most of these best practices are +related to improving security and mitigating attacks, yet others are for best +practices in the practical use of XML Signature, such as signing XML that +doesn't use namespaces, for example. + +## 1. Overview + +The XML Signature specification [XMLDSIG-CORE1] offers powerful and flexible +mechanisms to support a variety of use cases. This flexibility has the downside +of increasing the number of possible attacks. One countermeasure to the +increased number of threats is to follow best practices, including a +simplification of use of XML Signature where possible. + +## 2. Best Practices for Implementers + +### 2.1 Reduce the opportunities for denial of service attacks + +XML Signature may be used in application server systems, where multiple incoming +messages are being processed simultaneously. In this situation incoming messages +should be assumed to be possibly hostile with the concern that a single poison +message could bring down an entire set of web applications and services. + +**Best Practice 1**: Mitigate denial of service attacks by executing potentially +dangerous operations only after successfully authenticating the signature. + +Validate the `ds:Reference` elements for a signature only after establishing +trust, for example by verifying the key and validating `ds:SignedInfo` first. + +Recommended order of operations: + +1. **Step 1** — fetch the verification key and establish trust in that key. +2. **Step 2** — validate `ds:SignedInfo` with that key. +3. **Step 3** — validate the references. + +**Best Practice 2**: Establish trust in the verification/validation key (validate +X.509 certificates, certificate chains and revocation status). + +#### 2.1.1 XSLT transform that causes denial of service + +A nested-loop XSLT can require O(N^4) operations on a document with N elements. + +**Best Practice 3**: Consider avoiding XSLT Transforms. + +#### 2.1.2 XSLT transform that executes arbitrary code + +XSLT user-defined extensions can execute arbitrary code (e.g., shell commands). + +**Best Practice 4**: When XSLT is required disallow the use of user-defined +extensions. + +#### 2.1.3 XPath Filtering transform that causes denial of service + +A document with N namespaces and N elements produces N×N namespace nodes; an +XPath Filter evaluates the expression once per node, giving O(N^4) cost. + +**Best Practice 5**: Try to avoid or limit XPath transforms. + +#### 2.1.4 XPath selection DoS in streaming mode + +Wildcard axes (descendant, following, etc.) cause search-context explosion in +streaming verifiers. + +**Best Practice 6**: Avoid using "descendant", "descendant-or-self", +"following-sibling", and "following" axes when using streaming XPaths. + +#### 2.1.5 Retrieval method that causes an infinite loop + +`ds:RetrievalMethod` may form cyclic references. + +**Best Practice 7**: Try to avoid or limit `ds:RetrievalMethod` support with +`ds:KeyInfo`. + +#### 2.1.6 Problematic external references + +External URI references can read sensitive files or trigger side effects on +other sites. + +**Best Practice 8**: Control external references (mitigate query parameters, +unknown URI schemes, inappropriate content). + +#### 2.1.7 Denial of service caused by too many transforms + +A reference may carry thousands of C14N transforms. + +**Best Practice 9**: Limit the number of `ds:Reference` transforms allowed. + +### 2.2 Provide a mechanism to determine what was signed + +**Best Practice 10**: Offer interfaces for the application to learn what was +signed (return pre-digested data and pre-C14N data). + +### 2.3 Be aware of certificate encoding issues + +**Best Practice 11**: Do not re-encode certificates; use DER when possible with +the `X509Certificate` element. Re-encoding can break the signature on the +certificate. + +## 3. Best Practices for Applications + +### 3.1 Check what is signed + +**Best Practice 12**: Enable verifier to automate "see what is signed" +functionality. + +**Best Practice 13**: When applying XML Signatures using XPath it is recommended +to always actively verify that the signature protects the intended elements and +not more or less. + +**Best Practice 14**: When checking a reference URI, don't just check the name of +the element (wrapping attack mitigation — also check position). + +### 3.2 Prevent replay attacks + +**Best Practice 15**: Unless impractical, sign all parts of the document. + +**Best Practice 16**: Use a nonce in combination with signing time. + +**Best Practice 17**: Do not rely on application logic to prevent replay attacks +since applications may change. + +**Best Practice 18**: Nonce and signing time must be signature protected. + +### 3.3 Enable Long-Lived Signatures + +**Best Practice 19**: Use Timestamp tokens issued by Timestamp authorities for +long lived signatures. + +**Best Practice 20**: Long lived signatures should include a `xsd:dateTime` field +to indicate the time of signing. + +### 3.4 Signing XML without namespace information ("legacy XML") + +**Best Practice 21**: When creating an enveloping signature over XML without +namespace information, take steps to avoid having that content inherit the XML +Signature namespace (insert an empty default namespace declaration, or define a +namespace prefix for the Signature namespace). + +### 3.5 Prefer the XPath Filter 2 Transform + +**Best Practice 22**: Prefer the XPath Filter 2 Transform to the XPath Filter +Transform if possible. + +## 4. Best Practices for Signers and Verifiers + +### 4.1 Do not transmit external unparsed entity references + +**Best Practice 23**: Do not transmit unparsed external entity references in +signed material. Expand all entity references before creating the cleartext. + +### 4.2 Be aware of schema processing + +**Best Practice 24**: Do not rely on a validating processor on the consumer's end +to normalize XML documents. + +**Best Practice 25**: Avoid destructive validation before signature validation. + +### 4.3 HMAC truncation + +**Best Practice 26**: When using an HMAC, set the HMAC Output Length to one half +the number of bits in the hash size. + +### 4.4 Distinct keys for sign and encrypt + +**Best Practice 27**: When encrypting and signing use distinct keys. + +## 5. Best Practices Summary + +1. Mitigate DoS by authenticating before dangerous operations. +2. Establish trust in the verification key. +3. Avoid XSLT transforms. +4. Disallow XSLT user-defined extensions. +5. Avoid/limit XPath transforms. +6. Avoid wildcard axes in streaming XPaths. +7. Avoid/limit `ds:RetrievalMethod`. +8. Control external references. +9. Limit number of transforms per Reference. +10. Offer interfaces to learn what was signed. +11. Don't re-encode certificates; prefer DER. +12. Enable verifier to "see what is signed". +13. With XPath, verify the signature actually protects intended elements. +14. When checking reference URI, check both name and position. +15. Sign all parts of the document unless impractical. +16. Use nonce + signing time. +17. Don't rely solely on application logic for replay prevention. +18. Nonce and signing time must be signature protected. +19. Use TSA-issued timestamp tokens for long-lived signatures. +20. Include `xsd:dateTime` for signing time in long-lived signatures. +21. Avoid namespace inheritance in enveloping signatures over legacy XML. +22. Prefer XPath Filter 2 over XPath Filter. +23. Don't transmit unparsed external entity references. +24. Don't rely on validating parser on consumer's end. +25. Avoid destructive validation before signature validation. +26. Truncate HMAC to half hash size. +27. Use distinct keys for signing and encryption. + +## References + +- [XMLDSIG-CORE1] — XML Signature Syntax and Processing Version 1.1 +- [XADES] — XML Advanced Electronic Signatures (ETSI TS 101 903) +- [RFC3161] — Internet X.509 PKI Time-Stamp Protocol (TSP) +- [MCINTOSH-WRAP] — XML signature element wrapping attacks and countermeasures diff --git a/reference-docs/w3c-xmldsig-core.md b/reference-docs/w3c-xmldsig-core.md new file mode 100644 index 00000000..be391a71 --- /dev/null +++ b/reference-docs/w3c-xmldsig-core.md @@ -0,0 +1,400 @@ +# XML Signature Syntax and Processing Version 1.1 (W3C Recommendation, 11 April 2013) + +Curated implementation-focused extract. Source: https://www.w3.org/TR/xmldsig-core/ + +Editors: Donald Eastlake, Joseph Reagle, David Solo, Frederick Hirsch, Magnus Nyström, Thomas Roessler, Kelvin Yiu. + +## Abstract + +XML Signatures provide integrity, message authentication, and/or signer +authentication services for data of any type, whether located within the XML +that includes the signature or elsewhere. + +## Namespaces + +| URI | prefix | +| --- | --- | +| `http://www.w3.org/2000/09/xmldsig#` | `ds:` / `dsig:` | +| `http://www.w3.org/2009/xmldsig11#` | `dsig11:` | + +Algorithm identifiers in the `http://www.w3.org/2001/04/xmldsig-more#` namespace +are defined by RFC 6931. + +## Signature Structure + +``` + + + + + ( + ()? + + + )+ + + + ()? + ()* + +``` + +## Processing Rules + +### 3.1 Signature Generation + +#### 3.1.1 Reference Generation + +For each data object being signed: + +1. Apply the `Transforms`, as determined by the application, to the data object. +2. Calculate the digest value over the resulting data object. +3. Create a `Reference` element, including the (optional) identification of the + data object, any (optional) transform elements, the digest algorithm and the + `DigestValue`. + +#### 3.1.2 Signature Generation + +1. Create `SignedInfo` element with `SignatureMethod`, `CanonicalizationMethod` + and `Reference`(s). +2. Canonicalize and then calculate the `SignatureValue` over `SignedInfo` based + on algorithms specified in `SignedInfo`. +3. Construct the `Signature` element that includes `SignedInfo`, `Object`(s), + `KeyInfo` (if required), and `SignatureValue`. + +### 3.2 Core Validation + +The required steps of core validation include: + +1. **Reference validation** — verification of the digest contained in each + `Reference` in `SignedInfo`. +2. **Signature validation** — cryptographic signature validation of the signature + calculated over `SignedInfo`. + +Comparison of each value is over the numeric (integer) or decoded octet sequence +of the value. + +#### 3.2.1 Reference Validation + +1. Canonicalize the `SignedInfo` element based on the `CanonicalizationMethod`. +2. For each `Reference` in `SignedInfo`: + 1. Obtain the data object (dereference `URI`, execute `Transforms`, or fetch + from local cache). + 2. Digest using the `DigestMethod`. + 3. Compare against `DigestValue`; mismatch ⇒ validation fails. + +#### 3.2.2 Signature Validation + +1. Obtain the keying information from `KeyInfo` or external source. +2. Obtain the canonical form of `SignedInfo` using `CanonicalizationMethod` and + confirm the `SignatureValue` over `SignedInfo`. + +## Schema + +### `ds:CryptoBinary` + +Integer-to-octet conversion equivalent to IEEE 1363 I2OSP with minimal length, +then base64-encoded. + +### 4.2 `Signature` + +```xml + + + + + + + + + + +``` + +### 4.3 `SignatureValue` + +```xml + + + + + + + +``` + +### 4.4 `SignedInfo` + +```xml + + + + + + + + +``` + +### 4.4.1 `CanonicalizationMethod` + +```xml + + + + + + +``` + +### 4.4.2 `SignatureMethod` + +```xml + + + + + + + +``` + +Signatures MUST be deemed invalid if the HMAC truncation length is below the +larger of (a) half the underlying hash output length, and (b) 80 bits. + +### 4.4.3 `Reference` + +```xml + + + + + + + + + + +``` + +#### 4.4.3.2 Reference Processing Model + +Result of URI dereferencing or transforms is either octet stream or XPath node-set. + +Defaults: +- octet stream + node-set transform expected ⇒ parse octets as XML. +- node-set + octet transform expected ⇒ canonicalize via C14N. + +A **same-document reference** is a URI-Reference that consists of `#` followed by +a fragment, or an empty URI. + +- `URI=""` ⇒ node-set (minus comments) of the entire document. +- `URI="#id"` ⇒ node-set of the element with that ID + descendants + in-scope + namespaces/attributes, no comments. +- `#xpointer(/)` retains comments at root. +- `#xpointer(id('ID'))` retains comments for the element. + +### 4.4.3.4 `Transforms` + +```xml + + + + + + + + + + + + + +``` + +### 4.5 `KeyInfo` + +```xml + + + + + + + + + + + + + +``` + +`KeyValue` contains one of `DSAKeyValue`, `RSAKeyValue`, or (1.1) `ECKeyValue`. + +X509Data child elements: `X509IssuerSerial` (deprecated), `X509SKI`, +`X509SubjectName`, `X509Certificate`, `X509CRL`, `dsig11:X509Digest`. + +### 4.6 `Object` + +Optional element for including data objects. Has `Id`, `MimeType`, `Encoding` +attributes. + +## Additional Syntax + +### 5.1 `Manifest` + +A list of `Reference`s where digest checking is application-defined (not core). + +### 5.2 `SignatureProperties` + +For signature-time, hardware serial, etc. `SignatureProperty` has required +`Target` attribute referencing the `Signature` element. + +### 5.3 PIs and 5.4 Comments + +Unless `CanonicalizationMethod` strips comments or PIs, they are signed. + +## Algorithms + +### Mandatory & Recommended URIs + +**Digest**: +- Required: SHA1 `http://www.w3.org/2000/09/xmldsig#sha1` (discouraged); SHA256 + `http://www.w3.org/2001/04/xmlenc#sha256` +- Optional: SHA224 `…xmldsig-more#sha224`; SHA384 `…xmldsig-more#sha384`; SHA512 + `…xmlenc#sha512` + +**MAC**: +- Required: HMAC-SHA1 `…xmldsig#hmac-sha1` (discouraged); HMAC-SHA256 + `…xmldsig-more#hmac-sha256` +- Recommended: HMAC-SHA384, HMAC-SHA512 + +**Signature**: +- Required: RSA-SHA256 `…xmldsig-more#rsa-sha256`; ECDSA-SHA256 + `…xmldsig-more#ecdsa-sha256`; DSA-SHA1 (verification only) `…xmldsig#dsa-sha1` +- Recommended: RSA-SHA1 (verification; discouraged for generation) +- Optional: RSA-{SHA224,SHA384,SHA512}; ECDSA-{SHA1,SHA224,SHA384,SHA512}; + DSA-SHA256 `…xmldsig11#dsa-sha256` + +**Canonicalization**: +- Required: + - Canonical XML 1.0 (omit comments): `http://www.w3.org/TR/2001/REC-xml-c14n-20010315` + - Canonical XML 1.1 (omit comments): `http://www.w3.org/2006/12/xml-c14n11` + - Exclusive XML Canonicalization 1.0 (omit comments): `http://www.w3.org/2001/10/xml-exc-c14n#` +- Recommended variants append `#WithComments` + +**Transform**: +- Required: base64 `…xmldsig#base64`; Enveloped Signature + `…xmldsig#enveloped-signature` +- Recommended: XPath `…REC-xpath-19991116`; XPath Filter 2.0 + `…2002/06/xmldsig-filter2` +- Optional: XSLT `…REC-xslt-19991116` + +### Digest Algorithms + +- SHA-1: 160-bit / 20 octets, base64-encoded. +- SHA-256: 256-bit / 32 octets. +- SHA-384: 384-bit / 48 octets. +- SHA-512: 512-bit / 64 octets. + +### HMAC + +Identifier family: `…xmldsig#hmac-sha1`, `…xmldsig-more#hmac-{sha224,sha256,sha384,sha512}`. +Truncation length (`HMACOutputLength`) MUST be a multiple of 8 bits. If below +half the hash output length, signature MUST be deemed invalid. + +### DSA + +Identifier: `…xmldsig#dsa-sha1` (1024/160), `…xmldsig11#dsa-sha256` (2048/256 or +3072/256). + +Output (r, s): base64-encoded concatenation of two octet streams using I2OSP with +length parameter 20 (for SHA-1) or `N` (for SHA-256 with N=|q|). + +### RSA (PKCS#1 v1.5) + +Identifiers: `…xmldsig#rsa-sha1`, `…xmldsig-more#rsa-{sha224,sha256,sha384,sha512}`. +RSASSA-PKCS1-v1_5 per RFC 3447 §8.2.1. + +### ECDSA + +Identifiers: `…xmldsig-more#ecdsa-{sha1,sha224,sha256,sha384,sha512}`. +Output (r, s): base64-encoded concatenation of I2OSP of r and s, each of length +equal to the base point order in bytes (32 for P-256, 66 for P-521). + +Required curve: P-256 (FIPS 186-3 §D.2.3). Recommended: P-384, P-521. + +### Canonicalization + +All algorithms take octet-stream or node-set, produce octet-stream output. +Output is UTF-8 (no BOM), NFC. See [XML-C14N], [XML-C14N11], [XML-EXC-C14N]. + +### Transforms + +- **base64** (octet→octet, node-set→octet): decodes base64. For node-set input, + logically applies `self::text()`, sorts by document order, concatenates. +- **XPath Filtering** (octet|node-set → node-set): evaluates XPath per node, + includes nodes where boolean result is true. Includes `here()` function. +- **Enveloped Signature**: removes the containing `Signature` element from + digest calculation. Equivalent to specific XPath. +- **XSLT**: octet-stream → octet-stream via XSL stylesheet (sole child). + +## XML Canonicalization Considerations + +Signatures only work if verification uses the same bits as signing. XML surface +forms vary, so canonicalization standardizes before signing/verification. + +Categories of change to canonicalize: +1. XML 1.0 syntax (line endings, attribute defaults, entity refs, attribute + normalization). +2. DOM/SAX information loss (attribute order, insignificant whitespace, namespace + declaration locations). +3. Charset conversion. +4. Namespace inheritance/context. + +All canonicalization algorithms identified use UTF-8 (no BOM) and do not perform +character normalization. Applications SHOULD produce content in NFC. + +### Namespace Context and Portable Signatures + +Inclusive canonicalization "attracts" ancestor namespace context, breaking +signatures when subdocuments are moved. Exclusive canonicalization "repels" +ancestor context, preserving portability. + +## Security Considerations + +### 8.1 Transforms + +- Only what is signed is secure. +- Only what is "seen" should be signed. +- "See" what is signed (operate over canonicalized form). + +### 8.2 Security Models + +Public-key signatures vs keyed-hash MACs have different trust models. Public +keys verify; only private-key holders can sign. MAC keys are shared; any +verifier can forge. + +### 8.3 Algorithms, Key Lengths, Certificates + +Conforming implementations MUST support RSA signature generation and +verification with public keys at least 2048 bits. 3072-bit recommended for +signatures verified beyond 2030. + +### 8.4 Error Messages + +Generic error responses; avoid leaking specifics about algorithm processing. + +## References + +- [XML-C14N] Canonical XML 1.0 +- [XML-C14N11] Canonical XML 1.1 +- [XML-EXC-C14N] Exclusive XML Canonicalization 1.0 +- [PKCS1] RFC 3447 (RSA Cryptography Specifications v2.1) +- [FIPS-186-3] Digital Signature Standard +- [FIPS-180-3] Secure Hash Standard +- [HMAC] RFC 2104 +- [RFC6931] Additional XML Security URIs +- [XPATH] XML Path Language 1.0 +- [XMLDSIG-BESTPRACTICES] XML Signature Best Practices diff --git a/spec/fixtures/xmldsig/keys/rsa_private.pem b/spec/fixtures/xmldsig/keys/rsa_private.pem new file mode 100644 index 00000000..9c5201b4 --- /dev/null +++ b/spec/fixtures/xmldsig/keys/rsa_private.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC8F3ut738mezjz +Wg6J11OJCe28lyJXHcb6PbMJVm9NUC3MeMr27PI9s7ildxEodm9bOkXTIGD1ev4Y +4p69cjMeyn1vFoxUyE+IN9+0pSVs/ABf19/349MFMTC1mpWlBvRtOKyCkLObQ0hn +i+lMd4mQ+Ht+6+ZQNpNGIE0HcUUTH/r13AkmYLGYjlf2BwiqLP5MaxsdARormXwJ +wsJD92us5MHN2P4Pew+J+TyCMdM94h7TCR4vmhZrX7EQcbIfsIEYpfRs3IeORW90 +CoXt7AgLmC+myMt8N/VF6OTKzXrShjGGeF88sikG0GSsIVWy7+tReqe4rfx/iavd +da0LhPJVAgMBAAECggEAMSugaPmFj11CJ5fg2hcA3v/J9vW5g/WkWTG89pFyek8e +Eeh4ArIxp4Cuog7s3NuNQ9eJfmZmAnZ7K60+mz7Z71A3F03ZNKbC59TXdeWAUavV +Ozj3c1nLBf30gl0dhq05Q74/lshWM54UtQEF5bgQLeZPfoATzt9dg8UY7ful95f/ +StU1jJB16r7l8Oy1KyJE094ZL+dYSAaJaG/kb/hq857UJxU/9mmnqq0AsAseXrHm +9T4AR5vJjhh71+3BHsQNxP6oO2ErV+Bd4MddMRYZbyo7jVIN6t6nti+uB+reKAi0 +FdcKNL+zldxPyKzt/dXh43M1Y60aFJ9jre/x+aqW9wKBgQDmGrguBixFJiBYdJXw +NXyIDKDYPRlP3+B28piybne3JVvnyZQ+v5KmDCAtE4i/hIkX9/pdyXAoAfsEI8dc +sarcFPynMjl+BpBxRKiMgIJvAsRh83HfrkewUTk9uhD3hqW4RlKZZIRlsPuEhIcG +2oWJzwuDlRsZyrlqLef94O59FwKBgQDRQmJgZm0faJfIFkmy17iCSmpLCHsef3iJ +gKr2yQBxOJMwConPauZEQ+YdTjk2XPo81TNpjWyBKZD+1RPPpJ5sFzUAfAqCfsxY +EZWCzuZKntiegwOsdRrp5FdAFrFCmttW0p5AnTH23PMKxT+7fMDmuwq1X3pS712X +hGujrnXncwKBgDKKrefWDUVHAZXMTd7MMMVFWNMGYJftycT2tlmC8CK3Pv+jhD9g +HtsAENU11DSU7PPp0QFmrI7tGHCpVzHiCHB8353t5zjqQjHO7eKmm3+8sNv++AU0 +p6Rvws3vH6ju11mpgJ0WugoIHMbXwTzdJLXHV3UYfDJnF+DdonZeQKQTAoGAIo7K +/k6MAN7eCg4cN6vGbcXqTd/lrUCx4EtecIj7SLdmH03uOlHWGzn3W6maay7pqHgx +GGJho+cAagU4U1dFTmZ6u0zA05IrHvQwc7zFbVdUQME0LxvbyPqLqirVNUGrrWf0 ++Ii9Qp50iLfQcZ8FoFUNTKyq391l4Gre99YM5J8CgYEA2EsA5AVf3Q4hkY5ImWN9 +hWgcvKaa1Fw8n59iGExgoh1Zr4p9SWxaNxa/iY3q0wdiQBMihEgFdc3JMBiS3RSe +KZCpPAFeNW9RnXIQwnW06oyaiZkymLq96cYT5ToSLEoFl9/Z+ulMAYrfRJGszyR+ +hJAW5QUsrp1RKksKftYOAlA= +-----END PRIVATE KEY----- diff --git a/spec/fixtures/xmldsig/keys/rsa_public.pem b/spec/fixtures/xmldsig/keys/rsa_public.pem new file mode 100644 index 00000000..c1c2f93f --- /dev/null +++ b/spec/fixtures/xmldsig/keys/rsa_public.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvBd7re9/Jns481oOiddT +iQntvJciVx3G+j2zCVZvTVAtzHjK9uzyPbO4pXcRKHZvWzpF0yBg9Xr+GOKevXIz +Hsp9bxaMVMhPiDfftKUlbPwAX9ff9+PTBTEwtZqVpQb0bTisgpCzm0NIZ4vpTHeJ +kPh7fuvmUDaTRiBNB3FFEx/69dwJJmCxmI5X9gcIqiz+TGsbHQEaK5l8CcLCQ/dr +rOTBzdj+D3sPifk8gjHTPeIe0wkeL5oWa1+xEHGyH7CBGKX0bNyHjkVvdAqF7ewI +C5gvpsjLfDf1Rejkys160oYxhnhfPLIpBtBkrCFVsu/rUXqnuK38f4mr3XWtC4Ty +VQIDAQAB +-----END PUBLIC KEY----- diff --git a/spec/fixtures/xmldsig/keys/rsa_ref.pem b/spec/fixtures/xmldsig/keys/rsa_ref.pem new file mode 100644 index 00000000..73a4e4b7 --- /dev/null +++ b/spec/fixtures/xmldsig/keys/rsa_ref.pem @@ -0,0 +1,15 @@ +-----BEGIN RSA PRIVATE KEY----- +MIICXgIBAAKBgQC15La+LSmHNUs/yqzSuzKdBUED1OfaOZpBp8zxAAQy7VlTrqRh +/eiJH3VSeRRZEygORvtLgi/teF2P+z/mfJ6IHIdCdkn8MF4CCCQKkjm7JKRrKfK5 +fOUp1NZF22oP8x0L4j67NYCtR9F6KIkV5A6FPAZGI8nsHnyJzRwqmG2xbQIDAQAB +AoGBAJDT2UW3g/dqUc4rPExWTUiFJG0+mpVBhDd+ukmyL6W1Iojk53I2z25PJAVU +7wS1ohEsJ27J7Aty6Vx5Ozn0Q+zYVaKRSxcazNeGbwS0UaGrN0lMvWDs7RmVGCdx +bI2LUTQ88Bl94dW4QObAub+wMOL6xmVEVrJssZnm+CIqS2UBAkEA49QDNB//oHmi +iqD4SFotE8Lz80qBGHN15YIm80TKUR2k1LusZl6R5+2nYTF2vPsG+HGXPbkGhqTn +JL9GMBv7TQJBAMxinne8+bKTvOl/hhdAohFs7aHUBZhZOEuXIf1jYENASk2weYC6 +95SlHvWcwPHfqVbpwt83sGL8aDm8CCPYPqECQQDEFRQQx72GC0oG0FYAR4RmbrLx +YN1NAwqkVmlZlIogWEgmQ8Q0cw5Ws+cMMrtEGTU9nN4TZGymc8TwjqNFAsA9AkEA +ol8Cp/uQn6cxIIt4Gsb1OkTAcJ0BKOxQhfT2QtiNJEBSB3BYxsVCZWvcsaGrwzw9 +yteBQlZ6odkGcD+Kc/eaoQJAH+0a7jlHDu2VCHI63OiNZQJ8J9oxaPvWZyKYSaCO +iGvon/Z6KGQhXMedPDaCH7UjeMle5AVhjSrSvF6OglgZ9g== +-----END RSA PRIVATE KEY----- diff --git a/spec/fixtures/xmldsig/keys/rsa_ref.pub b/spec/fixtures/xmldsig/keys/rsa_ref.pub new file mode 100644 index 00000000..f2951aef --- /dev/null +++ b/spec/fixtures/xmldsig/keys/rsa_ref.pub @@ -0,0 +1,6 @@ +-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC15La+LSmHNUs/yqzSuzKdBUED +1OfaOZpBp8zxAAQy7VlTrqRh/eiJH3VSeRRZEygORvtLgi/teF2P+z/mfJ6IHIdC +dkn8MF4CCCQKkjm7JKRrKfK5fOUp1NZF22oP8x0L4j67NYCtR9F6KIkV5A6FPAZG +I8nsHnyJzRwqmG2xbQIDAQAB +-----END PUBLIC KEY----- diff --git a/spec/fixtures/xmldsig/sign2-doc.xml b/spec/fixtures/xmldsig/sign2-doc.xml new file mode 100644 index 00000000..daa52e58 --- /dev/null +++ b/spec/fixtures/xmldsig/sign2-doc.xml @@ -0,0 +1,6 @@ + + + + Hello, World! + + diff --git a/spec/fixtures/xmldsig/sign2-result.xml b/spec/fixtures/xmldsig/sign2-result.xml new file mode 100644 index 00000000..4d46cf25 --- /dev/null +++ b/spec/fixtures/xmldsig/sign2-result.xml @@ -0,0 +1,25 @@ + + + + Hello, World! + + + + + + + + + + + +Gx8CGUsbi2qvBLd15VCmwELbDMND8F4vY3jPOc7/FJ0= + + +T2c7nqOw55P8hcP1qhvfPCwOSEAuo8HstZf9shlrggcarxfgWTKhA6UdrF4McfrS +XtcgHA7zy0Yzd2cgeGkKA2jgI+9QRhoQsifOMuI55sE5r+fpBs+goaxC57gmcBXj +XnuwIiWf7nfpF4hYZ841HzYd2HcpQKPTdbhvZUprvx8= + +test + + diff --git a/spec/fixtures/xmldsig/sign3-result.xml b/spec/fixtures/xmldsig/sign3-result.xml new file mode 100644 index 00000000..6796e70a --- /dev/null +++ b/spec/fixtures/xmldsig/sign3-result.xml @@ -0,0 +1,39 @@ + + + + Hello, World! + + + + + + + + + + + +Gx8CGUsbi2qvBLd15VCmwELbDMND8F4vY3jPOc7/FJ0= + + +TGJ9fCzjppp3LgG4fiBJx+0R34wRa7il9XKKZ+kkOAdKkcW0PIAYKmjn0Tn8krGd +Gw6qtFFqjdohXfhkKmajXAFunEtd3J0kHFkf3obIwRB1qdsYmKXVFxUx3GqcIlph +vt9v/9FC12JAxwAiJXHuY2xN5uo3xSDER4+tCCy3/AI= + + +MIICLzCCAZgCCQCVuhhQ38rw0TANBgkqhkiG9w0BAQUFADBbMQswCQYDVQQGEwJV +UzEQMA4GA1UECAwHR2VvcmdpYTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQ +dHkgTHRkMRcwFQYDVQQDDA53d3cuZ29vZ2xlLmNvbTAgFw0xMzA1MjUxODQwMDRa +GA8zMDEyMDkyNTE4NDAwNFowWzELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0dlb3Jn +aWExITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDEXMBUGA1UEAwwO +d3d3Lmdvb2dsZS5jb20wgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBALE4oSql +eymfHtzOeY86WyvfsjZmaz2XnIo9dzZsK71yMEKkgvXQnnYy9pK0NaYcG0B0hcii +3fqGBiHMkZY2BOGWwCC/wOmJCzLq9q6caPWUs71Zko+h59LaqV93vzDmZaXYfFoQ +gSVEWpEpCSo560x0mSuLnJYdQQzZ/L6xvxZ1AgMBAAEwDQYJKoZIhvcNAQEFBQAD +gYEATyK/RlfpohUVimgFkycTF2hyusjctseXoZDCctgg/STMsL8iA0P9YB6k91GC +kWpwevuiwarD1MfSUV6goPINFkIBvfK+5R9lpHaTqqs615z8T9R5VJgaLcFe3tWd +7oq3V2q5Nl6MrZfXj2N07qe6/9zfdauxYO26vAEKCvIkbMo= + + + + diff --git a/spec/moxml/c14n/api_spec.rb b/spec/moxml/c14n/api_spec.rb new file mode 100644 index 00000000..a38171d3 --- /dev/null +++ b/spec/moxml/c14n/api_spec.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require "spec_helper" +require "moxml/c14n" + +RSpec.describe "Moxml::C14n API" do + let(:ctx) { Moxml.new(:nokogiri) } + + describe ".canonicalize (algorithm selector)" do + it "defaults to inclusive C14N 1.0" do + doc = ctx.parse("") + default_result = Moxml::C14n.canonicalize(doc.root) + incl10_result = Moxml::C14n.canonicalize(doc.root, algorithm: :inclusive10) + expect(default_result).to eq(incl10_result) + end + + it "raises ArgumentError for unknown algorithm" do + expect do + Moxml::C14n.canonicalize("", algorithm: :bogus) + end.to raise_error(ArgumentError, /unknown C14N algorithm/) + end + + it "accepts inclusive_11 algorithm" do + doc = ctx.parse("") + result = Moxml::C14n.canonicalize(doc.root, algorithm: :inclusive11) + expect(result).to eq("") + end + + it "accepts exclusive_10 algorithm" do + doc = ctx.parse("") + result = Moxml::C14n.canonicalize(doc.root, algorithm: :exclusive10) + # Exclusive renders ns where visibly used (on foo:a), not on root + expect(result).to include('') + end + end + + describe ".equivalent?" do + it "returns true for byte-identical inputs" do + expect(Moxml::C14n).to be_equivalent("x", "x") + end + + it "returns true when only attribute whitespace differs" do + # Whitespace inside the tag is not significant — both parse identically. + a = %() + b = %() + expect(Moxml::C14n).to be_equivalent(a, b) + end + + it "returns false when text whitespace differs (text IS significant)" do + a = "a " + b = " a" + expect(Moxml::C14n).not_to be_equivalent(a, b) + end + + it "returns true when only attribute order differs" do + a = %() + b = %() + expect(Moxml::C14n).to be_equivalent(a, b) + end + + it "returns true when only namespace prefix differs" do + a = "" + b = "" + # Inclusive canonical form: prefix is part of qname → not equivalent + expect(Moxml::C14n).not_to be_equivalent(a, b) + end + + it "returns false for different content" do + expect(Moxml::C14n).not_to be_equivalent("a", "b") + end + + it "respects algorithm argument" do + a = "" + b = "" + # Inclusive: ns on root vs on child → different + expect(Moxml::C14n).not_to be_equivalent(a, b, algorithm: :inclusive10) + end + + it "accepts Moxml::Node inputs" do + doc1 = ctx.parse("") + doc2 = ctx.parse("") + expect(Moxml::C14n).to be_equivalent(doc1.root, doc2.root) + end + end + + describe "convenience methods" do + it ".canonicalize_inclusive10 matches default" do + doc = ctx.parse("") + expect(Moxml::C14n.canonicalize_inclusive10(doc.root)) + .to eq(Moxml::C14n.canonicalize(doc.root)) + end + + it ".canonicalize_inclusive11 produces output" do + doc = ctx.parse("") + expect(Moxml::C14n.canonicalize_inclusive11(doc.root)).to eq("") + end + + it ".canonicalize_exclusive accepts inclusive_namespaces" do + doc = ctx.parse("") + result = Moxml::C14n.canonicalize_exclusive(doc.root, inclusive_namespaces: ["foo"]) + # Even though foo is not visibly used, inclusive list forces render + expect(result).to include('xmlns:foo="urn:foo"') + end + end + + describe "escape helpers (backward compat)" do + it ".escape_text escapes & < >" do + expect(Moxml::C14n.escape_text("a & b < c > d")) + .to eq("a & b < c > d") + end + + it ".escape_attribute escapes quotes and whitespace" do + expect(Moxml::C14n.escape_attribute(%(a"b\tc\nd))) + .to eq(%(a"b c d)) + end + end +end diff --git a/spec/moxml/c14n/c14n_spec.rb b/spec/moxml/c14n/c14n_spec.rb new file mode 100644 index 00000000..b1f73978 --- /dev/null +++ b/spec/moxml/c14n/c14n_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require "spec_helper" +require "moxml/signature" + +RSpec.describe "Moxml C14N engine" do + describe Moxml::C14n::Exclusive do + let(:ctx) { Moxml.new(:nokogiri) } + let(:canon) { described_class.new } + + it "renders a simple element without namespaces" do + doc = ctx.parse("hello") + expect(canon.canonicalize(doc.root)).to eq("hello") + end + + it "preserves nested element structure" do + doc = ctx.parse("text") + expect(canon.canonicalize(doc.root)).to eq("text") + end + + it "renders declared namespaces" do + doc = ctx.parse(<<~XML.strip) + x + XML + result = canon.canonicalize(doc.root) + # Exclusive C14N renders the namespace on the element where it's visibly + # used (foo:child), not on ancestors that don't use it. + expect(result).to include('') + expect(result).to include("") + end + + it "excludes ancestor-inherited namespaces when not visibly used (exclusive)" do + doc = ctx.parse(<<~XML.strip) + text + XML + inner = doc.at_xpath("//inner") + result = canon.canonicalize(inner) + expect(result).to eq("text") + end + + it "escapes special characters in text content" do + doc = ctx.parse("a<b>c&d") + expect(canon.canonicalize(doc.root)).to eq("a<b>c&d") + end + + it "escapes special characters in attribute values" do + doc = ctx.parse(%()) + expect(canon.canonicalize(doc.root)) + .to include(%(attr="a&b"c")) + end + + it "sorts attributes by namespace URI then local name" do + doc = ctx.parse(%()) + result = canon.canonicalize(doc.root) + expect(result).to include('a="1" b="2" c="3"') + end + + it "omits comments by default" do + doc = ctx.parse("ab") + expect(canon.canonicalize(doc.root)).to eq("ab") + end + + it "includes comments when with_comments is true" do + doc = ctx.parse("ab") + expect(canon.canonicalize(doc.root, with_comments: true)) + .to eq("ab") + end + end + + describe ".escape_text" do + it "escapes &, <, >" do + expect(Moxml::C14n.escape_text("a & b < c > d")) + .to eq("a & b < c > d") + end + + it "escapes bare CR as " do + expect(Moxml::C14n.escape_text("a\rb")) + .to eq("a b") + end + end + + describe ".escape_attribute" do + it "escapes quotes and tabs/newlines" do + expect(Moxml::C14n.escape_attribute(%(a"b\tc\nd))) + .to eq(%(a"b c d)) + end + end +end diff --git a/spec/moxml/c14n/comments_pis_spec.rb b/spec/moxml/c14n/comments_pis_spec.rb new file mode 100644 index 00000000..a7c922ec --- /dev/null +++ b/spec/moxml/c14n/comments_pis_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require "spec_helper" +require "moxml/c14n" + +# Edge cases for comment and processing-instruction handling per +# W3C C14N 1.0 §2.6, §2.7. +RSpec.describe "C14N comments and PIs" do + let(:ctx) { Moxml.new(:nokogiri) } + + describe "comments inside elements" do + it "omits comments by default" do + doc = ctx.parse("ab") + expect(Moxml::C14n.canonicalize(doc.root)).to eq("ab") + end + + it "includes comments when with_comments: true" do + doc = ctx.parse("ab") + expect(Moxml::C14n.canonicalize(doc.root, with_comments: true)) + .to eq("ab") + end + + it "preserves comment content verbatim" do + doc = ctx.parse("") + expect(Moxml::C14n.canonicalize(doc.root, with_comments: true)) + .to eq("") + end + end + + describe "processing instructions" do + it "always renders PIs (not affected by with_comments)" do + doc = ctx.parse("x") + expect(Moxml::C14n.canonicalize(doc.root)) + .to eq("x") + end + + it "renders PI without data when data is empty" do + doc = ctx.parse("x") + expect(Moxml::C14n.canonicalize(doc.root)) + .to eq("x") + end + + it "preserves PI target and data verbatim" do + doc = ctx.parse(%(x)) + expect(Moxml::C14n.canonicalize(doc.root)) + .to include(%()) + end + end + + describe "PIs at document level" do + # TODO.c14n/09: Document-level PIs (outside the document element) are + # not yet rendered correctly. The moxml PI parser mis-parses document-level + # PIs, and the DataModel.from_document path drops them. Fixing requires + # adapter-level work and is tracked separately. + it "renders PIs outside the document element (KNOWN LIMITATION)" do + skip "document-level PI rendering not yet implemented" + end + end + + describe "comments at document level" do + it "renders document-level comments when with_comments: true (KNOWN LIMITATION)" do + skip "document-level comment rendering not yet implemented" + end + end +end diff --git a/spec/moxml/c14n/inclusive10_spec.rb b/spec/moxml/c14n/inclusive10_spec.rb new file mode 100644 index 00000000..87dd22f8 --- /dev/null +++ b/spec/moxml/c14n/inclusive10_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "spec_helper" +require "moxml/signature" + +RSpec.describe Moxml::C14n::Inclusive10 do + let(:ctx) { Moxml.new(:nokogiri) } + let(:canon) { described_class.new } + + it "renders a simple element" do + doc = ctx.parse("hello") + expect(canon.canonicalize(doc.root)).to eq("hello") + end + + it "attracts ancestor namespaces at the apex (the key inclusive behavior)" do + xml = <<~XML.strip + + hello + + XML + doc = ctx.parse(xml) + inner = doc.at_xpath("//*[local-name()='inner']") + result = canon.canonicalize(inner) + # Both inherited namespaces are rendered on , even though + # itself does not visibly use them. This is the inclusive + # "ancestor attraction" behavior. + expect(result).to include('xmlns:bar="http://example.com/bar"') + expect(result).to include('xmlns:foo="http://example.com/foo"') + end + + it "renders each namespace declaration only once per chain" do + xml = <<~XML.strip + + + + XML + doc = ctx.parse(xml) + result = canon.canonicalize(doc.root) + expect(result.scan('xmlns:a="http://example.com/a"').count).to eq(1) + end + + it "re-renders a namespace when re-declared with a different URI" do + xml = <<~XML.strip + + + + XML + doc = ctx.parse(xml) + result = canon.canonicalize(doc.root) + expect(result.scan("xmlns:a=").count).to eq(2) + end + + it "renders the default namespace at apex when inherited" do + xml = '' + doc = ctx.parse(xml) + inner = doc.at_xpath("//*[local-name()='inner']") + result = canon.canonicalize(inner) + expect(result).to include('xmlns="http://example.com"') + end +end diff --git a/spec/moxml/c14n/namespace_edge_cases_spec.rb b/spec/moxml/c14n/namespace_edge_cases_spec.rb new file mode 100644 index 00000000..1ad5e0fd --- /dev/null +++ b/spec/moxml/c14n/namespace_edge_cases_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require "spec_helper" +require "moxml/c14n" + +# Edge cases for namespace rendering per W3C C14N 1.0 §2.3 / 1.1 §2.3. +# These cover the bugs that historically plague C14N implementations. +RSpec.describe "C14N namespace rendering" do + let(:ctx) { Moxml.new(:nokogiri) } + + describe "default namespace" do + it "renders xmlns= declaration when default ns is set" do + doc = ctx.parse(%()) + result = Moxml::C14n.canonicalize(doc.root) + expect(result).to eq('') + end + + it "renders xmlns='' when transitioning to empty default" do + doc = ctx.parse(%()) + result = Moxml::C14n.canonicalize(doc.root) + # has empty default; has urn:foo → xmlns="" appears + expect(result).to include('xmlns=""') + end + + it "does not render xmlns when default ns is empty everywhere" do + doc = ctx.parse(%()) + result = Moxml::C14n.canonicalize(doc.root) + expect(result).to eq("") + end + end + + describe "prefix redeclaration" do + it "renders both declarations when URI changes" do + doc = ctx.parse(%()) + result = Moxml::C14n.canonicalize(doc.root) + expect(result.scan("xmlns:x=").length).to eq(2) + end + + it "renders only once when URI is unchanged" do + doc = ctx.parse(%()) + result = Moxml::C14n.canonicalize(doc.root) + expect(result.scan("xmlns:x=").length).to eq(1) + end + end + + describe "xml namespace" do + it "does not render xmlns:xml declaration" do + doc = ctx.parse(%()) + result = Moxml::C14n.canonicalize(doc.root) + expect(result).not_to include("xmlns:xml=") + end + end + + describe "namespace sorting" do + it "sorts xmlns declarations by prefix" do + doc = ctx.parse(%()) + result = Moxml::C14n.canonicalize(doc.root) + # a should appear before z + expect(result.index("xmlns:a=")).to be < result.index("xmlns:z=") + end + + it "renders default namespace first" do + doc = ctx.parse(%()) + result = Moxml::C14n.canonicalize(doc.root) + # Default (xmlns=) before prefixed + expect(result.index("xmlns=")).to be < result.index("xmlns:a=") + end + end + + describe "deeply nested inheritance" do + it "renders each prefix at the level it is first declared" do + xml = <<~XML.strip + + + + + + + + XML + doc = ctx.parse(xml) + result = Moxml::C14n.canonicalize(doc.root) + # Inclusive: all in-scope ns render on apex. But we're canonicalizing + # the whole tree, so each ns renders at its declaration point. + expect(result.scan("xmlns:").length).to eq(3) + end + end +end diff --git a/spec/moxml/c14n/xml_attributes_spec.rb b/spec/moxml/c14n/xml_attributes_spec.rb new file mode 100644 index 00000000..2488dcf1 --- /dev/null +++ b/spec/moxml/c14n/xml_attributes_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "spec_helper" +require "moxml/c14n" + +# xml:lang and xml:space are "simple inheritable" attributes per W3C C14N +# 1.1 §2.4. When an element is in the node-set but an ancestor is not, +# the canonical form inherits these attributes from the nearest ancestor +# in which they were declared. +# +# These specs exercise the inclusive behavior at full-document +# canonicalization (where everything is in the set, so inheritance +# doesn't trigger). For subset scenarios, see TODO.c14n/07. +RSpec.describe "C14N xml:* inheritable attributes" do + let(:ctx) { Moxml.new(:nokogiri) } + + it "renders xml:lang attribute" do + doc = ctx.parse(%(hello)) + expect(Moxml::C14n.canonicalize(doc.root)) + .to eq(%(hello)) + end + + it "renders xml:space attribute" do + doc = ctx.parse(%(data)) + expect(Moxml::C14n.canonicalize(doc.root)) + .to eq(%(data)) + end + + it "renders xml:lang on descendant that declares its own" do + doc = ctx.parse(%()) + result = Moxml::C14n.canonicalize(doc.root) + expect(result).to include('') + end + + it "renders xml:id attribute" do + doc = ctx.parse(%()) + expect(Moxml::C14n.canonicalize(doc.root)) + .to eq(%()) + end + + it "renders xml:base attribute" do + doc = ctx.parse(%()) + expect(Moxml::C14n.canonicalize(doc.root)) + .to eq(%()) + end + + it "sorts xml:* attributes by namespace URI then local name" do + doc = ctx.parse(%()) + result = Moxml::C14n.canonicalize(doc.root) + # All in XML namespace; sort by local name: id < lang < space + expect(result.index("xml:id=")).to be < result.index("xml:lang=") + expect(result.index("xml:lang=")).to be < result.index("xml:space=") + end +end diff --git a/spec/moxml/signature/algorithms/base64_transform_spec.rb b/spec/moxml/signature/algorithms/base64_transform_spec.rb new file mode 100644 index 00000000..d180678b --- /dev/null +++ b/spec/moxml/signature/algorithms/base64_transform_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "spec_helper" +require "moxml/signature" +require "base64" + +RSpec.describe Moxml::Signature::Algorithms::Base64Transform do + let(:ctx) { Moxml.new(:nokogiri) } + let(:transform) { described_class.new(context: ctx) } + + it "decodes base64 octet input" do + encoded = Base64.strict_encode64("hello world") + expect(transform.transform(encoded)).to eq("hello world") + end + + it "strips whitespace before decoding" do + encoded = Base64.strict_encode64("hello world").chars.each_slice(4).map(&:join).join("\n") + expect(transform.transform(encoded)).to eq("hello world") + end + + it "raises TransformError on invalid base64" do + expect do + transform.transform("!!!not base64!!!") + end.to raise_error(Moxml::Signature::TransformError) + end +end diff --git a/spec/moxml/signature/algorithms/digest_base_spec.rb b/spec/moxml/signature/algorithms/digest_base_spec.rb new file mode 100644 index 00000000..75ca44c2 --- /dev/null +++ b/spec/moxml/signature/algorithms/digest_base_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require "spec_helper" +require "moxml/signature" +require "openssl" +require "base64" + +RSpec.describe Moxml::Signature::Algorithms::DigestBase do + describe "SHA-256" do + let(:digest) { Moxml::Signature::Algorithms::SHA256.new } + + it "computes raw bytes matching OpenSSL" do + data = "hello" + expect(digest.digest(data)).to eq(OpenSSL::Digest::SHA256.digest(data)) + end + + it "computes base64-encoded digest matching OpenSSL" do + data = "hello" + expected = Base64.strict_encode64(OpenSSL::Digest::SHA256.digest(data)) + expect(digest.digest_base64(data)).to eq(expected) + end + + it "matches the FIPS 180-3 test vector for empty string" do + # SHA-256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 + expected_hex = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + expect(digest.digest("").unpack1("H*")).to eq(expected_hex) + end + + it "matches the FIPS 180-3 test vector for 'abc'" do + expected_hex = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + expect(digest.digest("abc").unpack1("H*")).to eq(expected_hex) + end + end + + describe "SHA-1" do + it "matches the FIPS test vector for 'abc'" do + digest = Moxml::Signature::Algorithms::SHA1.new + expected_hex = "a9993e364706816aba3e25717850c26c9cd0d89d" + expect(digest.digest("abc").unpack1("H*")).to eq(expected_hex) + end + + it "is registered under its W3C URI" do + klass = Moxml::Signature::Algorithms.lookup( + :digest, "http://www.w3.org/2000/09/xmldsig#sha1" + ) + expect(klass).to eq(Moxml::Signature::Algorithms::SHA1) + end + end + + describe "all five SHA digests" do + expected_uris = { + "http://www.w3.org/2000/09/xmldsig#sha1" => + [Moxml::Signature::Algorithms::SHA1, 20], + "http://www.w3.org/2001/04/xmldsig-more#sha224" => + [Moxml::Signature::Algorithms::SHA224, 28], + "http://www.w3.org/2001/04/xmlenc#sha256" => + [Moxml::Signature::Algorithms::SHA256, 32], + "http://www.w3.org/2001/04/xmldsig-more#sha384" => + [Moxml::Signature::Algorithms::SHA384, 48], + "http://www.w3.org/2001/04/xmlenc#sha512" => + [Moxml::Signature::Algorithms::SHA512, 64], + } + + expected_uris.each do |uri, (klass, byte_length)| + it "#{uri} resolves to #{klass.name.split('::').last} with #{byte_length}-byte output" do + instance = klass.new + result = instance.digest("test") + expect(result.bytesize).to eq(byte_length) + end + end + end +end diff --git a/spec/moxml/signature/algorithms/dsa_sha_spec.rb b/spec/moxml/signature/algorithms/dsa_sha_spec.rb new file mode 100644 index 00000000..748686d8 --- /dev/null +++ b/spec/moxml/signature/algorithms/dsa_sha_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "spec_helper" +require "moxml/signature" +require "openssl" + +RSpec.describe Moxml::Signature::Algorithms::DsaSha do + let(:key) { OpenSSL::PKey::DSA.generate(2048) } + let(:data) { "the quick brown fox jumps over the lazy dog" } + + it "round-trips DSA-SHA1" do + algo = described_class.new( + identifier_uri: "http://www.w3.org/2000/09/xmldsig#dsa-sha1", + ) + sig = algo.sign(data, key) + # DSA q for 2048-bit DSA is 256 bits → 32-byte halves → 64-byte raw sig. + expect(sig.bytesize).to eq(64) + expect(algo.verify(data, key, sig)).to be true + end + + it "round-trips DSA-SHA256" do + algo = described_class.new( + identifier_uri: "http://www.w3.org/2009/xmldsig11#dsa-sha256", + ) + sig = algo.sign(data, key) + expect(sig.bytesize).to eq(64) + expect(algo.verify(data, key, sig)).to be true + end + + it "rejects tampered payload" do + algo = described_class.new( + identifier_uri: "http://www.w3.org/2009/xmldsig11#dsa-sha256", + ) + sig = algo.sign(data, key) + expect(algo.verify("#{data}!", key, sig)).to be false + end + + it "rejects non-DSA keys" do + algo = described_class.new( + identifier_uri: "http://www.w3.org/2009/xmldsig11#dsa-sha256", + ) + expect do + algo.sign(data, OpenSSL::PKey::RSA.generate(2048)) + end.to raise_error(Moxml::Signature::SignatureKeyError) + end +end diff --git a/spec/moxml/signature/algorithms/ecdsa_sha_spec.rb b/spec/moxml/signature/algorithms/ecdsa_sha_spec.rb new file mode 100644 index 00000000..bea51668 --- /dev/null +++ b/spec/moxml/signature/algorithms/ecdsa_sha_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require "spec_helper" +require "moxml/signature" +require "openssl" + +RSpec.describe Moxml::Signature::Algorithms::EcdsaSha do + let(:key) { OpenSSL::PKey::EC.generate("prime256v1") } + let(:data) { "the quick brown fox jumps over the lazy dog" } + + %w[ + http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha1 + http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha224 + http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256 + http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384 + http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512 + ].each do |uri| + it "#{uri} round-trips sign/verify on P-256" do + algo = described_class.new(identifier_uri: uri) + sig = algo.sign(data, key) + # P-256: 32-byte r ‖ 32-byte s = 64 bytes + expect(sig.bytesize).to eq(64) + expect(algo.verify(data, key, sig)).to be true + end + end + + it "rejects tampered payload" do + algo = described_class.new( + identifier_uri: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256", + ) + sig = algo.sign(data, key) + expect(algo.verify("#{data}!", key, sig)).to be false + end + + it "rejects verification with a different key" do + other = OpenSSL::PKey::EC.generate("prime256v1") + algo = described_class.new( + identifier_uri: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256", + ) + sig = algo.sign(data, key) + expect(algo.verify(data, other, sig)).to be false + end + + it "supports P-384 with 48-byte coordinates" do + p384 = OpenSSL::PKey::EC.generate("secp384r1") + algo = described_class.new( + identifier_uri: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384", + ) + sig = algo.sign(data, p384) + expect(sig.bytesize).to eq(96) + expect(algo.verify(data, p384, sig)).to be true + end + + it "supports P-521 with 66-byte coordinates" do + p521 = OpenSSL::PKey::EC.generate("secp521r1") + algo = described_class.new( + identifier_uri: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512", + ) + sig = algo.sign(data, p521) + expect(sig.bytesize).to eq(132) + expect(algo.verify(data, p521, sig)).to be true + end + + it "rejects non-EC keys" do + algo = described_class.new( + identifier_uri: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256", + ) + expect do + algo.sign(data, OpenSSL::PKey::RSA.generate(2048)) + end.to raise_error(Moxml::Signature::SignatureKeyError) + end +end diff --git a/spec/moxml/signature/algorithms/enveloped_signature_transform_spec.rb b/spec/moxml/signature/algorithms/enveloped_signature_transform_spec.rb new file mode 100644 index 00000000..b66bac3b --- /dev/null +++ b/spec/moxml/signature/algorithms/enveloped_signature_transform_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "spec_helper" +require "moxml/signature" + +RSpec.describe Moxml::Signature::Algorithms::EnvelopedSignatureTransform do + let(:ctx) { Moxml.new(:nokogiri) } + + it "is a no-op when signature_element is nil (signing case)" do + doc = ctx.parse("") + root = doc.root + transform = described_class.new(context: ctx, signature_element: nil) + expect(transform.transform(root)).to equal(root) + end + + it "removes the containing signature from a copy (verification case)" do + xml = <<~XML.strip + + important + + + + + XML + doc = ctx.parse(xml) + sig_elem = doc.at_xpath("//ds:Signature", + "ds" => "http://www.w3.org/2000/09/xmldsig#") + + transform = described_class.new(context: ctx, signature_element: sig_elem) + result = transform.transform(doc.root) + + expect(doc.at_xpath("//ds:Signature", + "ds" => "http://www.w3.org/2000/09/xmldsig#")).not_to be_nil + + c14n = Moxml::C14n::Exclusive.new + canonical = c14n.canonicalize(result) + expect(canonical).not_to include("Signature") + expect(canonical).to include("important") + end +end diff --git a/spec/moxml/signature/algorithms/hmac_sha_spec.rb b/spec/moxml/signature/algorithms/hmac_sha_spec.rb new file mode 100644 index 00000000..84146c91 --- /dev/null +++ b/spec/moxml/signature/algorithms/hmac_sha_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "spec_helper" +require "moxml/signature" +require "openssl" + +RSpec.describe Moxml::Signature::Algorithms::HmacSha do + let(:secret) { "super-secret-shared-key" } + let(:data) { "the quick brown fox" } + + describe "HMAC-SHA256" do + let(:uri) { "http://www.w3.org/2001/04/xmldsig-more#hmac-sha256" } + let(:algo) { described_class.new(identifier_uri: uri) } + + it "produces a verifiable MAC" do + mac = algo.sign(data, secret) + expect(algo.verify(data, secret, mac)).to be true + end + + it "rejects a tampered payload" do + mac = algo.sign(data, secret) + expect(algo.verify("#{data}!", secret, mac)).to be false + end + + it "matches OpenSSL HMAC for the same inputs" do + mac = algo.sign(data, secret) + expected = OpenSSL::HMAC.digest("SHA256", secret, data) + expect(mac).to eq(expected) + end + end + + describe "HMAC truncation" do + let(:uri) { "http://www.w3.org/2001/04/xmldsig-more#hmac-sha256" } + + it "truncates to the specified bit length" do + algo = described_class.new( + identifier_uri: uri, + parameters: { hmac_output_length: 128 }, + ) + mac = algo.sign(data, secret) + expect(mac.bytesize).to eq(16) + end + + it "rejects truncation below hash_bits / 2" do + expect do + described_class.new( + identifier_uri: uri, + parameters: { hmac_output_length: 64 }, + ) + end.to raise_error(Moxml::Signature::SignatureError) + end + + it "rejects truncation below 80 bits even for SHA-1" do + expect do + described_class.new( + identifier_uri: "http://www.w3.org/2000/09/xmldsig#hmac-sha1", + parameters: { hmac_output_length: 72 }, + ) + end.to raise_error(Moxml::Signature::SignatureError) + end + + it "rejects truncation that is not a multiple of 8" do + expect do + described_class.new( + identifier_uri: uri, + parameters: { hmac_output_length: 130 }, + ) + end.to raise_error(Moxml::Signature::SignatureError) + end + end +end diff --git a/spec/moxml/signature/algorithms/rsa_pkcs1_sha_spec.rb b/spec/moxml/signature/algorithms/rsa_pkcs1_sha_spec.rb new file mode 100644 index 00000000..47d568d1 --- /dev/null +++ b/spec/moxml/signature/algorithms/rsa_pkcs1_sha_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "spec_helper" +require "moxml/signature" +require "openssl" + +RSpec.describe Moxml::Signature::Algorithms::RsaPkcs1Sha do + let(:key) { OpenSSL::PKey::RSA.generate(2048) } + let(:data) { "the quick brown fox jumps over the lazy dog" } + + describe "RSA-SHA256" do + let(:uri) { "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" } + let(:algo) { described_class.new(identifier_uri: uri) } + + it "produces a verifiable signature" do + signature = algo.sign(data, key) + expect(algo.verify(data, key, signature)).to be true + end + + it "rejects a tampered payload" do + signature = algo.sign(data, key) + expect(algo.verify("#{data}!", key, signature)).to be false + end + + it "rejects verification with a different key" do + other_key = OpenSSL::PKey::RSA.generate(2048) + signature = algo.sign(data, key) + expect(algo.verify(data, other_key, signature)).to be false + end + end + + describe "RSA-SHA1 / SHA224 / SHA384 / SHA512" do + %w[ + http://www.w3.org/2000/09/xmldsig#rsa-sha1 + http://www.w3.org/2001/04/xmldsig-more#rsa-sha224 + http://www.w3.org/2001/04/xmldsig-more#rsa-sha384 + http://www.w3.org/2001/04/xmldsig-more#rsa-sha512 + ].each do |uri| + it "#{uri} round-trips sign/verify" do + algo = described_class.new(identifier_uri: uri) + sig = algo.sign(data, key) + expect(algo.verify(data, key, sig)).to be true + end + end + end + + it "rejects a non-RSA key" do + algo = described_class.new( + identifier_uri: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + ) + expect do + algo.sign(data, "not-a-key") + end.to raise_error(Moxml::Signature::SignatureKeyError) + end +end diff --git a/spec/moxml/signature/algorithms_spec.rb b/spec/moxml/signature/algorithms_spec.rb new file mode 100644 index 00000000..2d13e9fc --- /dev/null +++ b/spec/moxml/signature/algorithms_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require "spec_helper" +require "moxml/signature" + +RSpec.describe Moxml::Signature::Algorithms do + describe "registry" do + before { described_class.load_builtins! } + + it "registers built-in digest algorithms" do + expect(described_class.registered?(:digest, + "http://www.w3.org/2001/04/xmlenc#sha256")).to be true + expect(described_class.registered?(:digest, + "http://www.w3.org/2000/09/xmldsig#sha1")).to be true + end + + it "registers built-in signature methods" do + expect(described_class.registered?(:signature_method, + "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256")).to be true + expect(described_class.registered?(:signature_method, + "http://www.w3.org/2001/04/xmldsig-more#hmac-sha256")).to be true + end + + it "registers built-in canonicalization algorithms" do + expect(described_class.registered?(:canonicalization, + "http://www.w3.org/2001/10/xml-exc-c14n#")).to be true + expect(described_class.registered?(:canonicalization, + "http://www.w3.org/2001/10/xml-exc-c14n#WithComments")).to be true + end + + it "registers built-in transform algorithms" do + expect(described_class.registered?(:transform, + "http://www.w3.org/2000/09/xmldsig#base64")).to be true + expect(described_class.registered?(:transform, + "http://www.w3.org/2000/09/xmldsig#enveloped-signature")).to be true + end + + it "looks up a registered algorithm class" do + klass = described_class.lookup(:digest, + "http://www.w3.org/2001/04/xmlenc#sha256") + expect(klass).to eq(Moxml::Signature::Algorithms::SHA256) + end + + it "raises UnknownAlgorithm for unregistered URIs" do + expect do + described_class.lookup(:digest, "http://example.com/nonexistent") + end.to raise_error(Moxml::Signature::UnknownAlgorithm) + end + + it "validates category names" do + expect do + described_class.register(:bogus, "http://example.com", Class.new) + end.to raise_error(ArgumentError) + end + end + + describe "custom algorithm registration" do + after do + described_class.instance_variable_get(:@registry)[:digest] + &.delete("http://test.example/custom-digest") + end + + it "accepts a custom algorithm class" do + custom = Class.new(Moxml::Signature::Algorithms::DigestBase) do + identifier "http://test.example/custom-digest" + + def compute_digest(data) + "x" * data.bytesize + end + end + + expect(described_class.lookup(:digest, + "http://test.example/custom-digest")).to eq(custom) + end + end +end diff --git a/spec/moxml/signature/cross_verify_spec.rb b/spec/moxml/signature/cross_verify_spec.rb new file mode 100644 index 00000000..8e0c45df --- /dev/null +++ b/spec/moxml/signature/cross_verify_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require "spec_helper" +require "moxml/signature" +require "openssl" + +RSpec.describe "Ported reference fixtures (cross-verification)" do + let(:ctx) { Moxml.new(:nokogiri) } + let(:fixtures_dir) do + File.expand_path("../../fixtures/xmldsig", __dir__) + end + + describe "sign3-result.xml (libxmlsec1 with embedded X509Certificate)" do + let(:xml) { File.read(File.join(fixtures_dir, "sign3-result.xml")) } + let(:doc) { ctx.parse(xml) } + + it "auto-extracts the verification key from X509Certificate" do + # No explicit key passed — Verifier should auto-extract from KeyInfo. + result = Moxml::Signature.verify(context: ctx, document: doc) + expect(result.valid?).to be true + expect(result.results.first.signature_valid?).to be true + expect(result.results.first.references.first.valid?).to be true + end + + it "parses the X509Data correctly" do + sig_elem = doc.at_xpath( + "//ds:Signature", "ds" => "http://www.w3.org/2000/09/xmldsig#" + ) + parsed = Moxml::Signature::Parser.new(context: ctx).parse(sig_elem) + expect(parsed.key_info).not_to be_nil + expect(parsed.key_info.x509_data).not_to be_nil + expect(parsed.key_info.x509_data.certificates.size).to eq(1) + + extractor = Moxml::Signature::KeyExtractor.new + key = extractor.extract(parsed.key_info) + expect(key).to be_a(OpenSSL::PKey::RSA) + end + end + + describe "sign2-result.xml (libxmlsec1 with KeyName only)" do + let(:xml) { File.read(File.join(fixtures_dir, "sign2-result.xml")) } + let(:doc) { ctx.parse(xml) } + let(:pub_key_path) { File.join(fixtures_dir, "keys", "rsa_ref.pub") } + + it "cross-verifies with the Ruby ref's RSA public key" do + skip "public key fixture not present" unless File.exist?(pub_key_path) + + pub = OpenSSL::PKey::RSA.new(File.read(pub_key_path)) + result = Moxml::Signature.verify(context: ctx, document: doc, key: pub) + expect(result.valid?).to be true + end + + it "auto-resolves KeyName via the application key_map" do + skip "public key fixture not present" unless File.exist?(pub_key_path) + + pub = OpenSSL::PKey::RSA.new(File.read(pub_key_path)) + # The fixture uses test; map it. + result = Moxml::Signature.verify( + context: ctx, document: doc, key_map: { "test" => pub }, + ) + expect(result.valid?).to be true + end + end +end diff --git a/spec/moxml/signature/edge_cases_spec.rb b/spec/moxml/signature/edge_cases_spec.rb new file mode 100644 index 00000000..8c32f11c --- /dev/null +++ b/spec/moxml/signature/edge_cases_spec.rb @@ -0,0 +1,284 @@ +# frozen_string_literal: true + +require "spec_helper" +require "moxml/signature" +require "openssl" + +# Edge cases and defensive behavior. Per the audit (TODO.complete/20), +# the original suite lacked: +# - malformed input handling +# - attack-scenario rejection +# - KeyExtractor failure modes +# - HMAC truncation at exact boundaries +# - multi-signature documents +# - adapter portability +RSpec.describe "Moxml::Signature edge cases and defense" do + let(:ctx) { Moxml.new(:nokogiri) } + + describe "malformed Signature elements" do + it "rejects a document with no Signature element" do + doc = ctx.parse("") + result = Moxml::Signature.verify( + context: ctx, document: doc, key: OpenSSL::PKey::RSA.generate(2048), + ) + expect(result.signature_count).to eq(0) + expect(result.valid?).to be true # vacuously — no signatures + end + + it "raises on SignatureValue with invalid base64" do + key = OpenSSL::PKey::RSA.generate(2048) + doc = ctx.parse(<<~XML.strip) + + + + + + + !!!!not base64!!!! + + + also not base64!!! + + XML + expect do + Moxml::Signature.verify(context: ctx, document: doc, key: key) + end.to raise_error(Moxml::Signature::MalformedSignatureError) + end + + it "returns false for a Signature with empty SignatureValue" do + key = OpenSSL::PKey::RSA.generate(2048) + doc = ctx.parse(<<~XML.strip) + + + + + + + YWJjZA== + + + + + XML + result = Moxml::Signature.verify(context: ctx, document: doc, key: key) + expect(result.valid?).to be false + end + + it "returns false for an unknown signature method URI" do + key = OpenSSL::PKey::RSA.generate(2048) + doc = ctx.parse(<<~XML.strip) + + + + + + + YWJjZA== + + + YWJjZA== + + XML + result = Moxml::Signature.verify(context: ctx, document: doc, key: key) + expect(result.valid?).to be false + expect(result.results.first.error).to be_a(Moxml::Signature::UnknownAlgorithm) + end + end + + describe "HMAC truncation boundary" do + let(:secret) { "shared-secret" } + let(:data) { "data" } + + it "accepts truncation at exactly the minimum (hash_bits/2)" do + # For SHA-256, minimum is 128 bits. Should be accepted. + algo = Moxml::Signature::Algorithms::HmacSha.new( + identifier_uri: "http://www.w3.org/2001/04/xmldsig-more#hmac-sha256", + parameters: { hmac_output_length: 128 }, + ) + mac = algo.sign(data, secret) + expect(mac.bytesize).to eq(16) + end + + it "accepts truncation at exactly 80 bits (SHA-1 minimum)" do + algo = Moxml::Signature::Algorithms::HmacSha.new( + identifier_uri: "http://www.w3.org/2000/09/xmldsig#hmac-sha1", + parameters: { hmac_output_length: 80 }, + ) + expect(algo.sign(data, secret).bytesize).to eq(10) + end + + it "rejects truncation one bit below the minimum" do + expect do + Moxml::Signature::Algorithms::HmacSha.new( + identifier_uri: "http://www.w3.org/2001/04/xmldsig-more#hmac-sha256", + parameters: { hmac_output_length: 127 }, + ) + end.to raise_error(Moxml::Signature::SignatureError) + end + end + + describe "KeyExtractor failure modes" do + let(:extractor) { Moxml::Signature::KeyExtractor.new } + + it "returns nil for an empty KeyInfo" do + key_info = Moxml::Signature::Model::KeyInfo.new + expect(extractor.extract(key_info)).to be_nil + end + + it "returns nil for nil KeyInfo" do + expect(extractor.extract(nil)).to be_nil + end + + it "returns nil for a malformed X509Certificate" do + key_info = Moxml::Signature::Model::KeyInfo.new( + x509_data: Moxml::Signature::Model::Key::X509Data.new( + certificates: ["!!!not valid base64!!!"], + ), + ) + expect(extractor.extract(key_info)).to be_nil + end + + it "returns nil for a valid-base64 but invalid-DER certificate" do + require "base64" + key_info = Moxml::Signature::Model::KeyInfo.new( + x509_data: Moxml::Signature::Model::Key::X509Data.new( + certificates: [Base64.strict_encode64("not a certificate")], + ), + ) + expect(extractor.extract(key_info)).to be_nil + end + + it "returns nil for RSAKeyValue with malformed modulus" do + key_info = Moxml::Signature::Model::KeyInfo.new( + key_value: Moxml::Signature::Model::KeyValue.new( + rsa_key_value: Moxml::Signature::Model::Key::RSAKeyValue.new( + modulus: "!!!invalid base64!!!", + exponent: "AQAB", + ), + ), + ) + expect(extractor.extract(key_info)).to be_nil + end + + it "returns nil for ECKeyValue with unknown curve URI" do + key_info = Moxml::Signature::Model::KeyInfo.new( + key_value: Moxml::Signature::Model::KeyValue.new( + ec_key_value: Moxml::Signature::Model::Key::ECKeyValue.new( + named_curve_uri: "urn:oid:1.2.3.4.unknown", + public_key: "abc", + ), + ), + ) + expect(extractor.extract(key_info)).to be_nil + end + + it "returns nil for KeyName not in key_map" do + key_info = Moxml::Signature::Model::KeyInfo.new(key_name: "unknown") + expect(extractor.extract(key_info)).to be_nil + end + end + + describe "multi-signature documents" do + it "reports each signature separately" do + doc = ctx.parse("xy") + key1 = OpenSSL::PKey::RSA.generate(2048) + key2 = OpenSSL::PKey::RSA.generate(2048) + + sig1 = Moxml::Signature.sign( + context: ctx, document: doc, key: key1, + signature_method: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + canonicalization_method: "http://www.w3.org/2001/10/xml-exc-c14n#", + digest_method: "http://www.w3.org/2001/04/xmlenc#sha256", + reference_uri: "", + transforms: ["http://www.w3.org/2000/09/xmldsig#enveloped-signature"] + ) + sig2 = Moxml::Signature.sign( + context: ctx, document: doc, key: key2, + signature_method: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + canonicalization_method: "http://www.w3.org/2001/10/xml-exc-c14n#", + digest_method: "http://www.w3.org/2001/04/xmlenc#sha256", + reference_uri: "", + transforms: ["http://www.w3.org/2000/09/xmldsig#enveloped-signature"] + ) + serializer = Moxml::Signature::Serializer.new(context: ctx) + doc.root.add_child(serializer.serialize(sig1).root) + doc.root.add_child(serializer.serialize(sig2).root) + + # Verifying with key1: only sig1 should pass; sig2's SignatureValue + # won't verify against key1. + result = Moxml::Signature.verify(context: ctx, document: doc, key: key1) + expect(result.signature_count).to eq(2) + expect(result.results.map(&:signature_valid?)).to contain_exactly(true, false) + end + end + + describe "wrapping attack defense" do + it "does not verify a Signature whose SignedInfo references a different node" do + # Construct: doc has . Signature references + # "#real" but a tampered copy of lives inside . + # The reference digest matches the tampered copy, not the real one. + # This is the canonical wrapping attack pattern. + # + # The library cannot detect this on its own (the spec allows + # Object payloads). The application must check that the signed + # node is the one expected. This spec documents that the library + # faithfully reports per-reference validity, leaving the trust + # decision to the caller. + key = OpenSSL::PKey::RSA.generate(2048) + doc = ctx.parse(<<~XML.strip) + + original + + XML + + # Sign the real payload + signature = Moxml::Signature.sign( + context: ctx, document: doc, key: key, + signature_method: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + canonicalization_method: "http://www.w3.org/2001/10/xml-exc-c14n#", + digest_method: "http://www.w3.org/2001/04/xmlenc#sha256", + reference_uri: "#real", + transforms: [] + ) + serializer = Moxml::Signature::Serializer.new(context: ctx) + doc.root.add_child(serializer.serialize(signature).root) + + result = Moxml::Signature.verify(context: ctx, document: doc, key: key) + expect(result.valid?).to be true + + # Library returns the reference result; application must check URI. + ref = result.results.first.references.first + expect(ref.uri).to eq("#real") + end + end + + describe "adapter portability" do + # Skip on Opal — adapter switching is the whole point of moxml. + it "verifies a signature produced under one adapter with another", :adapter_portability do + skip "Only Nokogiri available in CI" unless defined?(Nokogiri) + + signing_ctx = Moxml.new(:nokogiri) + verify_ctx = Moxml.new(:nokogiri) # would be :rexml in a multi-adapter env + + key = OpenSSL::PKey::RSA.generate(2048) + doc = signing_ctx.parse("payload") + signature = Moxml::Signature.sign( + context: signing_ctx, document: doc, key: key, + signature_method: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + canonicalization_method: "http://www.w3.org/2001/10/xml-exc-c14n#", + digest_method: "http://www.w3.org/2001/04/xmlenc#sha256", + reference_uri: "", + transforms: ["http://www.w3.org/2000/09/xmldsig#enveloped-signature"] + ) + serializer = Moxml::Signature::Serializer.new(context: signing_ctx) + doc.root.add_child(serializer.serialize(signature).root) + + # Round-trip the XML through a different context + xml = doc.to_xml(indent: 0) + doc2 = verify_ctx.parse(xml) + + result = Moxml::Signature.verify(context: verify_ctx, document: doc2, key: key) + expect(result.valid?).to be true + end + end +end diff --git a/spec/moxml/signature/fixtures_spec.rb b/spec/moxml/signature/fixtures_spec.rb new file mode 100644 index 00000000..3bb28536 --- /dev/null +++ b/spec/moxml/signature/fixtures_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require "spec_helper" +require "moxml/signature" +require "openssl" + +# Parses fixtures produced by the nokogiri-xmlsec-instructure reference +# implementation (which wraps libxmlsec1). The fixtures are real-world +# XML signatures; verifying them byte-for-byte requires byte-exact +# canonicalization (see TODO.complete/05-c14n-engine.md — inclusive C14N +# 1.0/1.1 are stubs). These specs assert the parser handles the wire +# format correctly. +RSpec.describe "Ported reference fixtures" do + let(:ctx) { Moxml.new(:nokogiri) } + let(:fixtures_dir) do + File.expand_path("../../fixtures/xmldsig", __dir__) + end + + describe "sign2-result.xml (libxmlsec1 enveloped RSA-SHA256)" do + let(:xml) { File.read(File.join(fixtures_dir, "sign2-result.xml")) } + let(:doc) { ctx.parse(xml) } + let(:signature_element) do + doc.at_xpath("//ds:Signature", "ds" => "http://www.w3.org/2000/09/xmldsig#") || + doc.at_xpath("//*[local-name()='Signature']") + end + + it "is parseable into a model" do + parsed = Moxml::Signature::Parser.new(context: ctx).parse(signature_element) + expect(parsed.signed_info).not_to be_nil + expect(parsed.signed_info.canonicalization_method.algorithm) + .to eq("http://www.w3.org/2001/10/xml-exc-c14n#") + expect(parsed.signed_info.signature_method.algorithm) + .to eq("http://www.w3.org/2001/04/xmldsig-more#rsa-sha256") + expect(parsed.signed_info.references.length).to eq(1) + + ref = parsed.signed_info.references.first + expect(ref.digest_method.algorithm) + .to eq("http://www.w3.org/2001/04/xmlenc#sha256") + expect(ref.digest_value).to eq("Gx8CGUsbi2qvBLd15VCmwELbDMND8F4vY3jPOc7/FJ0=") + # Reference uses 1024-bit RSA (libxmlsec1 test key) → 128-byte signature. + expect(parsed.signature_value.value.bytesize).to eq(128) + end + + it "decodes SignatureValue despite embedded newlines (libxmlsec1 wraps long base64)" do + parsed = Moxml::Signature::Parser.new(context: ctx).parse(signature_element) + expect(parsed.signature_value.value.bytesize).to eq(128) + end + + it "exposes the transforms chain" do + parsed = Moxml::Signature::Parser.new(context: ctx).parse(signature_element) + transforms = parsed.signed_info.references.first.transforms + expect(transforms.size).to eq(2) + expect(transforms.transforms.map(&:algorithm)).to eq([ + "http://www.w3.org/2000/09/xmldsig#enveloped-signature", + "http://www.w3.org/2001/10/xml-exc-c14n#", + ]) + end + + it "cross-verifies with the Ruby ref's RSA public key" do + pub_pem = File.read(File.join(fixtures_dir, "keys", "rsa_ref.pub")) + pub = OpenSSL::PKey::RSA.new(pub_pem) + result = Moxml::Signature.verify(context: ctx, document: doc, key: pub) + expect(result.valid?).to be true + expect(result.results.first.signature_valid?).to be true + expect(result.results.first.references.first.valid?).to be true + end + end + + describe "sign2-doc.xml (the unsigned payload)" do + let(:xml) { File.read(File.join(fixtures_dir, "sign2-doc.xml")) } + + it "is the expected enveloped payload" do + doc = ctx.parse(xml) + expect(doc.root.name).to eq("Envelope") + # Data is in the default urn:envelope namespace. + data = doc.at_xpath("//*[local-name()='Data']") + expect(data.text.strip).to eq("Hello, World!") + end + end +end diff --git a/spec/moxml/signature/key_extractor_spec.rb b/spec/moxml/signature/key_extractor_spec.rb new file mode 100644 index 00000000..1c4296fe --- /dev/null +++ b/spec/moxml/signature/key_extractor_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require "spec_helper" +require "moxml/signature" +require "openssl" + +RSpec.describe Moxml::Signature::KeyExtractor do + let(:extractor) { described_class.new } + + describe "RSAKeyValue" do + let(:rsa_key) { OpenSSL::PKey::RSA.generate(2048) } + + it "reconstructs a public RSA key from Modulus and Exponent" do + require "base64" + n_b64 = Base64.strict_encode64(rsa_key.n.to_s(2)) + e_b64 = Base64.strict_encode64(rsa_key.e.to_s(2)) + key_value = Moxml::Signature::Model::KeyValue.new( + rsa_key_value: Moxml::Signature::Model::Key::RSAKeyValue.new( + modulus: n_b64, + exponent: e_b64, + ), + ) + key_info = Moxml::Signature::Model::KeyInfo.new(key_value: key_value) + extracted = extractor.extract(key_info) + expect(extracted).to be_a(OpenSSL::PKey::RSA) + expect(extracted.n).to eq(rsa_key.n) + expect(extracted.e).to eq(rsa_key.e) + end + end + + describe "X509Certificate" do + let(:rsa_key) { OpenSSL::PKey::RSA.generate(2048) } + let(:cert) do + # Generate a self-signed cert so we can round-trip the DER. + cert = OpenSSL::X509::Certificate.new + cert.version = 2 + cert.serial = 1 + cert.subject = OpenSSL::X509::Name.parse("/CN=test") + cert.issuer = cert.subject + cert.public_key = rsa_key + cert.not_before = Time.now + cert.not_after = Time.now + 3600 + cert.sign(rsa_key, OpenSSL::Digest.new("SHA256")) + cert + end + + it "extracts the public key from the certificate" do + require "base64" + key_info = Moxml::Signature::Model::KeyInfo.new( + x509_data: Moxml::Signature::Model::Key::X509Data.new( + # X509Certificate is stored as base64-encoded DER (spec §4.5.4). + certificates: [Base64.strict_encode64(cert.to_der)], + ), + ) + extracted = extractor.extract(key_info) + expect(extracted).to be_a(OpenSSL::PKey::RSA) + expect(extracted.n).to eq(rsa_key.n) + end + end + + describe "KeyName" do + it "looks up the key in the application-supplied map" do + key = OpenSSL::PKey::RSA.generate(2048) + extractor_with_map = described_class.new(key_map: { "my-key" => key }) + key_info = Moxml::Signature::Model::KeyInfo.new(key_name: "my-key") + expect(extractor_with_map.extract(key_info)).to equal(key) + end + + it "returns nil for an unknown key name" do + key_info = Moxml::Signature::Model::KeyInfo.new(key_name: "unknown") + expect(extractor.extract(key_info)).to be_nil + end + end + + describe "ECKeyValue" do + it "reconstructs a P-256 public EC key" do + ec = OpenSSL::PKey::EC.generate("prime256v1") + # Spec §4.5.2.3: PublicKey contains the uncompressed-point form + # 0x04 || x || y, base64-encoded. + point_octets = ec.public_key.to_octet_string(:uncompressed) + ec_pub_b64 = Base64.strict_encode64(point_octets) + + key_info = Moxml::Signature::Model::KeyInfo.new( + key_value: Moxml::Signature::Model::KeyValue.new( + ec_key_value: Moxml::Signature::Model::Key::ECKeyValue.new( + named_curve_uri: "urn:oid:1.2.840.10045.3.1.7", + public_key: ec_pub_b64, + ), + ), + ) + extracted = extractor.extract(key_info) + expect(extracted).to be_a(OpenSSL::PKey::EC) + expect(extracted.public_key).to eq(ec.public_key) + end + end +end diff --git a/spec/moxml/signature/model_spec.rb b/spec/moxml/signature/model_spec.rb new file mode 100644 index 00000000..228f57a9 --- /dev/null +++ b/spec/moxml/signature/model_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "spec_helper" +require "moxml/signature" + +RSpec.describe Moxml::Signature::Model do + describe Moxml::Signature::Model::Signature do + it "stores its components" do + si = Moxml::Signature::Model::SignedInfo.new + sv = Moxml::Signature::Model::SignatureValue.new(value: "abc") + sig = described_class.new(id: "S1", signed_info: si, signature_value: sv) + expect(sig.id).to eq("S1") + expect(sig.signed_info).to equal(si) + expect(sig.signature_value).to equal(sv) + expect(sig.objects).to eq([]) + end + end + + describe Moxml::Signature::Model::Reference do + it "accepts uri, transforms, and digest method" do + t = Moxml::Signature::Model::Transforms.new( + transforms: [Moxml::Signature::Model::Transform.new( + algorithm: "http://www.w3.org/2000/09/xmldsig#enveloped-signature", + )], + ) + dm = Moxml::Signature::Model::DigestMethod.new( + algorithm: "http://www.w3.org/2001/04/xmlenc#sha256", + ) + ref = described_class.new(uri: "", transforms: t, digest_method: dm, + digest_value: "abc=") + expect(ref.uri).to eq("") + expect(ref.transforms.transforms.size).to eq(1) + expect(ref.digest_method.algorithm).to eq( + "http://www.w3.org/2001/04/xmlenc#sha256", + ) + expect(ref.digest_value).to eq("abc=") + end + end + + describe Moxml::Signature::Model::Transforms do + it "is empty by default" do + expect(described_class.new).to be_empty + end + + it "appends transforms with <<" do + transforms = described_class.new + t = Moxml::Signature::Model::Transform.new( + algorithm: "http://www.w3.org/2000/09/xmldsig#base64", + ) + transforms << t + expect(transforms.size).to eq(1) + end + end +end diff --git a/spec/moxml/signature/round_trip_spec.rb b/spec/moxml/signature/round_trip_spec.rb new file mode 100644 index 00000000..6d3d0c0f --- /dev/null +++ b/spec/moxml/signature/round_trip_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require "spec_helper" +require "moxml/signature" +require "openssl" + +# End-to-end signing + verification, including tamper detection and +# wrong-key rejection. Uses real model instances and real OpenSSL keys +# per the project's "no doubles" rule. +RSpec.describe "Moxml XML Signature end-to-end round trip" do + let(:ctx) { Moxml.new(:nokogiri) } + let(:private_key) { OpenSSL::PKey::RSA.generate(2048) } + let(:document_xml) { "Hello, World!" } + + let(:common_options) do + { + context: ctx, + signature_method: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + canonicalization_method: "http://www.w3.org/2001/10/xml-exc-c14n#", + digest_method: "http://www.w3.org/2001/04/xmlenc#sha256", + reference_uri: "", + transforms: ["http://www.w3.org/2000/09/xmldsig#enveloped-signature"], + } + end + + def sign_and_attach(xml, key, **opts) + doc = ctx.parse(xml) + signature = Moxml::Signature.sign(document: doc, key: key, **common_options.merge(opts)) + serialized = Moxml::Signature::Serializer.new(context: ctx).serialize(signature) + doc.root.add_child(serialized.root) + doc + end + + describe "RSA-SHA256 round trip" do + it "verifies a freshly-signed document" do + doc = sign_and_attach(document_xml, private_key) + result = Moxml::Signature.verify(context: ctx, document: doc, key: private_key) + expect(result.valid?).to be true + expect(result.signature_count).to eq(1) + end + + it "verifies with the public key alone" do + doc = sign_and_attach(document_xml, private_key) + pub = OpenSSL::PKey::RSA.new(private_key.public_to_pem) + result = Moxml::Signature.verify(context: ctx, document: doc, key: pub) + expect(result.valid?).to be true + end + + it "detects payload tampering" do + doc = sign_and_attach(document_xml, private_key) + greeting = doc.at_xpath("//greeting") + greeting.text = "Goodbye, World!" + result = Moxml::Signature.verify(context: ctx, document: doc, key: private_key) + expect(result.valid?).to be false + # SignatureValue still matches (SignedInfo unchanged) but reference + # digest mismatches. + expect(result.results.first.signature_valid?).to be true + expect(result.results.first.references.first.valid?).to be false + end + + it "detects SignedInfo tampering" do + doc = sign_and_attach(document_xml, private_key) + dv = doc.at_xpath("//ds:DigestValue", + "ds" => "http://www.w3.org/2000/09/xmldsig#") + dv.text = Base64.strict_encode64("x" * 32) + result = Moxml::Signature.verify(context: ctx, document: doc, key: private_key) + expect(result.valid?).to be false + end + + it "rejects an unrelated verification key" do + doc = sign_and_attach(document_xml, private_key) + other = OpenSSL::PKey::RSA.generate(2048) + result = Moxml::Signature.verify(context: ctx, document: doc, key: other) + expect(result.valid?).to be false + expect(result.results.first.signature_valid?).to be false + end + end + + describe "HMAC-SHA256 round trip" do + let(:hmac_options) do + common_options.merge( + signature_method: "http://www.w3.org/2001/04/xmldsig-more#hmac-sha256", + ) + end + + it "verifies with the shared secret" do + doc = sign_and_attach(document_xml, "shared-secret", **hmac_options) + result = Moxml::Signature.verify(context: ctx, document: doc, key: "shared-secret") + expect(result.valid?).to be true + end + + it "rejects the wrong secret" do + doc = sign_and_attach(document_xml, "shared-secret", **hmac_options) + result = Moxml::Signature.verify(context: ctx, document: doc, key: "wrong-secret") + expect(result.valid?).to be false + end + end + + describe "fixture key" do + let(:pem_path) do + File.expand_path("../../fixtures/xmldsig/keys/rsa_private.pem", __dir__) + end + let(:fixture_key) { OpenSSL::PKey::RSA.new(File.read(pem_path)) } + + it "loads and round-trips" do + skip "fixture key not present" unless File.exist?(pem_path) + + doc = sign_and_attach(document_xml, fixture_key) + result = Moxml::Signature.verify(context: ctx, document: doc, key: fixture_key) + expect(result.valid?).to be true + end + end +end