"""Merchant Integration · signing toolkit for Python 3.8+ (standard library only). Signs requests you send to the Merchant API and verifies the Wallet API requests we send to you. Spec: Integration Guide › Authentication. Run this file to self-test against the published test vector: python3 merchant_sign.py """ import hashlib import hmac import json import secrets import time from typing import Mapping, Optional HEADER_MERCHANT_CODE = "X-Merchant-Code" HEADER_REQUEST_ID = "X-Request-Id" HEADER_TIMESTAMP = "X-Timestamp" HEADER_SIGNATURE = "X-Signature" TIMESTAMP_WINDOW_MS = 300_000 # |now - X-Timestamp| must be within 300 s OK = "OK" INVALID_SIGNATURE = "INVALID_SIGNATURE" TIMESTAMP_EXPIRED = "TIMESTAMP_EXPIRED" def body_sha256(raw_body: bytes) -> str: """Lower-case hex SHA-256 of the exact body bytes (empty body included).""" return hashlib.sha256(raw_body).hexdigest() def string_to_sign(method: str, path: str, merchant_code: str, timestamp: str, request_id: str, raw_body: bytes) -> str: return "\n".join([method.upper(), path, merchant_code, timestamp, request_id, body_sha256(raw_body)]) def sign(secret_key: str, method: str, path: str, merchant_code: str, timestamp: str, request_id: str, raw_body: bytes) -> str: """X-Signature: lower-case hex HMAC-SHA256(secretKey, stringToSign).""" msg = string_to_sign(method, path, merchant_code, timestamp, request_id, raw_body) return hmac.new(secret_key.encode(), msg.encode(), hashlib.sha256).hexdigest() def new_request_id() -> str: """A fresh X-Request-Id (32 hex characters). Use a new one for every request, retries included.""" return secrets.token_hex(16) def signed_headers(merchant_code: str, secret_key: str, method: str, path: str, raw_body: bytes, now_ms: Optional[int] = None, request_id: Optional[str] = None) -> dict: """Headers for one signed request. Send exactly `raw_body` — the bytes that were signed.""" ts = str(now_ms if now_ms is not None else int(time.time() * 1000)) rid = request_id or new_request_id() return { "Content-Type": "application/json; charset=utf-8", HEADER_MERCHANT_CODE: merchant_code, HEADER_REQUEST_ID: rid, HEADER_TIMESTAMP: ts, HEADER_SIGNATURE: sign(secret_key, method, path, merchant_code, ts, rid, raw_body), } def verify(secret_key: str, merchant_code: str, headers: Mapping[str, str], method: str, path: str, raw_body: bytes, now_ms: Optional[int] = None) -> str: """Check one incoming request (our Wallet API calls to you). Returns OK, INVALID_SIGNATURE or TIMESTAMP_EXPIRED, which you can answer as the envelope code. Verify on the raw body bytes BEFORE parsing JSON.""" h = {k.lower(): v for k, v in headers.items()} code = h.get(HEADER_MERCHANT_CODE.lower(), "") rid = h.get(HEADER_REQUEST_ID.lower(), "") ts = h.get(HEADER_TIMESTAMP.lower(), "") sig = h.get(HEADER_SIGNATURE.lower(), "") if not (code and rid and ts and sig) or code != merchant_code: return INVALID_SIGNATURE try: sent = int(ts) except ValueError: return TIMESTAMP_EXPIRED now = now_ms if now_ms is not None else int(time.time() * 1000) if abs(now - sent) > TIMESTAMP_WINDOW_MS: return TIMESTAMP_EXPIRED want = sign(secret_key, method, path, code, ts, rid, raw_body) return OK if hmac.compare_digest(want, sig.lower()) else INVALID_SIGNATURE def envelope(code: str, message: str = "", data: Optional[dict] = None) -> bytes: """A response body: {"code", "message", "data"}. Business results are always HTTP 200.""" out = {"code": code, "message": message or code} if data is not None: out["data"] = data return json.dumps(out, separators=(",", ":"), ensure_ascii=False).encode() def _self_test() -> None: key, path, code, ts, rid = "test-secret-key-0001", "/wallet/debit", "M0001", "1792000000000", "req-0001" body = b'{"txnId":"T7K2M9QXH4ZP8WNA","amount":"10.00"}' assert body_sha256(body) == "b8d0525520debc2d5e2c62e83d2303a4cd29ed0c18c4139e98196c76c14fc2b8" sig = sign(key, "POST", path, code, ts, rid, body) assert sig == "e5273ee9fb4c05408af2f8b32b26b4b98bc891fb77e5ffe5b46f9c112ebe6589", sig hdr = {HEADER_MERCHANT_CODE: code, HEADER_REQUEST_ID: rid, HEADER_TIMESTAMP: ts, HEADER_SIGNATURE: sig} now = 1792000000000 assert verify(key, code, hdr, "POST", path, body, now) == OK assert verify(key, code, hdr, "POST", path, body.replace(b"10.00", b"10.01"), now) == INVALID_SIGNATURE assert verify(key, "M0002", hdr, "POST", path, body, now) == INVALID_SIGNATURE assert verify(key, code, hdr, "POST", path, body, now + TIMESTAMP_WINDOW_MS + 1) == TIMESTAMP_EXPIRED out = signed_headers(code, key, "POST", path, body, now, rid) assert verify(key, code, out, "POST", path, body, now) == OK print("merchant_sign.py: self-test passed", sig) if __name__ == "__main__": _self_test()