Skip to content

Commit 2e45689

Browse files
szTheoryclaude
andcommitted
feat(29-03): wire real XMLDSig crypto into the [candidate] arm (D-01)
Closes the published-hex SAML auth-bypass: the [candidate] arm of verified_signed_node now performs genuine cryptographic verification between selecting the single candidate and building %SignedNode{}. - Add :digest_mismatch + :unsupported_signature_algorithm to the xml_error_type union (xml.ex, D-08) - Thread cert_chain from do_verify/4 through verify_algorithms_and_candidates/4 + verified_signed_node/5 to the arm - public_key_from_cert_chain/1: fail-closed PEM->RSA pubkey via pem_decode + pkix_decode_cert(:otp) -> element(8) SPKI; try/rescue maps every malformed PEM/DER to :untrusted_certificate, never raises (D-04, Pitfall 3) - Read D-02 keys (:signed_info_node/:signature_value_b64/ :digest_value_b64/:node) off the RAW candidate map - Step order (all fail CLOSED, typed errors): digest-atom + ECDSA gate (D-06/D-07) -> pubkey (D-04) -> sig math (C14N.serialize SignedInfo + safe_verify :public_key.verify, D-03) -> digest recompute (:crypto.hash over canonicalize, length-guard before :crypto.hash_equals, D-05) - SignedInfo prefix_list read from its own InclusiveNamespaces (Open Q2), not a hardcoded default - Triage the one structure-only {:ok} binding test: a single crypto-input-less candidate now fails closed (:missing_signature); genuine positive lives in signature_crypto_test.exs mix test signature_crypto_test.exs signed_node_binding_test.exs --warnings-as-errors: 19/0; broader test/relyra/security test/security: 161/0; C14N golden lane: 102/0; compile --warnings-as-errors clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4c3c218 commit 2e45689

3 files changed

Lines changed: 237 additions & 19 deletions

File tree

lib/relyra/security/signature.ex

Lines changed: 222 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ defmodule Relyra.Security.Signature do
44
alias Relyra.Error
55
alias Relyra.Security.AlgorithmPolicy
66
alias Relyra.Security.SignedNode
7+
alias Relyra.Security.XML.C14N
8+
alias Relyra.Security.XML.PureBeam
79

810
@spec verify(map(), map(), [binary()], keyword()) :: {:ok, SignedNode.t()} | {:error, Error.t()}
911
def verify(parsed_doc, connection, cert_chain, opts \\ [])
@@ -130,11 +132,11 @@ defmodule Relyra.Security.Signature do
130132
)}
131133

132134
true ->
133-
verify_algorithms_and_candidates(parsed_doc, details, opts)
135+
verify_algorithms_and_candidates(parsed_doc, details, cert_chain, opts)
134136
end
135137
end
136138

137-
defp verify_algorithms_and_candidates(parsed_doc, details, opts) do
139+
defp verify_algorithms_and_candidates(parsed_doc, details, cert_chain, opts) do
138140
policy = Keyword.get(opts, :algorithm_policy, AlgorithmPolicy.default())
139141
signature_method = Map.get(parsed_doc, :signature_method)
140142
digest_method = Map.get(parsed_doc, :digest_method)
@@ -146,7 +148,7 @@ defmodule Relyra.Security.Signature do
146148
),
147149
:ok <-
148150
evaluate_policy(AlgorithmPolicy.enforce_digest_method(policy, digest_method), details) do
149-
verified_signed_node(parsed_doc, signature_method, digest_method, details)
151+
verified_signed_node(parsed_doc, signature_method, digest_method, cert_chain, details)
150152
end
151153
end
152154

@@ -156,7 +158,7 @@ defmodule Relyra.Security.Signature do
156158
{:error, merge_error_details(error, details)}
157159
end
158160

159-
defp verified_signed_node(parsed_doc, signature_method, digest_method, details) do
161+
defp verified_signed_node(parsed_doc, signature_method, digest_method, cert_chain, details) do
160162
signed_candidates = Map.get(parsed_doc, :signed_candidates, [])
161163

162164
case signed_candidates do
@@ -165,14 +167,24 @@ defmodule Relyra.Security.Signature do
165167
Error.new(:missing_signature, "No signed node candidates were verified", details)}
166168

167169
[candidate] ->
168-
{:ok,
169-
%SignedNode{
170-
xml_id: Map.get(candidate, :xml_id),
171-
xpath: Map.get(candidate, :xpath),
172-
signed_xml: Map.get(candidate, :signed_xml, ""),
173-
signature_method: signature_method,
174-
digest_method: digest_method
175-
}}
170+
# D-01: THE published-hex auth-bypass site. The crypto runs BETWEEN
171+
# matching the single [candidate] and building %SignedNode{}. All
172+
# pre-existing trust gates already ran in do_verify/4 (cert_chain present,
173+
# KeyInfo-trust rejection, duplicate-ID, algorithm allowlist) and the
174+
# single-candidate selection happened above — none of that is touched.
175+
# `candidate` is the RAW enriched map off parsed_doc[:signed_candidates]
176+
# (Plan 02 D-02), NOT a select_candidate handle.
177+
with :ok <-
178+
cryptographically_verify(candidate, signature_method, cert_chain, details) do
179+
{:ok,
180+
%SignedNode{
181+
xml_id: Map.get(candidate, :xml_id),
182+
xpath: Map.get(candidate, :xpath),
183+
signed_xml: Map.get(candidate, :signed_xml, ""),
184+
signature_method: signature_method,
185+
digest_method: digest_method
186+
}}
187+
end
176188

177189
candidates ->
178190
{:error,
@@ -184,6 +196,204 @@ defmodule Relyra.Security.Signature do
184196
end
185197
end
186198

199+
# Real cryptographic XMLDSig verification of the bound candidate (D-01..D-08).
200+
# Every step fails CLOSED to a typed %Relyra.Error{} naming the failed check;
201+
# NOTHING here may raise (Pitfalls 3, 4). Step order matters: the digest-atom
202+
# / ECDSA gate (D-06/D-07) runs BEFORE any verify attempt, then key extraction
203+
# (D-04), then the SignedInfo signature math (D-03), then the Reference digest
204+
# recompute (D-05). %SignedNode{} is only built when BOTH crypto checks pass.
205+
defp cryptographically_verify(candidate, signature_method, cert_chain, details) do
206+
signed_info_node = Map.get(candidate, :signed_info_node)
207+
signature_value_b64 = Map.get(candidate, :signature_value_b64)
208+
digest_value_b64 = Map.get(candidate, :digest_value_b64)
209+
referenced_node = Map.get(candidate, :node)
210+
211+
with :ok <- require_field(signed_info_node, :missing_signature, "SignedInfo", details),
212+
:ok <-
213+
require_field(signature_value_b64, :invalid_signature, "SignatureValue", details),
214+
:ok <- require_field(digest_value_b64, :digest_mismatch, "DigestValue", details),
215+
:ok <- require_field(referenced_node, :missing_signature, "referenced node", details),
216+
# 1. Digest-atom + ECDSA gate (D-06/D-07) — fail CLOSED before any verify.
217+
{:ok, digest_atom} <- digest_atom(signature_method, details),
218+
# 2. Trust-source public key (D-04) — configured cert_chain only.
219+
{:ok, public_key} <- public_key_from_cert_chain(cert_chain, details),
220+
# 3. Signature math (D-03) — :public_key.verify of the canonical SignedInfo.
221+
:ok <-
222+
verify_signature_math(
223+
signed_info_node,
224+
signature_value_b64,
225+
digest_atom,
226+
public_key,
227+
details
228+
),
229+
# 4. Digest check (D-05) — recompute over the canonical referenced element.
230+
:ok <- verify_reference_digest(candidate, digest_value_b64, digest_atom, details) do
231+
:ok
232+
end
233+
end
234+
235+
defp require_field(value, _error_type, _label, _details) when not is_nil(value), do: :ok
236+
237+
defp require_field(_value, error_type, label, details) do
238+
{:error,
239+
Error.new(
240+
error_type,
241+
"Signed candidate is missing required #{label}",
242+
Map.put(details, :reason, :missing_signature_input)
243+
)}
244+
end
245+
246+
# D-06/D-07: map the signature-method URI to the digest atom, failing CLOSED
247+
# for ECDSA / unknown (the typed reject is the contract — the allowlist still
248+
# permits ECDSA URIs, Plan 02 decision).
249+
defp digest_atom(signature_method, details) do
250+
case AlgorithmPolicy.digest_atom_for_signature_method(signature_method) do
251+
{:ok, atom} ->
252+
{:ok, atom}
253+
254+
{:error, :unsupported_signature_algorithm} ->
255+
{:error,
256+
Error.new(
257+
:unsupported_signature_algorithm,
258+
"Signature algorithm is not supported for cryptographic verification",
259+
Map.put(details, :signature_method, signature_method)
260+
)}
261+
end
262+
end
263+
264+
# D-04: extract the RSA public key from the FIRST (leaf) configured cert PEM.
265+
# Mirrors the certificate_facts.ex:26-47 pem_decode + try/rescue idiom and
266+
# fails CLOSED with :untrusted_certificate on ANY malformed PEM/DER (Pitfall 3
267+
# — pem_entry_decode / pkix_decode_cert RAISE on malformed input). Per A1 the
268+
# configured signing cert is the single/first entry; chain-walk is out of scope.
269+
defp public_key_from_cert_chain(cert_chain, details) do
270+
case public_key_from_cert_chain(cert_chain) do
271+
{:ok, public_key} ->
272+
{:ok, public_key}
273+
274+
{:error, :untrusted_certificate} ->
275+
{:error,
276+
Error.new(
277+
:untrusted_certificate,
278+
"Configured certificate public key could not be extracted",
279+
Map.put(details, :reason, :public_key_extraction_failed)
280+
)}
281+
end
282+
end
283+
284+
@doc false
285+
# Bare PEM→public-key extraction (no %Relyra.Error{} wrapping). Returns
286+
# {:ok, public_key} | {:error, :untrusted_certificate}. NEVER raises.
287+
def public_key_from_cert_chain([pem | _rest]) when is_binary(pem) do
288+
with [entry | _] <- :public_key.pem_decode(pem),
289+
der when is_binary(der) <- elem(entry, 1) do
290+
{:OTPCertificate, otp_tbs, _sig_alg, _sig} = :public_key.pkix_decode_cert(der, :otp)
291+
{:OTPSubjectPublicKeyInfo, _alg_id, public_key} = :erlang.element(8, otp_tbs)
292+
{:ok, public_key}
293+
else
294+
_ -> {:error, :untrusted_certificate}
295+
end
296+
rescue
297+
_ -> {:error, :untrusted_certificate}
298+
end
299+
300+
def public_key_from_cert_chain(_cert_chain), do: {:error, :untrusted_certificate}
301+
302+
# D-03: canonicalize the SignedInfo (bare exclusive-C14N — SignedInfo carries
303+
# NO enveloped-signature transform; reading its own ds:CanonicalizationMethod
304+
# InclusiveNamespaces PrefixList when present, empty list otherwise) and verify
305+
# the decoded SignatureValue with :public_key.verify against the configured key.
306+
defp verify_signature_math(
307+
signed_info_node,
308+
signature_value_b64,
309+
digest_atom,
310+
public_key,
311+
details
312+
) do
313+
prefix_list = signed_info_prefix_list(signed_info_node)
314+
315+
with {:ok, c14n_signed_info} <- C14N.serialize(signed_info_node, prefix_list: prefix_list),
316+
{:ok, sig_bytes} <- decode_b64(signature_value_b64) do
317+
if safe_verify(c14n_signed_info, digest_atom, sig_bytes, public_key) do
318+
:ok
319+
else
320+
{:error,
321+
Error.new(
322+
:invalid_signature,
323+
"SignatureValue failed cryptographic verification",
324+
details
325+
)}
326+
end
327+
else
328+
:error ->
329+
{:error,
330+
Error.new(
331+
:invalid_signature,
332+
"SignatureValue is not valid base64",
333+
details
334+
)}
335+
336+
{:error, %Error{} = error} ->
337+
{:error, merge_error_details(error, details)}
338+
end
339+
end
340+
341+
# D-05: recompute the Reference digest over the canonicalized, transformed
342+
# referenced element (PureBeam.canonicalize over the bound :node — the EXACT
343+
# node the verifier consumes, anti-XSW) and constant-time-compare it to the
344+
# declared DigestValue. Length-guard BEFORE :crypto.hash_equals/2 (Pitfall 4 —
345+
# it RAISES on unequal-length inputs).
346+
defp verify_reference_digest(candidate, digest_value_b64, digest_atom, details) do
347+
with {:ok, %{canonical_xml: ref_bytes}} <- PureBeam.canonicalize(candidate),
348+
{:ok, declared} <- decode_b64(digest_value_b64) do
349+
recomputed = :crypto.hash(digest_atom, ref_bytes)
350+
351+
if byte_size(recomputed) == byte_size(declared) and
352+
:crypto.hash_equals(recomputed, declared) do
353+
:ok
354+
else
355+
{:error,
356+
Error.new(
357+
:digest_mismatch,
358+
"Recomputed Reference digest does not match DigestValue",
359+
details
360+
)}
361+
end
362+
else
363+
:error ->
364+
{:error,
365+
Error.new(
366+
:digest_mismatch,
367+
"DigestValue is not valid base64",
368+
details
369+
)}
370+
371+
{:error, %Error{} = error} ->
372+
{:error, merge_error_details(error, details)}
373+
end
374+
end
375+
376+
# Read the SignedInfo's own ds:CanonicalizationMethod InclusiveNamespaces
377+
# PrefixList when present (reusing the C14N.prefix_list_from_transforms/1 shape,
378+
# which scans a node for an InclusiveNamespaces descendant). Empty list is the
379+
# DERIVED result for the local signer (Open Q2), NOT a hardcoded default.
380+
defp signed_info_prefix_list(signed_info_node) do
381+
C14N.prefix_list_from_transforms(signed_info_node)
382+
end
383+
384+
defp decode_b64(value) when is_binary(value), do: Base.decode64(value)
385+
defp decode_b64(_value), do: :error
386+
387+
# :public_key.verify/4 returns false on a bad signature (no raise) but RAISES
388+
# on a malformed KEY / decoded ASN.1 (Pitfall 3). Wrap it so a malformed key
389+
# surfaces as a non-verifying result (the caller emits :invalid_signature),
390+
# never an escaping exception on the auth path.
391+
defp safe_verify(message, digest_atom, signature, public_key) do
392+
:public_key.verify(message, digest_atom, signature, public_key)
393+
rescue
394+
_ -> false
395+
end
396+
187397
defp merge_error_details(%Error{details: error_details} = error, details)
188398
when is_map(error_details) do
189399
%{error | details: Map.merge(details, error_details)}

lib/relyra/security/xml.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ defmodule Relyra.Security.XML do
1919
| :canonicalization_failed
2020
| :untrusted_certificate
2121
| :unsigned_or_partial_signature
22+
| :digest_mismatch
23+
| :unsupported_signature_algorithm
2224

2325
@callback parse_safely(binary(), keyword()) ::
2426
{:ok, term()} | {:error, %Error{}}

test/security/signed_node_binding_test.exs

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,15 +60,21 @@ defmodule Relyra.Security.SignedNodeBindingTest do
6060
assert details.connection_id == "conn-1"
6161
end
6262

63-
test "verify/4 returns the exact verified signed node when candidate count is one" do
64-
assert {:ok, %Relyra.Security.SignedNode{} = signed_node} =
63+
# Phase 29 Plan 03 triage (Rule 1/3): once real crypto is wired into the
64+
# [candidate] arm (D-01), a SINGLE structure-only candidate that selects past
65+
# all trust gates can no longer return {:ok} — it carries no SignedInfo /
66+
# SignatureValue / DigestValue, so the crypto fails CLOSED. This is the
67+
# bypass being closed: the selection path reaches exactly one candidate, then
68+
# rejects it for lacking signature material instead of trusting structure
69+
# alone. The genuine {:ok, %SignedNode{}} positive lives in
70+
# test/relyra/security/signature_crypto_test.exs (the in-test genuine signer);
71+
# the fuller end-to-end pipeline triage is Plan 04.
72+
test "verify/4 fails closed on a single structure-only candidate (no crypto inputs)" do
73+
assert {:error, %Error{type: :missing_signature, details: details}} =
6574
Signature.verify(base_parsed_doc(), connection(), cert_chain())
6675

67-
assert signed_node.xml_id == "assertion-1"
68-
assert signed_node.xpath == "/Response/Assertion[1]"
69-
assert signed_node.signed_xml == "<Assertion>signed</Assertion>"
70-
assert signed_node.signature_method == @allowed_signature_method
71-
assert signed_node.digest_method == @allowed_digest_method
76+
assert details.reason == :missing_signature_input
77+
assert details.connection_id == "conn-1"
7278
end
7379

7480
defp base_parsed_doc(overrides \\ %{}) do

0 commit comments

Comments
 (0)