"""Firma de pedidos a FCM: RFC 9421 (HTTP Message Signatures) + RFC 9530 (Content-Digest). Requiere ``cryptography``. Quien llama carga la Ed25519PrivateKey desde su propio almacén de claves; este módulo nunca escribe material privado. Cada pedido lleva un ``nonce`` aleatorio nuevo (si repetís uno, FCM responde ``signature_replayed``). Obligatorios: ``@method`` y ``@target-uri``; con cuerpo, además ``content-type``, ``content-digest`` e ``idempotency-key``; con ``FCM-Version``, ``fcm-version``. El borde acepta también, si los cubrís, ``date``, ``content-length``, ``@authority``, ``@path`` y ``@query`` (lista cerrada). ``Authorization`` (la API key) va en el pedido pero NO se firma: si la cubrís, FCM responde ``signature_invalid``. """ from __future__ import annotations import base64 import hashlib import json import secrets import time from urllib.parse import urlsplit from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey EXTRA_COMPONENTS = ("date", "content-length", "@authority", "@path", "@query") def _b64(value: bytes) -> str: return base64.b64encode(value).decode("ascii") def new_nonce() -> str: """16 bytes aleatorios en base64url (22 caracteres).""" return secrets.token_urlsafe(16) def _digest_header(body: bytes) -> str: return f"sha-256=:{_b64(hashlib.sha256(body).digest())}:" def _quote(value: str) -> str: # Los nombres de componentes y los nonces son ASCII: el JSON da la forma quoted-string del RFC 9421. return json.dumps(value, separators=(",", ":")) def _derived(component: str, target_uri: str) -> str: parts = urlsplit(target_uri) if component == "@authority": return parts.netloc.lower() if component == "@path": return parts.path or "/" return f"?{parts.query}" if parts.query else "?" def sign_fcm_request( *, method: str, target_uri: str, authorization: str, private_key: Ed25519PrivateKey, key_id: str, body: bytes | None = None, content_type: str | None = None, idempotency_key: str | None = None, fcm_version: str | None = None, extra_components: tuple[str, ...] = (), date: str | None = None, created: int | None = None, nonce: str | None = None, ) -> tuple[dict[str, str], str]: """``created`` y ``nonce`` fijos sólo para vectores de prueba: en producción, hora actual y nonce aleatorio.""" headers: dict[str, str] = {"Authorization": authorization} values: dict[str, str] = {"@method": method.upper(), "@target-uri": target_uri} components = ["@method", "@target-uri"] if body is not None: if content_type is None or idempotency_key is None: raise ValueError("body requests require content_type and idempotency_key") digest = _digest_header(body) headers.update({"Content-Type": content_type, "Content-Digest": digest, "Idempotency-Key": idempotency_key}) values.update({"content-type": content_type, "content-digest": digest, "idempotency-key": idempotency_key}) components.extend(("content-type", "content-digest", "idempotency-key")) if fcm_version is not None: headers["FCM-Version"] = fcm_version values["fcm-version"] = fcm_version components.append("fcm-version") for extra in extra_components: if extra not in EXTRA_COMPONENTS: raise ValueError(f"component {extra} is not accepted by FCM") if extra == "date": if date is None: raise ValueError("date is required to cover the date component") headers["Date"] = date values["date"] = date elif extra == "content-length": length = str(len(body or b"")) headers["Content-Length"] = length values["content-length"] = length else: values[extra] = _derived(extra, target_uri) components.append(extra) created = int(time.time()) if created is None else created nonce = new_nonce() if nonce is None else nonce params = ( f"({' '.join(_quote(component) for component in components)})" f";created={created};nonce={_quote(nonce)};keyid={_quote(key_id)};alg=\"ed25519\"" ) lines = [f"{_quote(component)}: {values[component]}" for component in components] signature_base = "\n".join((*lines, f'{_quote("@signature-params")}: {params}')) signature = private_key.sign(signature_base.encode("utf-8")) headers["Signature-Input"] = f"sig1={params}" headers["Signature"] = f"sig1=:{_b64(signature)}:" return headers, signature_base # Ejemplo: # body = json.dumps({"account_number_id": "acn_…", "amount": 1500000}, separators=(",", ":")).encode() # headers, _ = sign_fcm_request( # method="POST", target_uri="https://empresas-sandbox.fcmpayments.com/v1/simulations/incoming_transfers", # authorization="Bearer fcm_test_…", private_key=private_key, key_id="ck_…", # body=body, content_type="application/json", idempotency_key="01JAT…", # )