@@ -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 ) }
0 commit comments