Relyra.Metadata.TrustAnchor (relyra v1.5.4)

Copy Markdown View Source

Operator-pinned trust-anchor check for Phase 21 scheduled metadata refresh per D-17.

The trust anchor is a list of SHA-256 hex fingerprints (lowercase) the operator pinned out-of-band before enabling scheduled refresh on a MetadataSource. This module checks that AT LEAST ONE candidate certificate (PEM) presented in the freshly-fetched metadata matches one of those pinned fingerprints.

Why no TOFU: the first fetch is the moment of maximum MITM exposure; institutionalizing "trust on first fetch" hands an attacker a one-shot window. (ruby-saml CVE-2024-45409 lesson — locked rejection per D-17.)

Why no reuse-of-assertion-cert: the SAML metadata signing key and the assertion signing key are spec-separate roles; conflating them breaks any IdP that follows the spec. The metadata trust anchor MUST be its own pinned set, populated via the admin LiveView pinning UX (D-22) or the mix relyra.metadata.pin task.

Pure: no I/O, no Ecto, no Repo. fingerprint/1 is the canonical trust-fingerprint compute for the metadata subsystem (DER SHA-256); the certificate-fingerprint compute in lib/relyra/metadata/import.ex and the drift detector route through it so the pin, the admin-displayed fingerprint, the drift detector, and openssl x509 -outform DER | openssl dgst -sha256 all agree (CR-02).

Summary

Functions

Returns :ok if at least one PEM in candidate_pems matches a pinned fingerprint, {:error, %Relyra.Error{type: :trust_anchor_mismatch}} otherwise (including the empty-pinned-list case).

Computes the canonical trust fingerprint for a PEM: the SHA-256 of the certificate's DER bytes (lowercase hex, no colons). This matches the operator-facing mix relyra.metadata.pin task and the standard openssl x509 -outform DER | openssl dgst -sha256 recipe (CR-02). Hashing the PEM text (the prior behavior) could never match an operator-supplied DER fingerprint, so the pin either never matched (fail-closed DoS) or, once "fixed" to the PEM-text hash, decoupled the pin from the verified cert.

Returns {:ok, matched_pems} — the subset of candidate_pems whose DER fingerprint is present in pinned_fingerprints — or {:error, %Relyra.Error{type: :trust_anchor_mismatch}} when none match (or no fingerprints are pinned).

Functions

check(candidate_pems, pinned_fingerprints)

@spec check([String.t()], [String.t()]) :: :ok | {:error, Relyra.Error.t()}

Returns :ok if at least one PEM in candidate_pems matches a pinned fingerprint, {:error, %Relyra.Error{type: :trust_anchor_mismatch}} otherwise (including the empty-pinned-list case).

Thin boolean view of matching_pems/2. Prefer matching_pems/2 on the verification path so the signature is checked against ONLY the pinned cert(s) (CR-01).

fingerprint(pem)

@spec fingerprint(String.t()) :: String.t()

Computes the canonical trust fingerprint for a PEM: the SHA-256 of the certificate's DER bytes (lowercase hex, no colons). This matches the operator-facing mix relyra.metadata.pin task and the standard openssl x509 -outform DER | openssl dgst -sha256 recipe (CR-02). Hashing the PEM text (the prior behavior) could never match an operator-supplied DER fingerprint, so the pin either never matched (fail-closed DoS) or, once "fixed" to the PEM-text hash, decoupled the pin from the verified cert.

Returns the @uncomputable_fingerprint sentinel for input that cannot be decoded to a certificate (fail-closed: junk never matches a pin).

matching_pems(candidate_pems, pinned_fingerprints)

@spec matching_pems([String.t()], [String.t()]) ::
  {:ok, [String.t()]} | {:error, Relyra.Error.t()}

Returns {:ok, matched_pems} — the subset of candidate_pems whose DER fingerprint is present in pinned_fingerprints — or {:error, %Relyra.Error{type: :trust_anchor_mismatch}} when none match (or no fingerprints are pinned).

CR-01: the caller MUST verify the metadata signature against ONLY these matched (operator-pinned) PEMs — NEVER the full document-supplied set. The candidate certificate that satisfies the pin and the certificate whose key actually verifies the signature must be the SAME cert. Verifying against an unpinned, attacker-supplied cert (e.g. one prepended ahead of the legitimate, public cert) while the pin is satisfied by a different cert is an auth-bypass.