// 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); }
}
}
}