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 c83b1cb..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 @@ -32,6 +66,8 @@ defmodule ExSaml.Metadata do See `ExSaml.Metadata.ValidationResult` for the shape of the returned struct. """ + alias ExSaml.Metadata.Certificate + alias ExSaml.Metadata.Signature alias ExSaml.Metadata.ValidationResult require Record @@ -173,7 +209,10 @@ 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) ++ + Signature.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..c7c3634 --- /dev/null +++ b/lib/ex_saml/metadata/certificate.ex @@ -0,0 +1,251 @@ +defmodule ExSaml.Metadata.Certificate do + @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. + """ + + 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] + + 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(&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" + 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(&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 + + 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/lib/ex_saml/metadata/signature.ex b/lib/ex_saml/metadata/signature.ex new file mode 100644 index 0000000..5935705 --- /dev/null +++ b/lib/ex_saml/metadata/signature.ex @@ -0,0 +1,261 @@ +defmodule ExSaml.Metadata.Signature do + @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"). + """ + + 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" + ]) + + 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/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/ex_saml/metadata_test.exs b/test/ex_saml/metadata_test.exs index e8121f5..acbeef6 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,246 @@ 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 + + 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/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 @@ + + + + + + + + + + + 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 + + + + + diff --git a/test/support/cert_factory.ex b/test/support/cert_factory.ex new file mode 100644 index 0000000..5d81e81 --- /dev/null +++ b/test/support/cert_factory.ex @@ -0,0 +1,137 @@ +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 `` + """ + + 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" + @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 = PrivateKey.new_rsa(key_size) + + cert = + Certificate.self_signed(key, subject, + template: template, + validity: validity, + extensions: extensions, + hash: :sha256 + ) + + der = Certificate.to_der(cert) + pem = 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: Extension.basic_constraints(false), + key_usage: 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: Extension.basic_constraints(false), + key_usage: 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: Extension.basic_constraints(true), + key_usage: + 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 -> + 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) + Validity.new(not_before, not_after) + end +end