// FZP1 client for the Finzep payout API - Java 11+, no dependencies. // Specification: payout-api-guide.html (section "Encryption") // // Save this file as FzpClient.java. Library use: // FzpClient c = new FzpClient("https://api.example.com", apiKey, clientKeyId, clientPrivatePem, serverKeyId, serverPublicPem); // String answerJson = c.post("/API/Payout", "{\"APIRequestID\":\"REQ-1\", ...}"); // // Command line (Java 11+ runs a single source file directly): // java FzpClient.java keygen // java FzpClient.java build-request --client-key-id ck_.. --server-key-id sk_.. --path /API/Payout // --client-private client-private.pem --server-public server-public.pem --json "{...}" // java FzpClient.java open-response --request-envelope request.json --response response.json // --client-private client-private.pem --server-public server-public.pem // java FzpClient.java open-webhook --body webhook-body.json --event-id // --client-private client-private.pem --server-public server-public.pem import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.security.*; import java.security.spec.*; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.*; import java.util.regex.*; import javax.crypto.Cipher; import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.OAEPParameterSpec; import javax.crypto.spec.PSource; import javax.crypto.spec.SecretKeySpec; public class FzpClient { private static final SecureRandom RNG = new SecureRandom(); private final String baseUrl, apiKey, clientKeyId, serverKeyId; private final PrivateKey clientPrivate; private final PublicKey serverPublic; public FzpClient(String baseUrl, String apiKey, String clientKeyId, String clientPrivatePem, String serverKeyId, String serverPublicPem) throws Exception { this.baseUrl = baseUrl.replaceAll("/+$", ""); this.apiKey = apiKey; this.clientKeyId = clientKeyId; this.serverKeyId = serverKeyId; this.clientPrivate = privateKey(clientPrivatePem); this.serverPublic = publicKey(serverPublicPem); } /** Sends one encrypted call and returns the decrypted JSON answer (or the plain JSON of an early error). */ public String post(String path, String json) throws Exception { Map req = buildRequest(clientKeyId, serverKeyId, path, json, clientPrivate, serverPublic); HttpRequest http = HttpRequest.newBuilder(URI.create(baseUrl + path)) .header("Content-Type", "application/json").header("X-Api-Key", apiKey).header("X-Encrypted", "1") .POST(HttpRequest.BodyPublishers.ofString(toJson(req))).build(); HttpResponse res = HttpClient.newHttpClient().send(http, HttpResponse.BodyHandlers.ofString()); return res.headers().firstValue("X-Encrypted").isPresent() ? openResponse(res.body(), req.get("nonce"), clientPrivate, serverPublic) : res.body(); } // ------------------------------------------------------------------ crypto static String signedText(String kind, String pathOrRequestNonce, Map e) { return String.join("\n", "FZP1", kind, pathOrRequestNonce, e.get("clientKeyId"), e.get("serverKeyId"), e.get("timestamp"), e.get("nonce"), e.get("encryptedKey"), e.get("iv"), e.get("payload")); } static byte[] aad(Map e) { return (e.get("timestamp") + "\n" + e.get("nonce")).getBytes(StandardCharsets.UTF_8); } static OAEPParameterSpec oaep() { // OAEP with SHA-256 AND MGF1-SHA-256 (the Java default would use SHA-1 for MGF1) return new OAEPParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT); } static PSSParameterSpec pss() { return new PSSParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 32, 1); } static byte[] random(int n) { byte[] b = new byte[n]; RNG.nextBytes(b); return b; } static String b64(byte[] b) { return Base64.getEncoder().encodeToString(b); } public static Map buildRequest(String clientKeyId, String serverKeyId, String path, String json, PrivateKey clientPrivate, PublicKey serverPublic) throws Exception { byte[] aesKey = random(32), iv = random(12); Map e = new LinkedHashMap<>(); e.put("clientKeyId", clientKeyId); e.put("serverKeyId", serverKeyId); e.put("timestamp", Instant.now().truncatedTo(ChronoUnit.SECONDS).toString()); // e.g. 2026-10-09T12:00:00Z e.put("nonce", b64(random(16))); e.put("iv", b64(iv)); Cipher rsa = Cipher.getInstance("RSA/ECB/OAEPPadding"); rsa.init(Cipher.ENCRYPT_MODE, serverPublic, oaep()); e.put("encryptedKey", b64(rsa.doFinal(aesKey))); Cipher aes = Cipher.getInstance("AES/GCM/NoPadding"); aes.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(aesKey, "AES"), new GCMParameterSpec(128, iv)); aes.updateAAD(aad(e)); e.put("payload", b64(aes.doFinal(json.getBytes(StandardCharsets.UTF_8)))); // ciphertext || 16-byte tag Signature sig = Signature.getInstance("RSASSA-PSS"); sig.setParameter(pss()); sig.initSign(clientPrivate); sig.update(signedText("REQUEST", path, e).getBytes(StandardCharsets.UTF_8)); e.put("signature", b64(sig.sign())); return e; } public static String openResponse(String responseBody, String requestNonce, PrivateKey clientPrivate, PublicKey serverPublic) throws Exception { return open("RESPONSE", requestNonce, responseBody, clientPrivate, serverPublic); } /** Verifies and decrypts a WEBHOOK we sent you; eventId is the X-Webhook-Id header of the call. Returns the JSON text. */ public static String openWebhook(String body, String eventId, PrivateKey clientPrivate, PublicKey serverPublic) throws Exception { return open("WEBHOOK", eventId, body, clientPrivate, serverPublic); } static String open(String kind, String third, String responseBody, PrivateKey clientPrivate, PublicKey serverPublic) throws Exception { Map r = new HashMap<>(); for (String f : new String[]{"clientKeyId", "serverKeyId", "timestamp", "nonce", "encryptedKey", "iv", "payload", "signature"}) r.put(f, field(responseBody, f)); Signature sig = Signature.getInstance("RSASSA-PSS"); sig.setParameter(pss()); sig.initVerify(serverPublic); sig.update(signedText(kind, third, r).getBytes(StandardCharsets.UTF_8)); if (!sig.verify(Base64.getDecoder().decode(r.get("signature")))) throw new SecurityException("Server signature does not verify - do not trust this response"); Cipher rsa = Cipher.getInstance("RSA/ECB/OAEPPadding"); rsa.init(Cipher.DECRYPT_MODE, clientPrivate, oaep()); byte[] aesKey = rsa.doFinal(Base64.getDecoder().decode(r.get("encryptedKey"))); Cipher aes = Cipher.getInstance("AES/GCM/NoPadding"); aes.init(Cipher.DECRYPT_MODE, new SecretKeySpec(aesKey, "AES"), new GCMParameterSpec(128, Base64.getDecoder().decode(r.get("iv")))); aes.updateAAD(aad(r)); return new String(aes.doFinal(Base64.getDecoder().decode(r.get("payload"))), StandardCharsets.UTF_8); } // ------------------------------------------------------------------ helpers static byte[] pemBody(String pem) { return Base64.getMimeDecoder().decode(pem.replaceAll("-----[A-Z ]+-----", "").replaceAll("\\s", "")); } static PublicKey publicKey(String pem) throws Exception { return KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(pemBody(pem))); } static PrivateKey privateKey(String pem) throws Exception { // PKCS#8 ("BEGIN PRIVATE KEY") return KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(pemBody(pem))); } static String field(String json, String name) { // the envelope only holds simple string fields Matcher m = Pattern.compile("\"" + name + "\"\\s*:\\s*\"([^\"]*)\"").matcher(json); if (!m.find()) throw new IllegalArgumentException("field missing in response: " + name); return unescape(m.group(1)); } /** Undoes JSON string escapes (unicode escapes, escaped slashes ...) - a JSON library does this for you; this file avoids dependencies. */ static String unescape(String s) { if (s.indexOf('\\') < 0) return s; StringBuilder sb = new StringBuilder(); for (int i = 0; i < s.length(); i++) { char c = s.charAt(i); if (c != '\\' || i + 1 >= s.length()) { sb.append(c); continue; } char n = s.charAt(++i); switch (n) { case 'n' -> sb.append('\n'); case 'r' -> sb.append('\r'); case 't' -> sb.append('\t'); case 'b' -> sb.append('\b'); case 'f' -> sb.append('\f'); case 'u' -> { sb.append((char) Integer.parseInt(s.substring(i + 1, i + 5), 16)); i += 4; } default -> sb.append(n); // \\ \" \/ } } return sb.toString(); } static String toJson(Map e) { StringBuilder sb = new StringBuilder("{"); for (Map.Entry en : e.entrySet()) sb.append(sb.length() > 1 ? "," : "").append('"').append(en.getKey()).append("\":\"").append(en.getValue()).append('"'); return sb.append('}').toString(); } public static void main(String[] args) throws Exception { if (args.length == 0) { System.err.println("usage: keygen | build-request ... | open-response ..."); System.exit(2); } Map a = new HashMap<>(); for (int i = 1; i + 1 < args.length; i += 2) a.put(args[i].replaceFirst("^--", ""), args[i + 1]); switch (args[0]) { case "keygen" -> { KeyPairGenerator g = KeyPairGenerator.getInstance("RSA"); g.initialize(3072); KeyPair kp = g.generateKeyPair(); Path dir = Path.of(args.length > 1 ? args[1] : "."); Files.writeString(dir.resolve("client-private.pem"), "-----BEGIN PRIVATE KEY-----\n" + Base64.getMimeEncoder(64, "\n".getBytes()).encodeToString(kp.getPrivate().getEncoded()) + "\n-----END PRIVATE KEY-----\n"); Files.writeString(dir.resolve("client-public.pem"), "-----BEGIN PUBLIC KEY-----\n" + Base64.getMimeEncoder(64, "\n".getBytes()).encodeToString(kp.getPublic().getEncoded()) + "\n-----END PUBLIC KEY-----\n"); System.out.println("Wrote client-private.pem (KEEP SECRET) and client-public.pem (register this one) in " + dir); } case "build-request" -> System.out.print(toJson(buildRequest(a.get("client-key-id"), a.get("server-key-id"), a.get("path"), a.get("json"), privateKey(Files.readString(Path.of(a.get("client-private")))), publicKey(Files.readString(Path.of(a.get("server-public"))))))); case "open-response" -> System.out.print(openResponse(Files.readString(Path.of(a.get("response"))), field(Files.readString(Path.of(a.get("request-envelope"))), "nonce"), privateKey(Files.readString(Path.of(a.get("client-private")))), publicKey(Files.readString(Path.of(a.get("server-public")))))); case "open-webhook" -> System.out.print(openWebhook(Files.readString(Path.of(a.get("body"))), a.get("event-id"), privateKey(Files.readString(Path.of(a.get("client-private")))), publicKey(Files.readString(Path.of(a.get("server-public")))))); default -> { System.err.println("unknown command " + args[0]); System.exit(2); } } } }