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