// Merchant Integration · signing toolkit for .NET 6+ (BCL only).
// Signs requests you send to the Merchant API and verifies the Wallet API requests we send to you.
// Spec: Integration Guide › Authentication. Self-test against the published test vector: call MerchantSign.SelfTest().
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
public static class MerchantSign
{
public const string HeaderMerchantCode = "X-Merchant-Code";
public const string HeaderRequestId = "X-Request-Id";
public const string HeaderTimestamp = "X-Timestamp";
public const string HeaderSignature = "X-Signature";
public const long TimestampWindowMs = 300_000; // |now - X-Timestamp| must be within 300 s
public const string Ok = "OK";
public const string InvalidSignature = "INVALID_SIGNATURE";
public const string TimestampExpired = "TIMESTAMP_EXPIRED";
/// Lower-case hex SHA-256 of the exact body bytes (empty body included).
public static string BodySha256(byte[] rawBody) => Convert.ToHexString(SHA256.HashData(rawBody)).ToLowerInvariant();
public static string StringToSign(string method, string path, string merchantCode, string timestamp, string requestId, byte[] rawBody) =>
string.Join("\n", method.ToUpperInvariant(), path, merchantCode, timestamp, requestId, BodySha256(rawBody));
/// X-Signature: lower-case hex HMAC-SHA256(secretKey, stringToSign).
public static string Sign(string secretKey, string method, string path, string merchantCode, string timestamp, string requestId, byte[] rawBody)
{
using var mac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey));
var msg = Encoding.UTF8.GetBytes(StringToSign(method, path, merchantCode, timestamp, requestId, rawBody));
return Convert.ToHexString(mac.ComputeHash(msg)).ToLowerInvariant();
}
/// A fresh X-Request-Id (32 hex characters). Use a new one for every request, retries included.
public static string NewRequestId() => Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
public static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
/// Headers for one signed request (Content-Type goes on the HttpContent). Send exactly rawBody — the bytes that were signed.
public static Dictionary SignedHeaders(string merchantCode, string secretKey, string method, string path, byte[] rawBody,
long? nowMs = null, string? requestId = null)
{
var ts = (nowMs ?? NowMs()).ToString();
var rid = requestId ?? NewRequestId();
return new Dictionary
{
[HeaderMerchantCode] = merchantCode,
[HeaderRequestId] = rid,
[HeaderTimestamp] = ts,
[HeaderSignature] = Sign(secretKey, method, path, merchantCode, ts, rid, rawBody),
};
}
///
/// Check one incoming request (our Wallet API calls to you). Returns Ok, InvalidSignature or TimestampExpired, usable as the
/// envelope code. Read the raw body first and verify BEFORE deserializing JSON.
///
public static string Verify(string secretKey, string merchantCode, IDictionary headers, string method, string path,
byte[] rawBody, long? nowMs = null)
{
var h = headers.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase);
h.TryGetValue(HeaderMerchantCode, out var code);
h.TryGetValue(HeaderRequestId, out var rid);
h.TryGetValue(HeaderTimestamp, out var ts);
h.TryGetValue(HeaderSignature, out var sig);
if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(rid) || string.IsNullOrEmpty(ts) || string.IsNullOrEmpty(sig) || code != merchantCode)
return InvalidSignature;
if (!long.TryParse(ts, out var sent) || ts.Any(c => c < '0' || c > '9'))
return TimestampExpired;
if (Math.Abs((nowMs ?? NowMs()) - sent) > TimestampWindowMs)
return TimestampExpired;
var want = Encoding.ASCII.GetBytes(Sign(secretKey, method, path, code, ts, rid, rawBody));
var got = Encoding.ASCII.GetBytes(sig.ToLowerInvariant());
return CryptographicOperations.FixedTimeEquals(want, got) ? Ok : InvalidSignature;
}
public static void SelfTest()
{
const string key = "test-secret-key-0001", path = "/wallet/debit", code = "M0001", ts = "1792000000000", rid = "req-0001";
var body = Encoding.UTF8.GetBytes("{\"txnId\":\"T7K2M9QXH4ZP8WNA\",\"amount\":\"10.00\"}");
Check(BodySha256(body) == "b8d0525520debc2d5e2c62e83d2303a4cd29ed0c18c4139e98196c76c14fc2b8", "body hash");
var sig = Sign(key, "POST", path, code, ts, rid, body);
Check(sig == "e5273ee9fb4c05408af2f8b32b26b4b98bc891fb77e5ffe5b46f9c112ebe6589", "signature " + sig);
const long now = 1792000000000;
var h = SignedHeaders(code, key, "POST", path, body, now, rid);
Check(Verify(key, code, h, "POST", path, body, now) == Ok, "verify");
var tampered = Encoding.UTF8.GetBytes("{\"txnId\":\"T7K2M9QXH4ZP8WNA\",\"amount\":\"10.01\"}");
Check(Verify(key, code, h, "POST", path, tampered, now) == InvalidSignature, "tampered");
Check(Verify(key, "M0002", h, "POST", path, body, now) == InvalidSignature, "other merchant");
Check(Verify(key, code, h, "POST", path, body, now + TimestampWindowMs + 1) == TimestampExpired, "stale");
Console.WriteLine("MerchantSign.cs: self-test passed " + sig);
}
private static void Check(bool ok, string what)
{
if (!ok) throw new Exception("self-test failed: " + what);
}
}