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