Skip to content

feat(wsse): pure-Python WS-Security signing (no xmlsec required)#1485

Open
martincollignon wants to merge 4 commits into
mvantellingen:masterfrom
martincollignon:feat/pure-python-wsse-signature
Open

feat(wsse): pure-Python WS-Security signing (no xmlsec required)#1485
martincollignon wants to merge 4 commits into
mvantellingen:masterfrom
martincollignon:feat/pure-python-wsse-signature

Conversation

@martincollignon
Copy link
Copy Markdown

Summary

Adds zeep.wsse.crypto — a pure-Python alternative to the existing xmlsec-based zeep.wsse.signature module. Uses the cryptography library instead of the C-based xmlsec, making installation straightforward on all platforms.

Motivation: We build Landbruget.dk, a Danish agricultural data transparency project. Integrating with Denmark's VetStat SOAP API for antibiotic usage data required WS-Security features that zeep's current xmlsec-based module doesn't support — and installing xmlsec across CI/CD and developer machines was a constant pain point. We ended up writing ~300 lines of manual XML signing. This PR extracts that into a clean, general-purpose module that benefits everyone.

What's new

Feature Current (xmlsec) New (crypto)
Sign Body + Timestamp
Sign UsernameToken
Sign BinarySecurityToken
Sign arbitrary elements ✅ (extra_references)
Inclusive namespace prefixes ✅ (per-reference)
PKCS#12 key loading
PEM key loading
Configurable digest algorithm Partial ✅ (SHA1/SHA256/SHA384/SHA512)
Configurable signature algorithm Partial ✅ (RSA-SHA1/SHA256/SHA384/SHA512)
No C library dependency
Signature verification
KeyIdentifier styles ✅ (ThumbprintSHA1, SKI)
Security header layout control ✅ (xmlsec_compatible, etc.)
Timestamp freshness validation
Certificate validity validation

Usage

from zeep.wsse.crypto import CryptoSignature, CryptoBinarySignature

# Drop-in replacement for wsse.Signature
sig = CryptoSignature("key.pem", "cert.pem")

# BinarySecurityToken variant
sig = CryptoBinarySignature("key.pem", "cert.pem")

# PKCS#12 support
sig = CryptoBinarySignature.from_pkcs12("cert.p12", b"password")

# Sign extra elements + custom C14N prefixes (e.g. for government SOAP APIs)
sig = CryptoBinarySignature(
    "key.pem", "cert.pem",
    sign_username_token=True,
    sign_binary_security_token=True,
    digest_method="http://www.w3.org/2001/04/xmlenc#sha256",
    inclusive_ns_prefixes={"Body": ["wsse", "ds"]},
)

# Works with Compose
from zeep.wsse import Compose
from zeep.wsse.username import UsernameToken
wsse = Compose([UsernameToken("user", "pass"), sig])

Install: pip install zeep[crypto]

Related issues

Test plan

  • 38 tests covering all features (signing, verification, algorithm combos, PKCS12, inclusive prefixes, key identifiers, header layout, timestamp validation, Compose integration, edge cases)
  • No changes to existing signature.py — fully additive
  • Graceful fallback if cryptography not installed (classes set to None in __init__.py)
  • crypto optional dependency added to setup.py extras

🤖 Generated with Claude Code

martincollignon and others added 2 commits March 20, 2026 10:09
Add `zeep.wsse.crypto` module as a drop-in alternative to the existing
xmlsec-based `zeep.wsse.signature` module. Uses the `cryptography`
library instead of the C-based `xmlsec`, making installation
straightforward on all platforms.

New capabilities beyond the xmlsec-based module:
- No C library dependency (pure Python via `cryptography` + `lxml`)
- PKCS#12 (.p12/.pfx) key loading support
- Configurable signed parts (Body, Timestamp, UsernameToken,
  BinarySecurityToken, or any element with wsu:Id)
- Per-reference inclusive namespace prefixes for exclusive C14N
- Mixed digest/signature algorithms (e.g. SHA-256 digests + RSA-SHA1)

Classes: CryptoSignature, CryptoBinarySignature, CryptoMemorySignature,
CryptoBinaryMemorySignature, PKCS12Signature

Install with: pip install zeep[crypto]

Closes mvantellingen#1357, relates to mvantellingen#1419, mvantellingen#1428, mvantellingen#1363, mvantellingen#1318

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add features surfaced by review:
- KeyIdentifier styles (ThumbprintSHA1, SubjectKeyIdentifier)
- Configurable security header element ordering
- Timestamp freshness validation (Created/Expires)
- Certificate validity period validation
- Internal _configure() method for cleaner initialization

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@taybin
Copy link
Copy Markdown

taybin commented May 19, 2026

I've been using some of this code myself. It generally works, but I did have to make some modifications. Most importantly, the load_der_x509_certificate import is unused and I need to add an implementation for verifying signatures from the server's BinarySecurityToken. I also had to explicitly add Timestamps to the Header. So a couple of oversights, but overall this has been very helpful.

@martincollignon
Copy link
Copy Markdown
Author

Thanks for the feedback. I pushed a follow-up commit (6c2ae06) that addresses the oversights you called out:

  • load_der_x509_certificate is no longer unused; it is now used to parse the DER X.509 certificate embedded in a response wsse:BinarySecurityToken.
  • Response verification from BinarySecurityToken is supported via explicit opt-in: verify(envelope, use_binary_security_token=True). The default verify(envelope) path still uses the configured certificate, so existing behavior is preserved.
  • Added timestamp_token support to the crypto signature classes, matching the existing UsernameToken(timestamp_token=...) pattern. sign_timestamp=True still means “sign the timestamp if present,” and the new option lets the crypto plugin append one before signing.
  • Added tests for BST-based verification success, wrong-cert default failure, tampered body digest failure, missing/malformed BST failures, appended timestamp signing, and no-timestamp behavior.

Focused verification passed locally:

PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 .venv/bin/python -m pytest tests/test_wsse_crypto.py
# 43 passed

@taybin
Copy link
Copy Markdown

taybin commented May 19, 2026

These changes seem to work, but I'm not sure how to set use_binary_security_token=True on CryptoMemorySignature.verify(). Isn't that verify() called by zeep itself? I think this needs to be set at construction time on the object, and not passed in as an argument.

I also don't think passing in a timestamp at context construction time will work, as the timestamp will soon be out of date, and I believe we keep using the same security context.

@taybin
Copy link
Copy Markdown

taybin commented May 20, 2026

Thank you for your work on this. The BST change looks good. I think there is still an issue with the timestamp just being set once at wsse context creation time. Shouldn't the timestamp be created inside CryptoMemorySignature.apply()?

something like:

    def _make_timestamp_token(self, envelope: etree._Element) -> None:
        from datetime import timedelta

        security = get_security_header(envelope)  # type: ignore[no-untyped-call]
        now = datetime.now(UTC)
        timestamp = etree.SubElement(security, _wsu("Timestamp"))
        ensure_id(timestamp)  # type: ignore[no-untyped-call]
        created = etree.SubElement(timestamp, _wsu("Created"))
        created.text = now.strftime("%Y-%m-%dT%H:%M:%SZ")
        expires = etree.SubElement(timestamp, _wsu("Expires"))
        expires.text = (now + timedelta(seconds=300)).strftime("%Y-%m-%dT%H:%M:%SZ")

    def apply(
        self, envelope: etree._Element, headers: object
    ) -> tuple[etree._Element, object]:
        if (self.sign_timestamp):
            security = get_security_header(envelope)  # type: ignore[no-untyped-call]
            ts = security.find(_wsu("Timestamp"))
            if (ts is None):
                self._make_timestamp_token(envelope)

        self._sign(envelope)
        return envelope, headers

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants