From 63e01b22130512d02a09021fe9c4a027db19717e Mon Sep 17 00:00:00 2001 From: docJerem Date: Wed, 6 May 2026 09:43:33 +0200 Subject: [PATCH 1/7] =?UTF-8?q?=F0=9F=A7=AA=20Test=20/=20Add=20CertFactory?= =?UTF-8?q?=20helper=20for=20metadata=20certificate=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces an X509-backed test helper that generates self-signed certificates (signing, encryption, CA, expired) for upcoming metadata certificate validation rules. Wires test/support into elixirc_paths and adds x509 as a test-only dependency. --- mix.exs | 7 +- mix.lock | 1 + test/support/cert_factory.ex | 135 +++++++++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 test/support/cert_factory.ex diff --git a/mix.exs b/mix.exs index 7c298df..723e5a1 100644 --- a/mix.exs +++ b/mix.exs @@ -12,6 +12,7 @@ defmodule ExSaml.MixProject do description: description(), dialyzer: [ignore_warnings: ".dialyzer_ignore.exs"], elixir: "~> 1.15", + elixirc_paths: elixirc_paths(Mix.env()), package: package(), preferred_cli_env: preferred_cli_env(), source_url: @source_url, @@ -21,6 +22,9 @@ defmodule ExSaml.MixProject do ] end + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + # Run "mix help compile.app" to learn about applications. def application do [ @@ -42,7 +46,8 @@ defmodule ExSaml.MixProject do {:nebulex, "~> 2.6"}, {:plug, "~> 1.18"}, {:sobelow, "~> 0.13", only: [:dev, :test], runtime: false}, - {:sweet_xml, "~> 0.7"} + {:sweet_xml, "~> 0.7"}, + {:x509, "~> 0.9", only: [:test], runtime: false} ] end diff --git a/mix.lock b/mix.lock index cb91651..615b357 100644 --- a/mix.lock +++ b/mix.lock @@ -23,6 +23,7 @@ "sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"}, "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, + "x509": {:hex, :x509, "0.9.2", "a75aa605348abd905990f3d2dc1b155fcde4e030fa2f90c4a91534405dce0f6e", [:mix], [], "hexpm", "4c5ede75697e565d4b0f5be04c3b71bb1fd3a090ea243af4bd7dae144e48cfc7"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, "yaml_elixir": {:hex, :yaml_elixir, "2.12.1", "d74f2d82294651b58dac849c45a82aaea639766797359baff834b64439f6b3f4", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "d9ac16563c737d55f9bfeed7627489156b91268a3a21cd55c54eb2e335207fed"}, } diff --git a/test/support/cert_factory.ex b/test/support/cert_factory.ex new file mode 100644 index 0000000..e79e71b --- /dev/null +++ b/test/support/cert_factory.ex @@ -0,0 +1,135 @@ +defmodule ExSaml.Test.CertFactory do + @moduledoc """ + Test-only helper for generating self-signed X.509 certificates used in + `ExSaml.Metadata` validation tests. + + Wraps the `X509` hex package with SAML-flavoured defaults. Every `build/1` + call produces a fresh 2048-bit RSA key and a matching self-signed + certificate with a 10-year validity window. + + Returns a map with three views of the same cert: + + * `:der` — raw DER bytes + * `:pem` — PEM-encoded binary + * `:b64` — base64 DER, suitable for embedding in `` + """ + + @type cert :: %{der: binary(), pem: binary(), b64: binary()} + + @default_subject "/CN=ex_saml-test" + @default_key_size 2048 + @default_validity_days 3650 + + @spec build(keyword()) :: cert() + def build(opts \\ []) do + subject = Keyword.get(opts, :subject, @default_subject) + key_size = Keyword.get(opts, :key_size, @default_key_size) + template = Keyword.get(opts, :template, :server) + extensions = Keyword.get(opts, :extensions, []) + validity = validity_from(opts) + + key = X509.PrivateKey.new_rsa(key_size) + + cert = + X509.Certificate.self_signed(key, subject, + template: template, + validity: validity, + extensions: extensions, + hash: :sha256 + ) + + der = X509.Certificate.to_der(cert) + pem = X509.Certificate.to_pem(cert) + + %{der: der, pem: pem, b64: Base.encode64(der)} + end + + # ----- + # Shorthand builders for common fixtures + # ----- + + @doc "Self-signed leaf cert with digitalSignature + keyEncipherment key usage." + @spec signing(keyword()) :: cert() + def signing(opts \\ []) do + build( + Keyword.merge( + [ + template: :server, + extensions: [ + basic_constraints: X509.Certificate.Extension.basic_constraints(false), + key_usage: + X509.Certificate.Extension.key_usage([:digitalSignature, :keyEncipherment]) + ] + ], + opts + ) + ) + end + + @doc "Self-signed leaf cert intended for encryption only (no digitalSignature)." + @spec encryption(keyword()) :: cert() + def encryption(opts \\ []) do + build( + Keyword.merge( + [ + template: :server, + extensions: [ + basic_constraints: X509.Certificate.Extension.basic_constraints(false), + key_usage: X509.Certificate.Extension.key_usage([:keyEncipherment]) + ] + ], + opts + ) + ) + end + + @doc "CA certificate (BasicConstraints CA:TRUE, KeyUsage includes keyCertSign)." + @spec ca(keyword()) :: cert() + def ca(opts \\ []) do + build( + Keyword.merge( + [ + template: :root_ca, + extensions: [ + basic_constraints: X509.Certificate.Extension.basic_constraints(true), + key_usage: + X509.Certificate.Extension.key_usage([ + :digitalSignature, + :keyCertSign, + :cRLSign + ]) + ] + ], + opts + ) + ) + end + + @doc "Already-expired leaf cert (notAfter 1 day ago, notBefore 30 days ago)." + @spec expired(keyword()) :: cert() + def expired(opts \\ []) do + signing(Keyword.put(opts, :validity, expired_validity())) + end + + # ----- + # Internals + # ----- + + defp validity_from(opts) do + case Keyword.fetch(opts, :validity) do + {:ok, v} -> + v + + :error -> + X509.Certificate.Validity.days_from_now( + Keyword.get(opts, :validity_days, @default_validity_days) + ) + end + end + + defp expired_validity do + not_before = DateTime.add(DateTime.utc_now(), -30 * 86_400, :second) + not_after = DateTime.add(DateTime.utc_now(), -1 * 86_400, :second) + X509.Certificate.Validity.new(not_before, not_after) + end +end From 3dae1cb207a3c060cad5bd4f681d2f42e1ff332c Mon Sep 17 00:00:00 2001 From: docJerem Date: Tue, 19 May 2026 14:41:25 +0200 Subject: [PATCH 2/7] =?UTF-8?q?=E2=9C=A8=20Feature=20/=20Metadata=20/=20Ad?= =?UTF-8?q?d=20Certificate=20parser=20and=20validity=20rules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces ExSaml.Metadata.Certificate, plugged into the run_rules pipeline, which emits three always-error violations against every declared under SPSSO/IDPSSO descriptors: * :missing_x509_certificate — KeyDescriptor without a non-empty text node. * :invalid_x509_certificate — content that is not parseable as base64 DER or whose ASN.1 structure pkix_decode_cert rejects. * :certificate_expired — parseable cert whose notAfter is in the past as of DateTime.utc_now/0. Silence-able via the existing :ignore option for legacy scenarios. The module relies only on :public_key, already in extra_applications — no new runtime dependency. Cert paths are reported in XPath form, indexed only when more than one KeyDescriptor or X509Certificate share a parent. Best-practice cert linting (CA detection, KeyUsage, shared signing/encryption cert) is intentionally deferred to the next batch, alongside the :strict-mode plumbing it depends on. Refs #17 --- lib/ex_saml/metadata.ex | 5 +- lib/ex_saml/metadata/certificate.ex | 249 ++++++++++++++++++ test/ex_saml/metadata_test.exs | 107 ++++++++ .../metadata/keydescriptor_invalid_cert.xml | 17 ++ .../metadata/keydescriptor_missing_cert.xml | 15 ++ 5 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 lib/ex_saml/metadata/certificate.ex create mode 100644 test/fixtures/metadata/keydescriptor_invalid_cert.xml create mode 100644 test/fixtures/metadata/keydescriptor_missing_cert.xml diff --git a/lib/ex_saml/metadata.ex b/lib/ex_saml/metadata.ex index c83b1cb..14b558a 100644 --- a/lib/ex_saml/metadata.ex +++ b/lib/ex_saml/metadata.ex @@ -32,6 +32,7 @@ defmodule ExSaml.Metadata do See `ExSaml.Metadata.ValidationResult` for the shape of the returned struct. """ + alias ExSaml.Metadata.Certificate alias ExSaml.Metadata.ValidationResult require Record @@ -173,7 +174,9 @@ defmodule ExSaml.Metadata do # --------------------------------------------------------------------------- defp check_entity_descriptor(root) do - entity_id_violations(root) ++ descriptor_violations(root) + entity_id_violations(root) ++ + descriptor_violations(root) ++ + Certificate.violations(root, @namespaces) end defp entity_id_violations(root) do diff --git a/lib/ex_saml/metadata/certificate.ex b/lib/ex_saml/metadata/certificate.ex new file mode 100644 index 0000000..2a74236 --- /dev/null +++ b/lib/ex_saml/metadata/certificate.ex @@ -0,0 +1,249 @@ +defmodule ExSaml.Metadata.Certificate do + @moduledoc false + + # Certificate-level metadata validation rules. + # + # Iterates every `` declared under an `` + # or `` and emits violations for the structural and + # validity expectations of PR 2: + # + # * `:missing_x509_certificate` — KeyDescriptor without a non-empty + # `` text node. + # * `:invalid_x509_certificate` — text that does not decode as base64 DER + # or whose ASN.1 structure cannot be parsed by `:public_key`. + # * `:certificate_expired` — parseable certificate whose `notAfter` is in + # the past relative to `DateTime.utc_now/0`. Silence-able via + # `ExSaml.Metadata.validate/2`'s `:ignore` option. + # + # Best-practice rules that depend on certificate inspection (CA detection, + # KeyUsage linting, shared signing/encryption certificate) live in the next + # batch of rules alongside strict-mode plumbing — see issue #17 PR 3. + + alias ExSaml.Metadata.ValidationResult + + require Record + + Record.defrecordp( + :xml_element, + :xmlElement, + Record.extract(:xmlElement, from_lib: "xmerl/include/xmerl.hrl") + ) + + Record.defrecordp( + :xml_text, + :xmlText, + Record.extract(:xmlText, from_lib: "xmerl/include/xmerl.hrl") + ) + + @descriptor_kinds [:sp, :idp] + + @spec violations(:xmerl.xmlElement(), keyword()) :: [ValidationResult.violation()] + def violations(root, namespaces) do + @descriptor_kinds + |> Enum.flat_map(&key_descriptors_with_path(root, &1, namespaces)) + |> Enum.flat_map(fn {kd, path} -> kd_violations(kd, path, namespaces) end) + end + + # --------------------------------------------------------------------------- + # KeyDescriptor enumeration + # --------------------------------------------------------------------------- + + defp key_descriptors_with_path(root, kind, namespaces) do + descriptor_tag = descriptor_tag(kind) + + root + |> xpath_elems("./md:#{descriptor_tag}", namespaces) + |> Enum.flat_map(fn desc -> + kds = xpath_elems(desc, "./md:KeyDescriptor", namespaces) + total = length(kds) + + kds + |> Enum.with_index(1) + |> Enum.map(fn {kd, idx} -> + index_segment = if total > 1, do: "[#{idx}]", else: "" + {kd, "/EntityDescriptor/#{descriptor_tag}/KeyDescriptor#{index_segment}"} + end) + end) + end + + defp descriptor_tag(:sp), do: "SPSSODescriptor" + defp descriptor_tag(:idp), do: "IDPSSODescriptor" + + # --------------------------------------------------------------------------- + # Per-KeyDescriptor rules + # --------------------------------------------------------------------------- + + defp kd_violations(kd, kd_path, namespaces) do + cert_elems = xpath_elems(kd, "./ds:KeyInfo/ds:X509Data/ds:X509Certificate", namespaces) + + case cert_elems do + [] -> + [missing_cert_violation(kd_path)] + + list -> + total = length(list) + + list + |> Enum.with_index(1) + |> Enum.flat_map(fn {cert_elem, idx} -> + cert_path = cert_path(kd_path, idx, total) + + case String.trim(text_content(cert_elem)) do + "" -> [missing_cert_violation(kd_path)] + text -> validate_cert(text, cert_path) + end + end) + end + end + + defp cert_path(kd_path, _idx, 1), do: kd_path <> "/KeyInfo/X509Data/X509Certificate" + defp cert_path(kd_path, idx, _), do: kd_path <> "/KeyInfo/X509Data/X509Certificate[#{idx}]" + + defp validate_cert(b64, path) do + case parse_b64(b64) do + {:ok, cert} -> + if expired?(cert, DateTime.utc_now()) do + [expired_violation(path)] + else + [] + end + + :error -> + [invalid_cert_violation(path)] + end + end + + # --------------------------------------------------------------------------- + # Certificate parsing + # --------------------------------------------------------------------------- + + defp parse_b64(b64) do + cleaned = String.replace(b64, ~r/\s+/, "") + + with {:ok, der} <- Base.decode64(cleaned), + {:ok, cert} <- safe_pkix_decode(der) do + {:ok, cert} + else + _ -> :error + end + end + + defp safe_pkix_decode(der) do + {:ok, :public_key.pkix_decode_cert(der, :otp)} + rescue + _ -> :error + catch + _, _ -> :error + end + + defp expired?(cert, now) do + case not_after(cert) do + %DateTime{} = dt -> DateTime.compare(now, dt) == :gt + nil -> false + end + end + + defp not_after(cert) do + cert + |> elem(1) + |> elem(5) + |> elem(2) + |> parse_asn1_time() + rescue + _ -> nil + end + + defp parse_asn1_time({:utcTime, charlist}) do + case to_string(charlist) do + <> -> + yy_int = String.to_integer(yy) + year = if yy_int >= 50, do: 1900 + yy_int, else: 2000 + yy_int + to_datetime(year, mm, dd, hh, mi, ss) + + _ -> + nil + end + end + + defp parse_asn1_time({:generalTime, charlist}) do + case to_string(charlist) do + <> -> + to_datetime(String.to_integer(year), mm, dd, hh, mi, ss) + + _ -> + nil + end + end + + defp parse_asn1_time(_), do: nil + + defp to_datetime(year, mm, dd, hh, mi, ss) do + iso = + "#{pad4(year)}-#{mm}-#{dd}T#{hh}:#{mi}:#{ss}Z" + + case DateTime.from_iso8601(iso) do + {:ok, dt, 0} -> dt + _ -> nil + end + end + + defp pad4(year), do: year |> Integer.to_string() |> String.pad_leading(4, "0") + + # --------------------------------------------------------------------------- + # Text extraction + # --------------------------------------------------------------------------- + + defp text_content(element) do + element + |> xml_element(:content) + |> Enum.flat_map(fn + child when Record.is_record(child, :xmlText) -> [xml_text(child, :value)] + _ -> [] + end) + |> IO.iodata_to_binary() + end + + # --------------------------------------------------------------------------- + # XPath wrapper + # --------------------------------------------------------------------------- + + defp xpath_elems(context, path, namespaces) do + :xmerl_xpath.string(to_charlist(path), context, [{:namespace, namespaces}]) + end + + # --------------------------------------------------------------------------- + # Violation builders + # --------------------------------------------------------------------------- + + defp missing_cert_violation(kd_path) do + %{ + code: :missing_x509_certificate, + severity: :error, + message: " must contain a non-empty ", + path: kd_path, + spec_reference: "SAML 2.0 Metadata §2.4.1.1, XML-DSig §4.4.4" + } + end + + defp invalid_cert_violation(path) do + %{ + code: :invalid_x509_certificate, + severity: :error, + message: " content is not a parseable base64 DER X.509 certificate", + path: path, + spec_reference: "XML-DSig §4.4.4, RFC 5280 §4.1" + } + end + + defp expired_violation(path) do + %{ + code: :certificate_expired, + severity: :error, + message: "X.509 certificate is expired (notAfter is in the past)", + path: path, + spec_reference: "RFC 5280 §4.1.2.5" + } + end +end diff --git a/test/ex_saml/metadata_test.exs b/test/ex_saml/metadata_test.exs index e8121f5..b55d2c7 100644 --- a/test/ex_saml/metadata_test.exs +++ b/test/ex_saml/metadata_test.exs @@ -3,6 +3,7 @@ defmodule ExSaml.MetadataTest do alias ExSaml.Metadata alias ExSaml.Metadata.ValidationResult + alias ExSaml.Test.CertFactory @fixtures_dir Path.expand("../fixtures/metadata", __DIR__) @@ -10,6 +11,34 @@ defmodule ExSaml.MetadataTest do defp codes(violations), do: Enum.map(violations, & &1.code) + defp sp_metadata_with_keys(key_descriptors) do + """ + + + + #{Enum.join(key_descriptors, "\n ")} + + + + """ + end + + defp key_descriptor(use, b64) do + """ + + + + #{b64} + + + + """ + end + describe "validate/1 happy path" do test "returns :ok on spec-clean SP metadata" do xml = read_fixture("sp_clean.xml") @@ -219,4 +248,82 @@ defmodule ExSaml.MetadataTest do Metadata.validate(xml, ignore: []) end end + + describe "certificate rules" do + test "flags without " do + xml = read_fixture("keydescriptor_missing_cert.xml") + + assert {:error, %ValidationResult{errors: errors}} = Metadata.validate(xml) + assert :missing_x509_certificate in codes(errors) + end + + test "flags with non-base64 content" do + xml = read_fixture("keydescriptor_invalid_cert.xml") + + assert {:error, %ValidationResult{errors: errors}} = Metadata.validate(xml) + assert :invalid_x509_certificate in codes(errors) + end + + test "flags base64 content that decodes but is not a valid X.509 cert" do + garbage_b64 = Base.encode64("hello world this is not a der cert") + xml = sp_metadata_with_keys([key_descriptor("signing", garbage_b64)]) + + assert {:error, %ValidationResult{errors: errors}} = Metadata.validate(xml) + assert :invalid_x509_certificate in codes(errors) + end + + test "accepts a spec-clean SP with a freshly-issued signing cert" do + cert = CertFactory.signing() + xml = sp_metadata_with_keys([key_descriptor("signing", cert.b64)]) + + assert {:ok, %ValidationResult{errors: [], warnings: []}} = Metadata.validate(xml) + end + + test "flags an expired signing cert with :certificate_expired" do + cert = CertFactory.expired() + xml = sp_metadata_with_keys([key_descriptor("signing", cert.b64)]) + + assert {:error, %ValidationResult{errors: errors}} = Metadata.validate(xml) + assert :certificate_expired in codes(errors) + + err = Enum.find(errors, &(&1.code == :certificate_expired)) + assert err.path =~ "SPSSODescriptor/KeyDescriptor" + assert err.path =~ "X509Certificate" + end + + test ":ignore silences :certificate_expired" do + cert = CertFactory.expired() + xml = sp_metadata_with_keys([key_descriptor("signing", cert.b64)]) + + assert {:ok, %ValidationResult{errors: [], warnings: []}} = + Metadata.validate(xml, ignore: [:certificate_expired]) + end + + test "indexes paths when multiple KeyDescriptors are present" do + signing = CertFactory.signing() + encryption = CertFactory.encryption() + expired = CertFactory.expired() + + xml = + sp_metadata_with_keys([ + key_descriptor("signing", signing.b64), + key_descriptor("encryption", encryption.b64), + key_descriptor("signing", expired.b64) + ]) + + assert {:error, %ValidationResult{errors: errors}} = Metadata.validate(xml) + + err = Enum.find(errors, &(&1.code == :certificate_expired)) + assert err + assert err.path =~ "KeyDescriptor[3]" + end + + test "does not fire on metadata with no " do + assert {:ok, %ValidationResult{errors: [], warnings: []}} = + Metadata.validate(read_fixture("sp_clean.xml")) + + assert {:ok, %ValidationResult{errors: [], warnings: []}} = + Metadata.validate(read_fixture("idp_clean.xml")) + end + end end diff --git a/test/fixtures/metadata/keydescriptor_invalid_cert.xml b/test/fixtures/metadata/keydescriptor_invalid_cert.xml new file mode 100644 index 0000000..2b47905 --- /dev/null +++ b/test/fixtures/metadata/keydescriptor_invalid_cert.xml @@ -0,0 +1,17 @@ + + + + + + + not-a-real-certificate!! + + + + + + diff --git a/test/fixtures/metadata/keydescriptor_missing_cert.xml b/test/fixtures/metadata/keydescriptor_missing_cert.xml new file mode 100644 index 0000000..a160b79 --- /dev/null +++ b/test/fixtures/metadata/keydescriptor_missing_cert.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + From 3ee490019023935851e173fbb30471b3298cb274 Mon Sep 17 00:00:00 2001 From: docJerem Date: Tue, 19 May 2026 14:44:25 +0200 Subject: [PATCH 3/7] =?UTF-8?q?=E2=9C=A8=20Feature=20/=20Metadata=20/=20Ad?= =?UTF-8?q?d=20ds:Signature=20structure=20rules=20and=20algorithm=20allow-?= =?UTF-8?q?list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces ExSaml.Metadata.Signature, plugged into the run_rules pipeline, which inspects every declared either at document level (child of ) or nested inside a SPSSO / IDPSSO descriptor. Three always-error violations: * :invalid_signature_structure — a is missing one of its mandatory XML-DSig children (SignedInfo, SignatureValue, SignedInfo/SignatureMethod, SignedInfo/Reference[N]/DigestMethod, SignedInfo/Reference[N]/DigestValue) or one of those children is missing the required @Algorithm attribute. * :unknown_signature_algorithm — URI is not part of the spec-defined XML-DSig / xmldsig-more set declared in @known_signature_algorithms. * :unknown_digest_algorithm — same idea for . The "known" allow-list intentionally still recognises legacy URIs (RSA-SHA1, MD5, …) because PR 2 only validates that a value is part of the published spec. The dedicated :deprecated_signature_algorithm rule, which flags the legacy subset, ships in PR 3 together with strict-mode promotion. Cryptographic verification of the signature is explicitly out of scope (issue #17 Non-goals). Refs #17 --- lib/ex_saml/metadata.ex | 4 +- lib/ex_saml/metadata/signature.ex | 263 ++++++++++++++++++ test/ex_saml/metadata_test.exs | 164 +++++++++++ .../signature_missing_signed_info.xml | 13 + .../metadata/signature_unknown_algorithm.xml | 24 ++ .../metadata/signature_unknown_digest.xml | 24 ++ 6 files changed, 491 insertions(+), 1 deletion(-) create mode 100644 lib/ex_saml/metadata/signature.ex create mode 100644 test/fixtures/metadata/signature_missing_signed_info.xml create mode 100644 test/fixtures/metadata/signature_unknown_algorithm.xml create mode 100644 test/fixtures/metadata/signature_unknown_digest.xml diff --git a/lib/ex_saml/metadata.ex b/lib/ex_saml/metadata.ex index 14b558a..045e6e8 100644 --- a/lib/ex_saml/metadata.ex +++ b/lib/ex_saml/metadata.ex @@ -33,6 +33,7 @@ defmodule ExSaml.Metadata do """ alias ExSaml.Metadata.Certificate + alias ExSaml.Metadata.Signature alias ExSaml.Metadata.ValidationResult require Record @@ -176,7 +177,8 @@ defmodule ExSaml.Metadata do defp check_entity_descriptor(root) do entity_id_violations(root) ++ descriptor_violations(root) ++ - Certificate.violations(root, @namespaces) + Certificate.violations(root, @namespaces) ++ + Signature.violations(root, @namespaces) end defp entity_id_violations(root) do diff --git a/lib/ex_saml/metadata/signature.ex b/lib/ex_saml/metadata/signature.ex new file mode 100644 index 0000000..48a028f --- /dev/null +++ b/lib/ex_saml/metadata/signature.ex @@ -0,0 +1,263 @@ +defmodule ExSaml.Metadata.Signature do + @moduledoc false + + # Structural validation of `` elements declared inside SAML + # metadata. Emits three always-error violations: + # + # * `:invalid_signature_structure` — a `` is present but + # missing one of the mandatory XML-DSig children (`SignedInfo`, + # `SignatureValue`, `SignedInfo/SignatureMethod`, `SignedInfo/Reference`, + # `Reference/DigestMethod`, `Reference/DigestValue`). + # * `:unknown_signature_algorithm` — `` is + # not in the XML-DSig / xmldsig-more spec-defined set. Deprecated but + # spec-defined values (RSA-SHA1, etc.) are still recognized here; the + # dedicated `:deprecated_signature_algorithm` rule ships with PR 3 and + # its strict-mode promotion. + # * `:unknown_digest_algorithm` — same idea for ``. + # + # Cryptographic verification of the signature against a trust anchor is + # out of scope (see issue #17 "Non-goals"). + + alias ExSaml.Metadata.ValidationResult + + require Record + + Record.defrecordp( + :xml_element, + :xmlElement, + Record.extract(:xmlElement, from_lib: "xmerl/include/xmerl.hrl") + ) + + Record.defrecordp( + :xml_attribute, + :xmlAttribute, + Record.extract(:xmlAttribute, from_lib: "xmerl/include/xmerl.hrl") + ) + + # Spec-defined XML-DSig signature algorithm URIs (RFC 6931, RFC 4051, + # XML-DSig §6.4). Recognised by PR 2 regardless of cryptographic strength — + # PR 3 introduces the modern-only subset for :deprecated_signature_algorithm. + @known_signature_algorithms MapSet.new([ + "http://www.w3.org/2000/09/xmldsig#dsa-sha1", + "http://www.w3.org/2000/09/xmldsig#rsa-sha1", + "http://www.w3.org/2000/09/xmldsig#hmac-sha1", + "http://www.w3.org/2001/04/xmldsig-more#rsa-md5", + "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384", + "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512", + "http://www.w3.org/2001/04/xmldsig-more#hmac-sha256", + "http://www.w3.org/2001/04/xmldsig-more#hmac-sha384", + "http://www.w3.org/2001/04/xmldsig-more#hmac-sha512", + "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" + ]) + + @known_digest_algorithms MapSet.new([ + "http://www.w3.org/2000/09/xmldsig#sha1", + "http://www.w3.org/2001/04/xmldsig-more#md5", + "http://www.w3.org/2001/04/xmlenc#sha256", + "http://www.w3.org/2001/04/xmldsig-more#sha384", + "http://www.w3.org/2001/04/xmlenc#sha512", + "http://www.w3.org/2001/04/xmlenc#ripemd160" + ]) + + @spec violations(:xmerl.xmlElement(), keyword()) :: [ValidationResult.violation()] + def violations(root, namespaces) do + root + |> signatures_with_path(namespaces) + |> Enum.flat_map(fn {sig, path} -> signature_violations(sig, path, namespaces) end) + end + + # --------------------------------------------------------------------------- + # Signature discovery + # --------------------------------------------------------------------------- + + defp signatures_with_path(root, namespaces) do + document_level = collect("/EntityDescriptor", root, "./ds:Signature", namespaces) + + descriptor_level = + ["SPSSODescriptor", "IDPSSODescriptor"] + |> Enum.flat_map(fn tag -> + root + |> xpath_elems("./md:#{tag}", namespaces) + |> Enum.flat_map(fn desc -> + collect("/EntityDescriptor/#{tag}", desc, "./ds:Signature", namespaces) + end) + end) + + document_level ++ descriptor_level + end + + defp collect(parent_path, context, xpath, namespaces) do + sigs = xpath_elems(context, xpath, namespaces) + total = length(sigs) + + sigs + |> Enum.with_index(1) + |> Enum.map(fn {sig, idx} -> + index_segment = if total > 1, do: "[#{idx}]", else: "" + {sig, "#{parent_path}/Signature#{index_segment}"} + end) + end + + # --------------------------------------------------------------------------- + # Per-signature rules + # --------------------------------------------------------------------------- + + defp signature_violations(sig, sig_path, namespaces) do + case xpath_elems(sig, "./ds:SignedInfo", namespaces) do + [] -> + [missing_node(sig_path, "SignedInfo")] + + [signed_info | _] -> + signature_value_violations(sig, sig_path, namespaces) ++ + signed_info_violations(signed_info, sig_path, namespaces) + end + end + + defp signature_value_violations(sig, sig_path, namespaces) do + case xpath_elems(sig, "./ds:SignatureValue", namespaces) do + [] -> [missing_node(sig_path, "SignatureValue")] + _ -> [] + end + end + + defp signed_info_violations(signed_info, sig_path, namespaces) do + signature_method_violations(signed_info, sig_path, namespaces) ++ + reference_violations(signed_info, sig_path, namespaces) + end + + defp signature_method_violations(signed_info, sig_path, namespaces) do + case xpath_elems(signed_info, "./ds:SignatureMethod", namespaces) do + [] -> + [missing_node(sig_path, "SignedInfo/SignatureMethod")] + + [method | _] -> + algorithm_violation( + method, + "#{sig_path}/SignedInfo/SignatureMethod/@Algorithm", + @known_signature_algorithms, + :unknown_signature_algorithm, + "SignatureMethod" + ) + end + end + + defp reference_violations(signed_info, sig_path, namespaces) do + case xpath_elems(signed_info, "./ds:Reference", namespaces) do + [] -> + [missing_node(sig_path, "SignedInfo/Reference")] + + refs -> + refs + |> Enum.with_index(1) + |> Enum.flat_map(fn {ref, idx} -> + ref_path = "#{sig_path}/SignedInfo/Reference[#{idx}]" + digest_method_violations(ref, ref_path, namespaces) ++ + digest_value_violations(ref, ref_path, namespaces) + end) + end + end + + defp digest_method_violations(ref, ref_path, namespaces) do + case xpath_elems(ref, "./ds:DigestMethod", namespaces) do + [] -> + [missing_signature_node(ref_path <> "/DigestMethod")] + + [method | _] -> + algorithm_violation( + method, + "#{ref_path}/DigestMethod/@Algorithm", + @known_digest_algorithms, + :unknown_digest_algorithm, + "DigestMethod" + ) + end + end + + defp digest_value_violations(ref, ref_path, namespaces) do + case xpath_elems(ref, "./ds:DigestValue", namespaces) do + [] -> [missing_signature_node(ref_path <> "/DigestValue")] + _ -> [] + end + end + + defp algorithm_violation(element, path, known, code, kind) do + case attr_value(element, "Algorithm") do + nil -> + [ + %{ + code: :invalid_signature_structure, + severity: :error, + message: " is missing the required Algorithm attribute", + path: path, + spec_reference: "XML-DSig §4.3" + } + ] + + value -> + if value in known do + [] + else + [ + %{ + code: code, + severity: :error, + message: "Unknown #{kind} algorithm URI: " <> inspect(value), + path: path, + spec_reference: "XML-DSig §6.4, RFC 6931" + } + ] + end + end + end + + # --------------------------------------------------------------------------- + # Violation builders + # --------------------------------------------------------------------------- + + defp missing_node(sig_path, child) do + %{ + code: :invalid_signature_structure, + severity: :error, + message: " is missing required child ", + path: "#{sig_path}/#{child}", + spec_reference: "XML-DSig §4.3" + } + end + + defp missing_signature_node(path) do + %{ + code: :invalid_signature_structure, + severity: :error, + message: " is missing required descendant " <> path, + path: path, + spec_reference: "XML-DSig §4.3" + } + end + + # --------------------------------------------------------------------------- + # XPath helpers + # --------------------------------------------------------------------------- + + defp xpath_elems(context, path, namespaces) do + :xmerl_xpath.string(to_charlist(path), context, [{:namespace, namespaces}]) + end + + defp attr_value(element, name) do + case xpath_elems(element, "@#{name}", []) do + [attr] -> + if Record.is_record(attr, :xmlAttribute) do + attr |> xml_attribute(:value) |> to_string() + else + nil + end + + _ -> + nil + end + end +end diff --git a/test/ex_saml/metadata_test.exs b/test/ex_saml/metadata_test.exs index b55d2c7..acbeef6 100644 --- a/test/ex_saml/metadata_test.exs +++ b/test/ex_saml/metadata_test.exs @@ -326,4 +326,168 @@ defmodule ExSaml.MetadataTest do Metadata.validate(read_fixture("idp_clean.xml")) end end + + describe "ds:Signature structural rules" do + @valid_sig """ + + + + + + + + + + YWJjZGVm + + + YmFzZTY0c2lnbmF0dXJl + + """ + + defp sp_metadata_with_signature(signature_xml) do + """ + + + #{signature_xml} + + + + + """ + end + + test "accepts metadata with a structurally valid " do + xml = sp_metadata_with_signature(@valid_sig) + + assert {:ok, %ValidationResult{errors: [], warnings: []}} = Metadata.validate(xml) + end + + test "flags without " do + xml = read_fixture("signature_missing_signed_info.xml") + + assert {:error, %ValidationResult{errors: errors}} = Metadata.validate(xml) + assert :invalid_signature_structure in codes(errors) + + err = Enum.find(errors, &(&1.code == :invalid_signature_structure)) + assert err.path =~ "SignedInfo" + end + + test "flags without " do + sig = """ + + + + + + + YWJjZGVm + + + + """ + + xml = sp_metadata_with_signature(sig) + + assert {:error, %ValidationResult{errors: errors}} = Metadata.validate(xml) + assert :invalid_signature_structure in codes(errors) + assert Enum.any?(errors, &(&1.path =~ "SignatureValue")) + end + + test "flags a missing " do + sig = """ + + + + + + + + + YmFzZTY0 + + """ + + xml = sp_metadata_with_signature(sig) + + assert {:error, %ValidationResult{errors: errors}} = Metadata.validate(xml) + + err = + Enum.find(errors, fn e -> + e.code == :invalid_signature_structure and e.path =~ "Reference[1]/DigestValue" + end) + + assert err + end + + test "flags an unknown SignatureMethod algorithm" do + xml = read_fixture("signature_unknown_algorithm.xml") + + assert {:error, %ValidationResult{errors: errors}} = Metadata.validate(xml) + assert :unknown_signature_algorithm in codes(errors) + + err = Enum.find(errors, &(&1.code == :unknown_signature_algorithm)) + assert err.path =~ "SignatureMethod/@Algorithm" + end + + test "flags an unknown DigestMethod algorithm" do + xml = read_fixture("signature_unknown_digest.xml") + + assert {:error, %ValidationResult{errors: errors}} = Metadata.validate(xml) + assert :unknown_digest_algorithm in codes(errors) + + err = Enum.find(errors, &(&1.code == :unknown_digest_algorithm)) + assert err.path =~ "Reference[1]/DigestMethod/@Algorithm" + end + + test "recognises RSA-SHA1 as a known (legacy) signature algorithm" do + sig = + String.replace( + @valid_sig, + "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + "http://www.w3.org/2000/09/xmldsig#rsa-sha1" + ) + + xml = sp_metadata_with_signature(sig) + + # PR 2 only checks "known" — RSA-SHA1 stays valid here. + # PR 3 introduces :deprecated_signature_algorithm to flag it. + assert {:ok, %ValidationResult{errors: [], warnings: []}} = Metadata.validate(xml) + end + + test ":ignore silences :unknown_signature_algorithm" do + xml = read_fixture("signature_unknown_algorithm.xml") + + assert {:ok, %ValidationResult{errors: [], warnings: []}} = + Metadata.validate(xml, ignore: [:unknown_signature_algorithm]) + end + + test "detects a nested inside a descriptor" do + xml = """ + + + + + YmFzZTY0 + + + + + """ + + assert {:error, %ValidationResult{errors: errors}} = Metadata.validate(xml) + + err = Enum.find(errors, &(&1.code == :invalid_signature_structure)) + assert err + assert err.path =~ "SPSSODescriptor/Signature/SignedInfo" + end + end end diff --git a/test/fixtures/metadata/signature_missing_signed_info.xml b/test/fixtures/metadata/signature_missing_signed_info.xml new file mode 100644 index 0000000..62b4bbd --- /dev/null +++ b/test/fixtures/metadata/signature_missing_signed_info.xml @@ -0,0 +1,13 @@ + + + + YmFzZTY0c2lnbmF0dXJl + + + + + diff --git a/test/fixtures/metadata/signature_unknown_algorithm.xml b/test/fixtures/metadata/signature_unknown_algorithm.xml new file mode 100644 index 0000000..db94135 --- /dev/null +++ b/test/fixtures/metadata/signature_unknown_algorithm.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + YWJjZGVm + + + YmFzZTY0c2lnbmF0dXJl + + + + + diff --git a/test/fixtures/metadata/signature_unknown_digest.xml b/test/fixtures/metadata/signature_unknown_digest.xml new file mode 100644 index 0000000..91f6660 --- /dev/null +++ b/test/fixtures/metadata/signature_unknown_digest.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + YWJjZGVm + + + YmFzZTY0c2lnbmF0dXJl + + + + + From acf988be2fdd87ed7d312cc6bf5e9afb30959c78 Mon Sep 17 00:00:00 2001 From: docJerem Date: Tue, 19 May 2026 14:48:32 +0200 Subject: [PATCH 4/7] =?UTF-8?q?=F0=9F=9A=A7=20Chore=20/=20Metadata=20/=20A?= =?UTF-8?q?ddress=20credo=20--strict=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Extract nested closures in Certificate so every function body stays within the credo max-nesting threshold (max depth 2). * Alias X509.Certificate / Certificate.Extension / Certificate.Validity / PrivateKey at the top of CertFactory rather than referencing them by fully-qualified module path inside function bodies. No behavioural change — pure refactor. mix audit (which runs credo --strict) now reports no findings on the touched files. --- lib/ex_saml/metadata/certificate.ex | 47 ++++++++++++++++------------- lib/ex_saml/metadata/signature.ex | 1 + test/support/cert_factory.ex | 32 +++++++++++--------- 3 files changed, 44 insertions(+), 36 deletions(-) diff --git a/lib/ex_saml/metadata/certificate.ex b/lib/ex_saml/metadata/certificate.ex index 2a74236..272b1bb 100644 --- a/lib/ex_saml/metadata/certificate.ex +++ b/lib/ex_saml/metadata/certificate.ex @@ -53,17 +53,21 @@ defmodule ExSaml.Metadata.Certificate do root |> xpath_elems("./md:#{descriptor_tag}", namespaces) - |> Enum.flat_map(fn desc -> - kds = xpath_elems(desc, "./md:KeyDescriptor", namespaces) - total = length(kds) - - kds - |> Enum.with_index(1) - |> Enum.map(fn {kd, idx} -> - index_segment = if total > 1, do: "[#{idx}]", else: "" - {kd, "/EntityDescriptor/#{descriptor_tag}/KeyDescriptor#{index_segment}"} - end) - end) + |> Enum.flat_map(&kds_under_descriptor(&1, descriptor_tag, namespaces)) + end + + defp kds_under_descriptor(desc, descriptor_tag, namespaces) do + kds = xpath_elems(desc, "./md:KeyDescriptor", namespaces) + total = length(kds) + + kds + |> Enum.with_index(1) + |> Enum.map(&kd_with_path(&1, descriptor_tag, total)) + end + + defp kd_with_path({kd, idx}, descriptor_tag, total) do + index_segment = if total > 1, do: "[#{idx}]", else: "" + {kd, "/EntityDescriptor/#{descriptor_tag}/KeyDescriptor#{index_segment}"} end defp descriptor_tag(:sp), do: "SPSSODescriptor" @@ -85,14 +89,16 @@ defmodule ExSaml.Metadata.Certificate do list |> Enum.with_index(1) - |> Enum.flat_map(fn {cert_elem, idx} -> - cert_path = cert_path(kd_path, idx, total) - - case String.trim(text_content(cert_elem)) do - "" -> [missing_cert_violation(kd_path)] - text -> validate_cert(text, cert_path) - end - end) + |> Enum.flat_map(&cert_elem_violations(&1, kd_path, total)) + end + end + + defp cert_elem_violations({cert_elem, idx}, kd_path, total) do + cert_path = cert_path(kd_path, idx, total) + + case String.trim(text_content(cert_elem)) do + "" -> [missing_cert_violation(kd_path)] + text -> validate_cert(text, cert_path) end end @@ -155,8 +161,7 @@ defmodule ExSaml.Metadata.Certificate do defp parse_asn1_time({:utcTime, charlist}) do case to_string(charlist) do - <> -> + <> -> yy_int = String.to_integer(yy) year = if yy_int >= 50, do: 1900 + yy_int, else: 2000 + yy_int to_datetime(year, mm, dd, hh, mi, ss) diff --git a/lib/ex_saml/metadata/signature.ex b/lib/ex_saml/metadata/signature.ex index 48a028f..fea3349 100644 --- a/lib/ex_saml/metadata/signature.ex +++ b/lib/ex_saml/metadata/signature.ex @@ -156,6 +156,7 @@ defmodule ExSaml.Metadata.Signature do |> Enum.with_index(1) |> Enum.flat_map(fn {ref, idx} -> ref_path = "#{sig_path}/SignedInfo/Reference[#{idx}]" + digest_method_violations(ref, ref_path, namespaces) ++ digest_value_violations(ref, ref_path, namespaces) end) diff --git a/test/support/cert_factory.ex b/test/support/cert_factory.ex index e79e71b..5d81e81 100644 --- a/test/support/cert_factory.ex +++ b/test/support/cert_factory.ex @@ -14,6 +14,11 @@ defmodule ExSaml.Test.CertFactory do * `:b64` — base64 DER, suitable for embedding in `` """ + alias X509.Certificate + alias X509.Certificate.Extension + alias X509.Certificate.Validity + alias X509.PrivateKey + @type cert :: %{der: binary(), pem: binary(), b64: binary()} @default_subject "/CN=ex_saml-test" @@ -28,18 +33,18 @@ defmodule ExSaml.Test.CertFactory do extensions = Keyword.get(opts, :extensions, []) validity = validity_from(opts) - key = X509.PrivateKey.new_rsa(key_size) + key = PrivateKey.new_rsa(key_size) cert = - X509.Certificate.self_signed(key, subject, + Certificate.self_signed(key, subject, template: template, validity: validity, extensions: extensions, hash: :sha256 ) - der = X509.Certificate.to_der(cert) - pem = X509.Certificate.to_pem(cert) + der = Certificate.to_der(cert) + pem = Certificate.to_pem(cert) %{der: der, pem: pem, b64: Base.encode64(der)} end @@ -56,9 +61,8 @@ defmodule ExSaml.Test.CertFactory do [ template: :server, extensions: [ - basic_constraints: X509.Certificate.Extension.basic_constraints(false), - key_usage: - X509.Certificate.Extension.key_usage([:digitalSignature, :keyEncipherment]) + basic_constraints: Extension.basic_constraints(false), + key_usage: Extension.key_usage([:digitalSignature, :keyEncipherment]) ] ], opts @@ -74,8 +78,8 @@ defmodule ExSaml.Test.CertFactory do [ template: :server, extensions: [ - basic_constraints: X509.Certificate.Extension.basic_constraints(false), - key_usage: X509.Certificate.Extension.key_usage([:keyEncipherment]) + basic_constraints: Extension.basic_constraints(false), + key_usage: Extension.key_usage([:keyEncipherment]) ] ], opts @@ -91,9 +95,9 @@ defmodule ExSaml.Test.CertFactory do [ template: :root_ca, extensions: [ - basic_constraints: X509.Certificate.Extension.basic_constraints(true), + basic_constraints: Extension.basic_constraints(true), key_usage: - X509.Certificate.Extension.key_usage([ + Extension.key_usage([ :digitalSignature, :keyCertSign, :cRLSign @@ -121,15 +125,13 @@ defmodule ExSaml.Test.CertFactory do v :error -> - X509.Certificate.Validity.days_from_now( - Keyword.get(opts, :validity_days, @default_validity_days) - ) + Validity.days_from_now(Keyword.get(opts, :validity_days, @default_validity_days)) end end defp expired_validity do not_before = DateTime.add(DateTime.utc_now(), -30 * 86_400, :second) not_after = DateTime.add(DateTime.utc_now(), -1 * 86_400, :second) - X509.Certificate.Validity.new(not_before, not_after) + Validity.new(not_before, not_after) end end From 85c667e01a65025487891944f1d80114df2be9f1 Mon Sep 17 00:00:00 2001 From: docJerem Date: Tue, 19 May 2026 14:48:38 +0200 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=93=9D=20Docs=20/=20Metadata=20/=20Li?= =?UTF-8?q?st=20new=20violation=20codes=20in=20@moduledoc=20and=20CHANGELO?= =?UTF-8?q?G?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the six new codes emitted by validate/2 — three certificate rules and three ds:Signature structure rules — and add an Unreleased section to CHANGELOG.md. Refs #17 --- CHANGELOG.md | 5 +++++ lib/ex_saml/metadata.ex | 36 +++++++++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 232b248..47b47bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `ExSaml.Metadata.validate/2` now reports certificate-level violations on every ``: `:missing_x509_certificate`, `:invalid_x509_certificate`, `:certificate_expired` (the last is silence-able via `:ignore`) (#17) +- `ExSaml.Metadata.validate/2` now reports XML-DSig structure violations on every `` declared in metadata: `:invalid_signature_structure`, `:unknown_signature_algorithm`, `:unknown_digest_algorithm`. Cryptographic verification remains out of scope (#17) + ## [1.1.0] - 2026-05-06 ### Added diff --git a/lib/ex_saml/metadata.ex b/lib/ex_saml/metadata.ex index 045e6e8..bcaf1c6 100644 --- a/lib/ex_saml/metadata.ex +++ b/lib/ex_saml/metadata.ex @@ -10,7 +10,41 @@ defmodule ExSaml.Metadata do This module currently implements **spec-conformance rules only** — every finding is an `:error`. Best-practice rules (warnings, strict mode, - certificate linting) are added in subsequent releases. + domain-mismatch lint) are added in subsequent releases. + + ## Violation codes emitted by this version + + Structural rules (root, descriptors, endpoints): + + * `:invalid_xml` + * `:invalid_root_element` + * `:entities_descriptor_not_supported` + * `:missing_entity_id` + * `:entity_id_too_long` + * `:missing_role_descriptor` + * `:missing_saml2_protocol_support` + * `:missing_acs` + * `:missing_sso_service` + * `:invalid_acs_binding` + * `:missing_http_post_acs` + * `:duplicate_acs_index` + * `:multiple_default_acs` + * `:invalid_slo_binding` + + Certificate rules (every ``): + + * `:missing_x509_certificate` + * `:invalid_x509_certificate` + * `:certificate_expired` — silence-able via `:ignore` for legacy scenarios + + Signature-structure rules (every `` declared in metadata): + + * `:invalid_signature_structure` + * `:unknown_signature_algorithm` + * `:unknown_digest_algorithm` + + Cryptographic verification of `` against a trust anchor is + intentionally out of scope. ## Example From 6d3dc521c59d44d70ac65dc48a8a2728c26a4808 Mon Sep 17 00:00:00 2001 From: docJerem Date: Tue, 19 May 2026 15:26:27 +0200 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=93=9D=20Docs=20/=20Metadata=20/=20Co?= =?UTF-8?q?nvert=20Certificate=20and=20Signature=20module=20headers=20to?= =?UTF-8?q?=20@moduledoc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the leading `# ...` comment blocks (paired with @moduledoc false) with real triple-quoted @moduledoc strings, so the rationale and list of emitted codes show up in ExDoc instead of staying buried in plain source comments. No behavioural change. --- lib/ex_saml/metadata/certificate.ex | 38 ++++++++++++++--------------- lib/ex_saml/metadata/signature.ex | 36 +++++++++++++-------------- 2 files changed, 37 insertions(+), 37 deletions(-) diff --git a/lib/ex_saml/metadata/certificate.ex b/lib/ex_saml/metadata/certificate.ex index 272b1bb..84f6840 100644 --- a/lib/ex_saml/metadata/certificate.ex +++ b/lib/ex_saml/metadata/certificate.ex @@ -1,23 +1,23 @@ defmodule ExSaml.Metadata.Certificate do - @moduledoc false - - # Certificate-level metadata validation rules. - # - # Iterates every `` declared under an `` - # or `` and emits violations for the structural and - # validity expectations of PR 2: - # - # * `:missing_x509_certificate` — KeyDescriptor without a non-empty - # `` text node. - # * `:invalid_x509_certificate` — text that does not decode as base64 DER - # or whose ASN.1 structure cannot be parsed by `:public_key`. - # * `:certificate_expired` — parseable certificate whose `notAfter` is in - # the past relative to `DateTime.utc_now/0`. Silence-able via - # `ExSaml.Metadata.validate/2`'s `:ignore` option. - # - # Best-practice rules that depend on certificate inspection (CA detection, - # KeyUsage linting, shared signing/encryption certificate) live in the next - # batch of rules alongside strict-mode plumbing — see issue #17 PR 3. + @moduledoc """ + Certificate-level metadata validation rules. + + Iterates every `` declared under an `` + or `` and emits violations for the structural and + validity expectations of PR 2: + + * `:missing_x509_certificate` — KeyDescriptor without a non-empty + `` text node. + * `:invalid_x509_certificate` — text that does not decode as base64 DER + or whose ASN.1 structure cannot be parsed by `:public_key`. + * `:certificate_expired` — parseable certificate whose `notAfter` is in + the past relative to `DateTime.utc_now/0`. Silence-able via + `ExSaml.Metadata.validate/2`'s `:ignore` option. + + Best-practice rules that depend on certificate inspection (CA detection, + KeyUsage linting, shared signing/encryption certificate) live in the next + batch of rules alongside strict-mode plumbing — see issue #17 PR 3. + """ alias ExSaml.Metadata.ValidationResult diff --git a/lib/ex_saml/metadata/signature.ex b/lib/ex_saml/metadata/signature.ex index fea3349..bc2d33b 100644 --- a/lib/ex_saml/metadata/signature.ex +++ b/lib/ex_saml/metadata/signature.ex @@ -1,22 +1,22 @@ defmodule ExSaml.Metadata.Signature do - @moduledoc false - - # Structural validation of `` elements declared inside SAML - # metadata. Emits three always-error violations: - # - # * `:invalid_signature_structure` — a `` is present but - # missing one of the mandatory XML-DSig children (`SignedInfo`, - # `SignatureValue`, `SignedInfo/SignatureMethod`, `SignedInfo/Reference`, - # `Reference/DigestMethod`, `Reference/DigestValue`). - # * `:unknown_signature_algorithm` — `` is - # not in the XML-DSig / xmldsig-more spec-defined set. Deprecated but - # spec-defined values (RSA-SHA1, etc.) are still recognized here; the - # dedicated `:deprecated_signature_algorithm` rule ships with PR 3 and - # its strict-mode promotion. - # * `:unknown_digest_algorithm` — same idea for ``. - # - # Cryptographic verification of the signature against a trust anchor is - # out of scope (see issue #17 "Non-goals"). + @moduledoc """ + Structural validation of `` elements declared inside SAML + metadata. Emits three always-error violations: + + * `:invalid_signature_structure` — a `` is present but + missing one of the mandatory XML-DSig children (`SignedInfo`, + `SignatureValue`, `SignedInfo/SignatureMethod`, `SignedInfo/Reference`, + `Reference/DigestMethod`, `Reference/DigestValue`). + * `:unknown_signature_algorithm` — `` is + not in the XML-DSig / xmldsig-more spec-defined set. Deprecated but + spec-defined values (RSA-SHA1, etc.) are still recognized here; the + dedicated `:deprecated_signature_algorithm` rule ships with PR 3 and + its strict-mode promotion. + * `:unknown_digest_algorithm` — same idea for ``. + + Cryptographic verification of the signature against a trust anchor is + out of scope (see issue #17 "Non-goals"). + """ alias ExSaml.Metadata.ValidationResult From 10db38be8e82beb802874d0ed65fdd3d1a525a9e Mon Sep 17 00:00:00 2001 From: docJerem Date: Tue, 19 May 2026 16:02:26 +0200 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=90=9B=20Fix=20/=20Metadata=20/=20Dro?= =?UTF-8?q?p=20overly-narrow=20@spec=20on=20Certificate.violations/2=20and?= =?UTF-8?q?=20Signature.violations/2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dialyzer cannot narrow the root element through the `if Record.is_record(root, :xmlElement)` guard in `parse/1` across function boundaries, so the call site in `check_entity_descriptor/1` passes the broader xmerl record union type. That widened type violated the `:xmerl.xmlElement()` contract on `violations/2`, producing `:no_return` on `check_entity_descriptor/1` and breaking `mix dialyzer`. The existing convention in `ExSaml.Metadata` is to leave internal helpers untyped (`parse/1`, `run_rules/1`, `classify_root/1`, …), so the two new helpers now follow the same convention. No new `.dialyzer_ignore.exs` entry is added. Refs #17 --- lib/ex_saml/metadata/certificate.ex | 3 --- lib/ex_saml/metadata/signature.ex | 3 --- 2 files changed, 6 deletions(-) diff --git a/lib/ex_saml/metadata/certificate.ex b/lib/ex_saml/metadata/certificate.ex index 84f6840..c7c3634 100644 --- a/lib/ex_saml/metadata/certificate.ex +++ b/lib/ex_saml/metadata/certificate.ex @@ -19,8 +19,6 @@ defmodule ExSaml.Metadata.Certificate do batch of rules alongside strict-mode plumbing — see issue #17 PR 3. """ - alias ExSaml.Metadata.ValidationResult - require Record Record.defrecordp( @@ -37,7 +35,6 @@ defmodule ExSaml.Metadata.Certificate do @descriptor_kinds [:sp, :idp] - @spec violations(:xmerl.xmlElement(), keyword()) :: [ValidationResult.violation()] def violations(root, namespaces) do @descriptor_kinds |> Enum.flat_map(&key_descriptors_with_path(root, &1, namespaces)) diff --git a/lib/ex_saml/metadata/signature.ex b/lib/ex_saml/metadata/signature.ex index bc2d33b..5935705 100644 --- a/lib/ex_saml/metadata/signature.ex +++ b/lib/ex_saml/metadata/signature.ex @@ -18,8 +18,6 @@ defmodule ExSaml.Metadata.Signature do out of scope (see issue #17 "Non-goals"). """ - alias ExSaml.Metadata.ValidationResult - require Record Record.defrecordp( @@ -64,7 +62,6 @@ defmodule ExSaml.Metadata.Signature do "http://www.w3.org/2001/04/xmlenc#ripemd160" ]) - @spec violations(:xmerl.xmlElement(), keyword()) :: [ValidationResult.violation()] def violations(root, namespaces) do root |> signatures_with_path(namespaces)