#!/usr/bin/env python3 """ FZP1 client for the Finzep payout API - Python 3.8+ (pip install cryptography requests) Specification: payout-api-guide.html (section "Encryption") Save this file as fzp_client.py. Use as a library: from fzp_client import FzpClient client = FzpClient(base_url="https://api.example.com", api_key="fzp_...", client_key_id="ck_...", client_private_pem=open("client-private.pem").read(), server_key_id="sk_...", server_public_pem="...") status, answer = client.post("/API/Payout", {"APIRequestID": "REQ-1", "TransMode": "IMPS", ...}) or from the command line: python fzp_client.py keygen python fzp_client.py build-request --client-key-id ck_.. --server-key-id sk_.. --path /API/Payout \ --client-private client-private.pem --server-public server-public.pem --json '{"APIRequestID":"R1",...}' python fzp_client.py open-response --request-envelope request.json --response response.json \ --client-private client-private.pem --server-public server-public.pem python fzp_client.py open-webhook --body webhook-body.json --event-id \ --client-private client-private.pem --server-public server-public.pem """ import base64 import datetime import json import os import sys from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import padding, rsa from cryptography.hazmat.primitives.ciphers.aead import AESGCM VERSION = "FZP1" PSS = padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=32) OAEP = padding.OAEP(mgf=padding.MGF1(hashes.SHA256()), algorithm=hashes.SHA256(), label=None) def _b64(data: bytes) -> str: return base64.b64encode(data).decode("ascii") def signed_text(kind: str, path_or_request_nonce: str, e: dict) -> bytes: parts = [VERSION, kind, path_or_request_nonce, e["clientKeyId"], e["serverKeyId"], e["timestamp"], e["nonce"], e["encryptedKey"], e["iv"], e["payload"]] return "\n".join(parts).encode("utf-8") def _aad(e: dict) -> bytes: return f'{e["timestamp"]}\n{e["nonce"]}'.encode("utf-8") def build_request(client_key_id, server_key_id, path, json_text, client_private_pem, server_public_pem) -> dict: """Encrypts `json_text` for the server and signs it with the client's private key. Returns the envelope (a dict).""" aes_key, iv = os.urandom(32), os.urandom(12) e = { "clientKeyId": client_key_id, "serverKeyId": server_key_id, "timestamp": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "nonce": _b64(os.urandom(16)), "iv": _b64(iv), } server_public = serialization.load_pem_public_key(server_public_pem.encode()) e["encryptedKey"] = _b64(server_public.encrypt(aes_key, OAEP)) e["payload"] = _b64(AESGCM(aes_key).encrypt(iv, json_text.encode("utf-8"), _aad(e))) # ciphertext || 16-byte tag client_private = serialization.load_pem_private_key(client_private_pem.encode(), password=None) e["signature"] = _b64(client_private.sign(signed_text("REQUEST", path, e), PSS, hashes.SHA256())) return e def open_response(response_body, request_envelope, client_private_pem, server_public_pem) -> str: """Verifies the server's signature, then decrypts its answer. Raises if anything does not check out.""" return _open("RESPONSE", request_envelope["nonce"], response_body, client_private_pem, server_public_pem) def open_webhook(body, event_id, client_private_pem, server_public_pem) -> str: """Verifies and decrypts a WEBHOOK we sent you. event_id is the X-Webhook-Id header of the call. Returns the JSON text.""" return _open("WEBHOOK", event_id, body, client_private_pem, server_public_pem) def _open(kind, third, body, client_private_pem, server_public_pem) -> str: r = json.loads(body) if isinstance(body, str) else body server_public = serialization.load_pem_public_key(server_public_pem.encode()) try: server_public.verify(base64.b64decode(r["signature"]), signed_text(kind, third, r), PSS, hashes.SHA256()) except InvalidSignature: raise ValueError("Server signature does not verify - do not trust this response") client_private = serialization.load_pem_private_key(client_private_pem.encode(), password=None) aes_key = client_private.decrypt(base64.b64decode(r["encryptedKey"]), OAEP) return AESGCM(aes_key).decrypt(base64.b64decode(r["iv"]), base64.b64decode(r["payload"]), _aad(r)).decode("utf-8") class FzpClient: def __init__(self, base_url, api_key, client_key_id, client_private_pem, server_key_id, server_public_pem): self.base_url, self.api_key = base_url.rstrip("/"), api_key self.client_key_id, self.client_private_pem = client_key_id, client_private_pem self.server_key_id, self.server_public_pem = server_key_id, server_public_pem def post(self, path, body): """Sends one encrypted call. Returns (http_status, answer_dict).""" import requests # pip install requests req = build_request(self.client_key_id, self.server_key_id, path, json.dumps(body), self.client_private_pem, self.server_public_pem) resp = requests.post(self.base_url + path, data=json.dumps(req), headers={"Content-Type": "application/json", "X-Api-Key": self.api_key, "X-Encrypted": "1"}, timeout=60) if "X-Encrypted" in resp.headers: # errors found before the message could be trusted come back as plain JSON return resp.status_code, json.loads(open_response(resp.text, req, self.client_private_pem, self.server_public_pem)) return resp.status_code, resp.json() def keygen(): key = rsa.generate_private_key(public_exponent=65537, key_size=3072) private_pem = key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()).decode() public_pem = key.public_key().public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo).decode() return public_pem, private_pem if __name__ == "__main__": cmd, rest = (sys.argv[1] if len(sys.argv) > 1 else ""), sys.argv[2:] def arg(name): return rest[rest.index("--" + name) + 1] if ("--" + name) in rest else None def read(name): with open(arg(name), encoding="utf-8") as f: return f.read() if cmd == "keygen": out = rest[0] if rest else "." pub, priv = keygen() with open(os.path.join(out, "client-private.pem"), "w") as f: f.write(priv) with open(os.path.join(out, "client-public.pem"), "w") as f: f.write(pub) print("Wrote client-private.pem (KEEP SECRET) and client-public.pem (register this one) in " + out) elif cmd == "build-request": sys.stdout.write(json.dumps(build_request(arg("client-key-id"), arg("server-key-id"), arg("path"), arg("json"), read("client-private"), read("server-public")))) elif cmd == "open-response": sys.stdout.write(open_response(read("response"), json.loads(read("request-envelope")), read("client-private"), read("server-public"))) elif cmd == "open-webhook": sys.stdout.write(open_webhook(read("body"), arg("event-id"), read("client-private"), read("server-public"))) else: sys.exit("usage: fzp_client.py keygen | build-request ... | open-response ... | open-webhook ...")