Files
django-nepali-payments/nepali_payment/helpers.py
T

192 lines
6.6 KiB
Python

"""
Signing helpers and a response converter used by the gateway services:
- HMAC signing for eSewa (SHA256, base64) and Fonepay (SHA512, lowercase hex).
- RSA signing for ConnectIPS.
- Converting provider responses into typed objects.
"""
import base64
import hashlib
import hmac as _hmac
import json
from functools import lru_cache
from typing import Any, TypeVar
from nepali_payment.exceptions import ValidationError
T = TypeVar("T")
def _validate(message: str, secret: str) -> None:
if not message:
raise ValidationError("Message cannot be null or empty.")
if not secret:
raise ValidationError("Secret key cannot be null or empty.")
def generate_hmac_sha256_signature(message: str, secret: str) -> str:
"""HMAC-SHA256 digest as base64 (eSewa). Mirrors GenerateHmacSha256Signature."""
_validate(message, secret)
digest = _hmac.new(secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).digest()
return base64.b64encode(digest).decode("utf-8")
def generate_hmac_sha256(message: str, secret: str) -> str:
"""Alias for generate_hmac_sha256_signature."""
return generate_hmac_sha256_signature(message, secret)
def generate_hmac_sha512(message: str, secret: str) -> str:
"""HMAC-SHA512 digest as lowercase hex (Fonepay).
Mirrors GenerateHmacSha512. Fonepay rejects base64-encoded dataValidation
with "Data Validation Failed" (406); only hex reproduces its worked sample.
"""
_validate(message, secret)
digest = _hmac.new(secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha512).digest()
return digest.hex()
def load_rsa_private_key(
cert_path: str | None = None,
cert_data: bytes | None = None,
cert_format: str | None = None,
cert_password: str | None = None,
):
"""Load an RSA private key from a certificate (used by ConnectIPS).
Works with PEM, PFX and P12 files. If you pass ``cert_data`` we use that
straight away; otherwise we read the file at ``cert_path``. PFX/P12 is
detected from ``cert_format`` or the file ending, otherwise we treat the
data as PEM.
Args:
cert_path: Path to a ``.pfx``, ``.p12`` or ``.pem`` certificate.
cert_data: The raw certificate bytes (use this instead of ``cert_path``).
cert_format: ``"pfx"``/``"p12"`` or ``"pem"`` when passing ``cert_data``.
cert_password: Password for PFX/P12 files (PEM usually has none).
Returns:
An RSA private key object from the ``cryptography`` library.
Raises:
ValidationError: If no certificate is given, or the key is not RSA.
"""
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
if cert_path is None and not cert_data:
raise ValidationError("CertPath or CertData is required.")
if cert_data is not None:
is_pfx = bool(cert_format and cert_format.lower() in ("pfx", "p12"))
else:
with open(cert_path, "rb") as handle: # cert path is user-supplied
cert_data = handle.read()
is_pfx = cert_path.lower().endswith((".pfx", ".p12"))
if is_pfx:
key = serialization.pkcs12.load_key_and_certificates(
cert_data, cert_password.encode("utf-8") if cert_password else None
)[0]
else:
key = serialization.load_pem_private_key(
cert_data,
password=cert_password.encode("utf-8") if cert_password else None,
)
if not isinstance(key, rsa.RSAPrivateKey):
raise ValidationError("Certificate does not contain an RSA private key.")
return key
def generate_rsa_signature(message: str, private_key: Any) -> str:
"""Sign ``message`` with RSA-SHA256 and return it as base64 (ConnectIPS).
Args:
message: The text to sign.
private_key: An RSA key from :func:`load_rsa_private_key`.
Returns:
The signature as base64 text.
"""
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
signature = private_key.sign(message.encode("utf-8"), padding.PKCS1v15(), hashes.SHA256())
return base64.b64encode(signature).decode("utf-8")
def convert_to(cls: type[T], response: Any) -> T:
"""Best-effort conversion of an arbitrary response into ``cls`` (ResponseConverter.cs)."""
if response is None:
return cls()
if isinstance(response, cls):
return response
if isinstance(response, (str, bytes, bytearray)):
if isinstance(response, bytes):
response = response.decode("utf-8")
try:
data = json.loads(response)
except (json.JSONDecodeError, TypeError):
data = str(response)
return _coerce(cls, data)
if isinstance(response, dict):
return _coerce(cls, response)
try:
return cls(response)
except (TypeError, ValueError):
return response # type: ignore[return-value]
@lru_cache(maxsize=512)
def _to_snake(name: str) -> str:
"""Turn a camelCase or PascalCase name into snake_case."""
out = []
for i, ch in enumerate(name):
if ch.isupper():
if i > 0 and (name[i - 1].islower() or name[i - 1].isdigit()):
out.append("_")
out.append(ch.lower())
else:
out.append(ch)
return "".join(out)
def _coerce(cls: type[T], data: Any) -> T:
"""Build an object of ``cls`` from a dictionary.
Handles missing keys and camelCase JSON names (``qrMessage`` becomes
``qr_message``) without crashing.
"""
if data is None:
return cls()
if isinstance(data, (list, tuple)):
return [_coerce(cls, item) for item in data] # type: ignore[return-value]
try:
return cls(**data)
except (TypeError, ValueError):
keys = getattr(cls, "__dataclass_fields__", None)
if not keys:
return data # type: ignore[return-value]
if not isinstance(data, dict):
return data # type: ignore[return-value]
snake_map = {_to_snake(k): v for k, v in data.items()}
filtered = {k: v for k, v in snake_map.items() if k in keys}
return cls(**filtered)
def decode_base64_content(encoded_content: str) -> str:
"""Decode base64 content, falling back to the raw string (DecodeBase64Content)."""
try:
# Try standard base64 first, then urlsafe
try:
return base64.b64decode(encoded_content, validate=False).decode("utf-8")
except Exception:
return base64.urlsafe_b64decode(encoded_content + "=" * (-len(encoded_content) % 4)).decode("utf-8")
except (ValueError, TypeError, Exception):
return encoded_content