XML Signatures & Key Material
Canonicalize XML, read certificates and RSA keys, and convert between hex, bytes and Base64 — the primitives a SAML Service Provider, an XML-DSig verifier or an OpenID Connect JWKS endpoint is assembled from.
Overview
These are primitives, not a turn-key signer. Soli ships the pieces — canonicalization, certificate parsing, key parsing, modular exponentiation, PKCS#1 padding, encoding conversions — and you compose the protocol you need. That keeps one opinionated implementation of SAML or XML-DSig from being baked into the language, at the cost of you owning the assembly. The worked examples below are the composition, end to end.
Three encodings meet in this area, and mixing them up is the most common source of a signature that verifies locally and nowhere else:
Crypto.sha256,Crypto.modexpandCrypto.pkcs1_*all speak hex.- XML-DSig's
DigestValueandSignatureValue, and a JWK'sn/e, are Base64. Deflateand the encoders work in raw bytes.
Hex is the bridge between the first two — encoding a hex string to Base64 instead of the bytes it denotes produces a value twice the right length that no counterparty will accept.
XML Canonicalization
Xml.c14n_exclusive(xml, options?)
Canonicalizes a document with W3C Exclusive XML Canonicalization 1.0 (http://www.w3.org/2001/10/xml-exc-c14n#) — the canonical form XML-DSig signs. Empty elements expand to start/end pairs, attributes and namespace declarations are sorted, the XML declaration and comments are removed, and — the “exclusive” part — namespace declarations that are not visibly utilized are dropped rather than inherited from ancestors. DOCTYPE declarations are rejected as an XXE defense.
The optional second argument is either the InclusiveNamespaces PrefixList directly — a space-separated string such as "ds saml", or an array of prefixes, with #default selecting the default namespace — or an options hash:
inclusive_prefixes : String | Array - as aboveid : String - canonicalize only the subtree of the element whose Id/ID/id attribute matches, inheriting the ancestor namespace context. This is how a Reference URI="#..." is resolved.enveloped_signature : Bool - drop any descendant <ds:Signature> (the enveloped-signature transform)Returns
String — the canonical UTF-8 serialization (the no-comments form).Xml.c14n_exclusive("<?xml version=\"1.0\"?><doc b='2' a='1'/>")
# => "<doc a=\"1\" b=\"2\"></doc>"
# An ancestor namespace the signed element does not use is dropped (n2 removed):
xml = "<n0:r xmlns:n0=\"http://a\" xmlns:n2=\"http://c\"><n1:e xmlns:n1=\"http://b\">x</n1:e></n0:r>"
Xml.c14n_exclusive(xml)
# => "<n0:r xmlns:n0=\"http://a\"><n1:e xmlns:n1=\"http://b\">x</n1:e></n0:r>"
# Canonicalize the SAML-referenced element with its own signature stripped
Xml.c14n_exclusive(saml_response, {"id": "_assertion1", "enveloped_signature": true})
Xml.get_element_by_id(xml, id)
Returns the element whose Id/ID/id attribute equals id, serialized as a standalone fragment with the inherited ancestor namespaces injected onto its root — so it re-parses and re-canonicalizes identically to the in-context subtree. Raises if no such element exists.
Returns
String — the element subtree as standalone XMLXml.get_elements_by_tag(xml, local_name)
Every element with the given local name (namespace prefix ignored), each as a standalone fragment. Use it to locate elements that carry no Id, such as <ds:SignedInfo>.
Returns
Array of StringCertificates & Keys
X509.public_key(cert)
Parses an X.509 certificate and extracts its RSA public key. Accepts PEM (-----BEGIN CERTIFICATE-----), bare base64 as found in a SAML metadata <ds:X509Certificate>, hex, or a raw DER byte array.
Returns
Hash — { "algorithm": "RSA", "n": hex, "e": hex, "bits": Int }. The n/e hex strings drop straight into Crypto.modexp.key = X509.public_key(idp_metadata_cert)
em = Crypto.modexp(signature, key["e"], key["n"]) # the RSA verify step
X509.fingerprint(cert, algorithm?)
The certificate fingerprint (a hash of the DER bytes) as hex. algorithm is "sha256" (default) or "sha1". Useful for pinning an identity provider's certificate.
Returns
String — hex digestRsaKey.private_from_pem(pem)
Parses an RSA private key — PKCS#8 (-----BEGIN PRIVATE KEY-----) or PKCS#1 (-----BEGIN RSA PRIVATE KEY-----) PEM — so you can sign. The verification side, X509.public_key, only yields (n, e).
Returns
Hash — { "algorithm": "RSA", "n": hex, "e": hex, "d": hex, "bits": Int }The public counterpart is RsaKey.public_from_pem(pem), which reads an SPKI (-----BEGIN PUBLIC KEY-----) or PKCS#1 public key and returns { algorithm, n, e, bits }. Use it when you hold a bare public key rather than a certificate — publishing a JWKS, or verifying someone else’s tokens.
There is no RSA key generation. Soli reads keys, it does not create them — generate one with openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 and load the PEM. A signing key has to survive restarts, be shared across workers and hosts, and be rotatable, which makes it operator-managed configuration rather than something a process mints for itself.
# An OIDC provider's public key, as a JWK. Hex.decode first: encoding the
# hex text rather than the bytes it denotes yields a value twice as long.
key = RsaKey.private_from_pem(getenv("SOLI_OIDC_PRIVATE_KEY"))
jwk = {
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": Crypto.sha256(public_pem).slice(0, 16),
"n": Base64.urlsafe_encode(Hex.decode(key["n"])),
"e": Base64.urlsafe_encode(Hex.decode(key["e"]))
}
Encoding Bridges
Hex.encode(data) / Hex.decode(hex)
Bridges the hex world (Crypto.modexp, Crypto.sha256 and Crypto.pkcs1_* all speak hex) and the byte/Base64 world (Base64, XML-DSig's DigestValue/SignatureValue, and a JWK's n/e). encode takes a String or byte array and returns hex; decode takes a hex string (an optional 0x prefix is accepted) and returns a byte array.
Hex.decode raises on odd-length input. A hex string that lost a leading zero — from string trimming, or from a producer that drops it — fails here rather than silently decoding shifted by half a byte.
# hex digest -> base64 DigestValue
digest_value = Base64.encode(Hex.decode(Crypto.sha256(canonical_xml)))
# incoming base64 -> hex, to compare against a Crypto.* result
Hex.encode(Base64.decode(incoming_b64))
# PKCE S256 challenge, the same conversion in the OIDC world
challenge = Base64.urlsafe_encode(Hex.decode(Crypto.sha256(code_verifier)))
Deflate.deflate(data) / Deflate.inflate(data)
Raw DEFLATE (RFC 1951, with no zlib or gzip wrapper) — the compression used by the SAML 2.0 HTTP-Redirect binding. Byte conventions mirror Base64: deflate takes a String (its UTF-8 bytes) or a byte array and returns a byte array; inflate returns a String when the result is valid UTF-8 and a byte array otherwise.
# Inbound: decode a SAMLRequest from a redirect URL
xml = Deflate.inflate(Base64.decode(params["SAMLRequest"]))
# Outbound: encode one
param = Base64.encode(Deflate.deflate(authn_request_xml))
Putting It Together
RSA signing is modexp over a PKCS#1-padded digest, and verification is the same operation with the public exponent, unpadded. The round trip below is the whole of it:
key = RsaKey.private_from_pem(sp_private_key_pem)
key_octets = key["bits"] / 8
digest = Crypto.sha256(canonical_xml)
em = Crypto.pkcs1_pad(digest, key_octets) # block type 1
signature = Crypto.modexp(em, key["d"], key["n"]) # sign
# Verify with the public exponent
recovered = Crypto.pkcs1_unpad(Crypto.modexp(signature, key["e"], key["n"]))
assert(recovered == digest)
This is textbook RSA. Crypto.modexp is unblinded and its big-integer arithmetic is not constant-time, so a local attacker who can measure your signing operation precisely may be able to recover key material. That is a deliberate trade: the Rust rsa crate carries an unfixed timing advisory (RUSTSEC-2023-0071) and is excluded from this project. It is appropriate for signing documents you are publishing; treat a high-rate signing oracle exposed to untrusted timing measurement as out of scope.