From cd9caa1cbb38d8a29d61e5886d4a4dd65e954773 Mon Sep 17 00:00:00 2001 From: AlgoVoi Date: Wed, 15 Jul 2026 13:38:37 +0100 Subject: [PATCH] fix(merchant-backend): make signature verifier RFC 9421-conformant --- .../app/security/signature_verification.py | 237 ++++++++++++------ 1 file changed, 158 insertions(+), 79 deletions(-) diff --git a/merchant-backend/app/security/signature_verification.py b/merchant-backend/app/security/signature_verification.py index acafe35..ce6533a 100644 --- a/merchant-backend/app/security/signature_verification.py +++ b/merchant-backend/app/security/signature_verification.py @@ -8,13 +8,13 @@ import re import time -import json from typing import Dict, Optional, Tuple from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import rsa, padding +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey from cryptography.exceptions import InvalidSignature import base64 -import hashlib publicKey = """-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAysHJFJ9uoVvU1sH2x3TV @@ -26,6 +26,22 @@ LQIDAQAB -----END PUBLIC KEY-----""" +# Replay cache to prevent nonce reuse (in production this would be a shared/ +# persistent store; an in-memory map suffices for the single-process reference +# merchant backend). Each nonce is retained until the expiry of the signature +# that used it: while the signature is still valid the nonce blocks a replay, +# and once it expires the timestamp check rejects it regardless, so the entry +# is purged. Keying retention to the signature's own expiry (rather than a fixed +# TTL) closes the replay window that opens when a signature outlives the TTL. +_nonce_cache: Dict[str, int] = {} + + +def _purge_expired_nonces(now: int) -> None: + for nonce, expires_at in list(_nonce_cache.items()): + if expires_at < now: + del _nonce_cache[nonce] + + class SignatureVerifier: def __init__(self): # In production, these would be loaded from secure storage/config @@ -39,10 +55,10 @@ def __init__(self): "name": "Sample Payment Directory" } } - + def _load_public_key(self, agent_name: str): """Load public key for the agent. In production, load from secure storage.""" - + if agent_name == "example": return serialization.load_pem_public_key(publicKey.encode("utf-8")) elif agent_name == "sample": @@ -51,124 +67,187 @@ def _load_public_key(self, agent_name: str): else: raise ValueError(f"Unknown agent name: {agent_name}") - def parse_signature_headers(self, signature_agent: str, signature_input: str, signature: str) -> Optional[Dict]: - """Parse the signature headers and extract components.""" + """Parse the RFC 9421 signature headers and extract components. + + Accepts the standard RFC 9421 wire form used by the CDN proxy reference, + e.g.: + Signature-Input: sig2=("@authority" "@path"); created=...; expires=...; + keyid="..."; alg="rsa-pss-sha256"; nonce="..."; tag="..." + Signature: sig2=:: + The covered-component list is a space separated list of individually + quoted identifiers (RFC 9421 sec 2.1), the signature parameters are + unordered, and the label (sig1/sig2/...) is arbitrary. + """ try: - # Parse Signature-Agent agent_url = signature_agent.strip('"') - - # Parse Signature-Input - signature_input_pattern = r'sig1=\("([^"]+)"\);\s*nonce="([^"]+)";\s*created=(\d+);\s*expires=(\d+);\s*keyid="([^"]+)";\s*tag="([^"]+)"' - match = re.match(signature_input_pattern, signature_input.strip()) - - if not match: + + #