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 `