Skip to content
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<md:KeyDescriptor>`: `: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 `<ds:Signature>` 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
Expand Down
43 changes: 41 additions & 2 deletions lib/ex_saml/metadata.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<md:KeyDescriptor>`):

* `:missing_x509_certificate`
* `:invalid_x509_certificate`
* `:certificate_expired` — silence-able via `:ignore` for legacy scenarios

Signature-structure rules (every `<ds:Signature>` declared in metadata):

* `:invalid_signature_structure`
* `:unknown_signature_algorithm`
* `:unknown_digest_algorithm`

Cryptographic verification of `<ds:Signature>` against a trust anchor is
intentionally out of scope.

## Example

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
251 changes: 251 additions & 0 deletions lib/ex_saml/metadata/certificate.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
defmodule ExSaml.Metadata.Certificate do
@moduledoc """
Certificate-level metadata validation rules.

Iterates every `<md:KeyDescriptor>` declared under an `<md:SPSSODescriptor>`
or `<md:IDPSSODescriptor>` and emits violations for the structural and
validity expectations of PR 2:

* `:missing_x509_certificate` — KeyDescriptor without a non-empty
`<ds:X509Certificate>` 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::binary-2, mm::binary-2, dd::binary-2, hh::binary-2, mi::binary-2, ss::binary-2, "Z">> ->
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
<<year::binary-4, mm::binary-2, dd::binary-2, hh::binary-2, mi::binary-2, ss::binary-2,
"Z">> ->
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: "<md:KeyDescriptor> must contain a non-empty <ds:X509Certificate>",
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: "<ds:X509Certificate> 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
Loading
Loading