Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/nornir-auth-saml/tests/fixtures/gen.py

#!/usr/bin/env python3
"""Generate the INTEROP fixtures for nornir-auth-saml's `sp` verifier.

Why a Python script in a Rust crate: the verifier's canonicaliser (exclusive
XML C14N) is our own. A test that signs with our own canonicaliser and then
verifies with it proves only self-consistency. These fixtures are signed by
signxml, whose C14N is libxml2's (through lxml) — an independent
implementation — so `tests/sp_interop.rs` proves byte-for-byte agreement with
the C library every IdP-side toolkit is tested against.

Everything here is TEST-ONLY: the RSA/EC keys are generated fresh on every run
and thrown away; only the certificates and the signed documents are kept.

Run (needs `signxml` + `lxml` in the interpreter):
    python3 tests/fixtures/gen.py tests/fixtures
"""
import base64
import datetime
import os
import sys

from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec, rsa
from cryptography.x509.oid import NameOID
from lxml import etree
from lxml.etree import SubElement
from signxml import SignatureConstructionMethod, SignatureReference, XMLSigner, XMLVerifier
from signxml.util import ec_tag

OUT = sys.argv[1] if len(sys.argv) > 1 else os.path.dirname(os.path.abspath(__file__))
EXC = "http://www.w3.org/2001/10/xml-exc-c14n#"


def self_signed(key, cn):
    name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn)])
    return (
        x509.CertificateBuilder()
        .subject_name(name)
        .issuer_name(name)
        .public_key(key.public_key())
        .serial_number(4242)
        .not_valid_before(datetime.datetime(2020, 1, 1))
        .not_valid_after(datetime.datetime(2060, 1, 1))
        .sign(key, hashes.SHA256())
    )


def pem(cert):
    return cert.public_bytes(serialization.Encoding.PEM)


def write(name, data):
    with open(os.path.join(OUT, name), "wb") as f:
        f.write(data if isinstance(data, bytes) else data.encode())


rsa_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
rsa_cert = self_signed(rsa_key, "interop RSA IdP (TEST ONLY)")
ec_key = ec.generate_private_key(ec.SECP256R1())
ec_cert = self_signed(ec_key, "interop P-256 IdP (TEST ONLY)")
write("idp_rsa.cert.b64", base64.b64encode(rsa_cert.public_bytes(serialization.Encoding.DER)))
write("idp_ec.cert.b64", base64.b64encode(ec_cert.public_bytes(serialization.Encoding.DER)))
write("idp_rsa.cert.pem", pem(rsa_cert))

PLACE = '<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Id="placeholder"></ds:Signature>'


def response(resp_ns, assertion_open, a_sig="", r_sig="", attr_extra="", value_attrs=""):
    """A Success Response with one Assertion. `resp_ns` declares namespaces on
    the Response; `assertion_open` is the Assertion start tag (prefixed or
    default-namespaced); `a_sig`/`r_sig` are placeholders to place."""
    p = "saml:" if assertion_open.startswith("<saml:") else ""
    close = "</saml:Assertion>" if p else "</Assertion>"
    return f"""<samlp:Response {resp_ns} ID="_resp1" Version="2.0" IssueInstant="2024-01-01T00:00:00Z" Destination="https://sp.example.com/acs" InResponseTo="_req1">
  <saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">https://idp.example.com/</saml:Issuer>{r_sig}
  <samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status>
  {assertion_open}
    <{p}Issuer>https://idp.example.com/</{p}Issuer>{a_sig}
    <{p}Subject>
      <{p}NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">alice@example.com</{p}NameID>
      <{p}SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
        <{p}SubjectConfirmationData NotOnOrAfter="2024-01-01T00:05:00Z" Recipient="https://sp.example.com/acs" InResponseTo="_req1"/>
      </{p}SubjectConfirmation>
    </{p}Subject>
    <{p}Conditions NotBefore="2024-01-01T00:00:00Z" NotOnOrAfter="2024-01-01T00:10:00Z">
      <{p}AudienceRestriction><{p}Audience>https://sp.example.com/metadata</{p}Audience></{p}AudienceRestriction>
    </{p}Conditions>
    <{p}AuthnStatement AuthnInstant="2024-01-01T00:00:00Z" SessionIndex="_sess1"><{p}AuthnContext><{p}AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</{p}AuthnContextClassRef></{p}AuthnContext></{p}AuthnStatement>
    <{p}AttributeStatement>
      <{p}Attribute Name="email"><{p}AttributeValue{value_attrs}>alice@example.com</{p}AttributeValue></{p}Attribute>
      <{p}Attribute Name="groups"><{p}AttributeValue{value_attrs}>eng</{p}AttributeValue><{p}AttributeValue{value_attrs}>ops &amp; on-call</{p}AttributeValue></{p}Attribute>{attr_extra}
    </{p}AttributeStatement>
  {close}
</samlp:Response>"""


class Signer(XMLSigner):
    """signxml writes no InclusiveNamespaces into an ENVELOPED reference's c14n
    Transform (it digests with the prefixes but does not say so), so the
    PrefixList case needs the element written here. Only the XML shape is ours;
    the C14N and the digest are still libxml2's."""

    def _build_transforms_for_reference(self, *, transforms_node, reference, exclude_c14n_transform_element=True):
        SubElement(transforms_node, self._ds_tag("Transform"), Algorithm=SignatureConstructionMethod.enveloped.value)
        x = SubElement(transforms_node, self._ds_tag("Transform"), Algorithm=reference.c14n_method.value)
        if reference.inclusive_ns_prefixes:
            SubElement(x, ec_tag("InclusiveNamespaces"), PrefixList=" ".join(reference.inclusive_ns_prefixes))


def signer(alg="rsa-sha256"):
    return Signer(
        method=SignatureConstructionMethod.enveloped,
        signature_algorithm=alg,
        digest_algorithm="sha256",
        c14n_algorithm=EXC,
    )


def sign(xml, ref, key, cert, alg="rsa-sha256", prefixes=None):
    root = etree.fromstring(xml.encode())
    # The prefixes go on the REFERENCE too: given only as the sign() keyword,
    # signxml digests with them but writes the Transform without them.
    ref = [SignatureReference(URI=ref, inclusive_ns_prefixes=prefixes)] if prefixes else ref
    signed = signer(alg).sign(root, key=key, cert=[cert], reference_uri=ref, inclusive_ns_prefixes=prefixes)
    return signed


def check(root, cert, schema=True):
    # signxml verifies its own output (libxml2 C14N again) — a fixture that does
    # not verify here would be a broken fixture, not a finding.
    v = XMLVerifier()
    if not schema:
        # signxml's bundled XSD does not know the InclusiveNamespaces element it
        # itself emits; the signature check is what matters here.
        v.validate_schema = lambda _sig: None
    v.verify(etree.tostring(root), x509_cert=pem(cert).decode(), ignore_ambiguous_key_info=True)
    return root


NS = 'xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"'
A_OPEN = '<saml:Assertion ID="_assert1" Version="2.0" IssueInstant="2024-01-01T00:00:00Z">'

# (a) the assertion signed (the common Okta / Keycloak / ADFS shape).
x = sign(response(NS, A_OPEN, a_sig=PLACE), "#_assert1", rsa_key, rsa_cert)
write("a_assertion_signed.xml", etree.tostring(check(x, rsa_cert)))

# (b) only the response signed (Entra's "Sign SAML response" option).
x = sign(response(NS, A_OPEN, r_sig=PLACE), "#_resp1", rsa_key, rsa_cert)
write("b_response_signed.xml", etree.tostring(check(x, rsa_cert)))

# (b2) both: the assertion first, then the response over it.
x = sign(response(NS, A_OPEN, a_sig=PLACE, r_sig=PLACE.replace("placeholder", "second")), "#_assert1", rsa_key, rsa_cert)
s = etree.tostring(x).decode().replace('Id="second"', 'Id="placeholder"')
x = sign(s, "#_resp1", rsa_key, rsa_cert)
write("b2_both_signed.xml", etree.tostring(check(x, rsa_cert)))

# (c) xsi:type="xs:string" values: `xs` appears only inside an attribute VALUE,
# so exclusive C14N would drop it — the InclusiveNamespaces PrefixList="xs" case.
NS_C = NS + ' xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
x = sign(response(NS_C, A_OPEN, a_sig=PLACE, value_attrs=' xsi:type="xs:string"'), "#_assert1", rsa_key, rsa_cert, prefixes=["xs"])
write("c_inclusive_prefixes.xml", etree.tostring(check(x, rsa_cert, schema=False)))

# (d) Entra-style: the Assertion in the DEFAULT namespace declared on the
# Response, plus an unused prefix that must not be rendered.
NS_D = 'xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:unused="urn:example:unused"'
D_OPEN = '<Assertion ID="_assert1" Version="2.0" IssueInstant="2024-01-01T00:00:00Z">'
x = sign(response(NS_D, D_OPEN, a_sig=PLACE), "#_assert1", rsa_key, rsa_cert)
write("d_default_namespace.xml", etree.tostring(check(x, rsa_cert)))

# (e) ECDSA P-256 (raw r||s in SignatureValue, per XMLDSig 1.1).
x = sign(response(NS, A_OPEN, a_sig=PLACE), "#_assert1", ec_key, ec_cert, alg="ecdsa-sha256")
write("e_ecdsa_p256.xml", etree.tostring(check(x, ec_cert)))

# ── direct C14N checks: lxml's exclusive canonical form of tricky subtrees ──
C14N_DOC = """<root xmlns="urn:d" xmlns:a="urn:a" xmlns:b="urn:b" xmlns:unused="urn:u" xmlns:xs="urn:xs">
  <a:x b:attr2="2" attr1="1" a:attr3="3" xmlns:c="urn:c"><c:y/>text &amp; &lt; &gt; &#xD; end<empty></empty><b:z q="&quot;&#x9;&#xA;&#xD;&lt;&amp;'"/><!-- dropped --></a:x>
  <inner xmlns=""><deeper k="v"/><d:again xmlns:d="urn:d2"><d:more/></d:again></inner>
  <plain>default-ns child<a:mixed xmlns:a="urn:a">same a</a:mixed><a:changed xmlns:a="urn:a2"/></plain>
  <typed xmlns:xsi="urn:xsi" xsi:type="xs:string">v</typed>
  <?pi some data?>
</root>"""
write("c14n_doc.xml", C14N_DOC)
doc = etree.fromstring(C14N_DOC.encode())
cases = [
    ("x", "{urn:a}x", []),
    ("inner", "{}inner", []),
    ("again", "{urn:d2}again", []),
    ("plain", "{urn:d}plain", []),
    ("typed", "{urn:d}typed", []),
    ("typed_xs", "{urn:d}typed", ["xs"]),
    ("root", "{urn:d}root", []),
    ("root_unused", "{urn:d}root", ["unused"]),
]
manifest = []
for name, tag, prefixes in cases:
    if tag.startswith("{}"):
        el = doc.find(".//" + tag[2:])
    else:
        el = doc if doc.tag == tag else doc.find(".//" + tag)
    out = etree.tostring(el, method="c14n", exclusive=True, with_comments=False, inclusive_ns_prefixes=prefixes or None)
    write(f"c14n_{name}.expected", out)
    ns, local = tag[1:].split("}")
    manifest.append(f"{name}|{ns}|{local}|{' '.join(prefixes)}")
write("c14n_cases.txt", "\n".join(manifest) + "\n")
print("fixtures written to", OUT)