// FZP1 client for the Finzep payout API (.NET 6+). Specification: payout-api-encryption.txt
//
// var client = new FzpClient(http, apiKey, clientKeyId, clientPrivateKey /* RSA */, serverKeyId, serverPublicKey /* RSA */);
// var (status, json) = await client.PostAsync("/API/Payout", "{\"APIRequestID\":\"REQ-1\", ...}");
//
// The request is encrypted for OUR public key and signed with YOUR private key; the answer is verified with OUR public key
// and decrypted with YOUR private key. PostAsync throws if the answer's signature does not verify.
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
public sealed class FzpClient
{
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
private readonly HttpClient _http;
private readonly string _apiKey, _clientKeyId, _serverKeyId;
private readonly RSA _clientPrivate, _serverPublic;
public FzpClient(HttpClient http, string apiKey, string clientKeyId, RSA clientPrivateKey, string serverKeyId, RSA serverPublicKey)
{
_http = http; _apiKey = apiKey; _clientKeyId = clientKeyId; _clientPrivate = clientPrivateKey; _serverKeyId = serverKeyId; _serverPublic = serverPublicKey;
}
public sealed class Envelope
{
public string? ClientKeyId { get; set; }
public string? ServerKeyId { get; set; }
public string? Timestamp { get; set; }
public string? Nonce { get; set; }
public string? EncryptedKey { get; set; }
public string? Iv { get; set; }
public string? Payload { get; set; }
public string? Signature { get; set; }
}
private static string SignedText(string kind, string pathOrRequestNonce, Envelope e) =>
string.Join("\n", "FZP1", kind, pathOrRequestNonce, e.ClientKeyId, e.ServerKeyId, e.Timestamp, e.Nonce, e.EncryptedKey, e.Iv, e.Payload);
private static byte[] Aad(Envelope e) => Encoding.UTF8.GetBytes($"{e.Timestamp}\n{e.Nonce}");
/// Builds the envelope for one request (exposed so it can be inspected / tested).
public Envelope BuildRequest(string path, string json)
{
var aesKey = RandomNumberGenerator.GetBytes(32);
var iv = RandomNumberGenerator.GetBytes(12);
var e = new Envelope
{
ClientKeyId = _clientKeyId,
ServerKeyId = _serverKeyId,
Timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'"),
Nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)),
Iv = Convert.ToBase64String(iv),
EncryptedKey = Convert.ToBase64String(_serverPublic.Encrypt(aesKey, RSAEncryptionPadding.OaepSHA256))
};
var plain = Encoding.UTF8.GetBytes(json);
var cipher = new byte[plain.Length];
var tag = new byte[16];
using (var aes = new AesGcm(aesKey, 16)) aes.Encrypt(iv, plain, cipher, tag, Aad(e));
e.Payload = Convert.ToBase64String(cipher.Concat(tag).ToArray());
e.Signature = Convert.ToBase64String(_clientPrivate.SignData(Encoding.UTF8.GetBytes(SignedText("REQUEST", path, e)), HashAlgorithmName.SHA256, RSASignaturePadding.Pss));
return e;
}
/// Verifies our signature and decrypts the answer. Throws CryptographicException when anything does not check out.
public string OpenResponse(string responseBody, Envelope request) => Open("RESPONSE", request.Nonce!, responseBody);
/// Verifies and decrypts a WEBHOOK we sent you; eventId is the X-Webhook-Id header of the call. Returns the JSON text.
public string OpenWebhook(string body, string eventId) => Open("WEBHOOK", eventId, body);
private string Open(string kind, string third, string body)
{
var r = JsonSerializer.Deserialize(body, Json) ?? throw new CryptographicException("Empty envelope.");
var valid = _serverPublic.VerifyData(Encoding.UTF8.GetBytes(SignedText(kind, third, r)),
Convert.FromBase64String(r.Signature ?? ""), HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
if (!valid) throw new CryptographicException("Server signature does not verify - do not trust this response.");
var aesKey = _clientPrivate.Decrypt(Convert.FromBase64String(r.EncryptedKey!), RSAEncryptionPadding.OaepSHA256);
var payload = Convert.FromBase64String(r.Payload!);
var plain = new byte[payload.Length - 16];
using var aes = new AesGcm(aesKey, 16);
aes.Decrypt(Convert.FromBase64String(r.Iv!), payload.AsSpan(0, plain.Length), payload.AsSpan(plain.Length), plain, Aad(r));
return Encoding.UTF8.GetString(plain);
}
/// Sends one encrypted call. Returns the HTTP status and the decrypted JSON answer (or the plain JSON of an early error).
public async Task<(int Status, string Json)> PostAsync(string path, string json)
{
var request = BuildRequest(path, json);
using var message = new HttpRequestMessage(HttpMethod.Post, path)
{
Content = new StringContent(JsonSerializer.Serialize(request, Json), Encoding.UTF8, new MediaTypeHeaderValue("application/json"))
};
message.Headers.Add("X-Api-Key", _apiKey);
message.Headers.Add("X-Encrypted", "1");
using var response = await _http.SendAsync(message);
var body = await response.Content.ReadAsStringAsync();
// errors found before the message could be trusted (bad key, IP, rate limit, bad envelope ...) are plain JSON
return response.Headers.Contains("X-Encrypted") ? ((int)response.StatusCode, OpenResponse(body, request)) : ((int)response.StatusCode, body);
}
}