# Merchant Integration · signing toolkit for Ruby 2.6+ (standard library 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: ruby merchant_sign.rb require 'openssl' require 'securerandom' require 'json' module MerchantSign HEADER_MERCHANT_CODE = 'X-Merchant-Code'.freeze HEADER_REQUEST_ID = 'X-Request-Id'.freeze HEADER_TIMESTAMP = 'X-Timestamp'.freeze HEADER_SIGNATURE = 'X-Signature'.freeze TIMESTAMP_WINDOW_MS = 300_000 # |now - X-Timestamp| must be within 300 s OK = 'OK'.freeze INVALID_SIGNATURE = 'INVALID_SIGNATURE'.freeze TIMESTAMP_EXPIRED = 'TIMESTAMP_EXPIRED'.freeze module_function # Lower-case hex SHA-256 of the exact body bytes (empty body included). def body_sha256(raw_body) OpenSSL::Digest::SHA256.hexdigest(raw_body.b) end def string_to_sign(method, path, merchant_code, timestamp, request_id, raw_body) [method.upcase, path, merchant_code, timestamp.to_s, request_id, body_sha256(raw_body)].join("\n") end # X-Signature: lower-case hex HMAC-SHA256(secretKey, stringToSign). def sign(secret_key, method, path, merchant_code, timestamp, request_id, raw_body) OpenSSL::HMAC.hexdigest('SHA256', secret_key, string_to_sign(method, path, merchant_code, timestamp, request_id, raw_body)) end # A fresh X-Request-Id (32 hex characters). Use a new one for every request, retries included. def new_request_id SecureRandom.hex(16) end def now_ms (Time.now.to_f * 1000).floor end # Headers for one signed request. Send exactly raw_body — the bytes that were signed. def signed_headers(merchant_code, secret_key, method, path, raw_body, now = nil, request_id = nil) ts = (now || now_ms).to_s rid = request_id || new_request_id { 'Content-Type' => 'application/json; charset=utf-8', HEADER_MERCHANT_CODE => merchant_code, HEADER_REQUEST_ID => rid, HEADER_TIMESTAMP => ts, HEADER_SIGNATURE => sign(secret_key, method, path, merchant_code, ts, rid, raw_body) } end # Check one incoming request (our Wallet API calls to you). Returns OK, INVALID_SIGNATURE or TIMESTAMP_EXPIRED, # usable as the envelope code. Verify the raw body (request.raw_post / body.read) BEFORE JSON.parse. def verify(secret_key, merchant_code, headers, method, path, raw_body, now = nil) h = headers.each_with_object({}) { |(k, v), o| o[k.to_s.downcase.tr('_', '-').sub(/\Ahttp-/, '')] = v.to_s } code = h['x-merchant-code'].to_s rid = h['x-request-id'].to_s ts = h['x-timestamp'].to_s sig = h['x-signature'].to_s.downcase return INVALID_SIGNATURE if code.empty? || rid.empty? || ts.empty? || sig.empty? || code != merchant_code return TIMESTAMP_EXPIRED unless ts.match?(/\A\d+\z/) return TIMESTAMP_EXPIRED if ((now || now_ms) - ts.to_i).abs > TIMESTAMP_WINDOW_MS secure_compare(sign(secret_key, method, path, code, ts, rid, raw_body), sig) ? OK : INVALID_SIGNATURE end # Constant-time string comparison (OpenSSL.fixed_length_secure_compare needs Ruby 2.7+). def secure_compare(a, b) return false unless a.bytesize == b.bytesize r = 0 a.bytes.zip(b.bytes) { |x, y| r |= x ^ y } r.zero? end # A response body: {code, message, data}. Business results are always HTTP 200. def envelope(code, message = '', data = nil) out = { 'code' => code, 'message' => message.empty? ? code : message } out['data'] = data unless data.nil? JSON.generate(out) end end if $PROGRAM_NAME == __FILE__ key = 'test-secret-key-0001'; path = '/wallet/debit'; code = 'M0001'; ts = '1792000000000'; rid = 'req-0001' body = '{"txnId":"T7K2M9QXH4ZP8WNA","amount":"10.00"}' check = ->(ok, what) { abort("FAIL: #{what}") unless ok } check.call(MerchantSign.body_sha256(body) == 'b8d0525520debc2d5e2c62e83d2303a4cd29ed0c18c4139e98196c76c14fc2b8', 'body hash') sig = MerchantSign.sign(key, 'POST', path, code, ts, rid, body) check.call(sig == 'e5273ee9fb4c05408af2f8b32b26b4b98bc891fb77e5ffe5b46f9c112ebe6589', "signature #{sig}") now = 1_792_000_000_000 h = MerchantSign.signed_headers(code, key, 'POST', path, body, now, rid) check.call(MerchantSign.verify(key, code, h, 'POST', path, body, now) == MerchantSign::OK, 'verify') check.call(MerchantSign.verify(key, code, h, 'POST', path, body.sub('10.00', '10.01'), now) == MerchantSign::INVALID_SIGNATURE, 'tampered') check.call(MerchantSign.verify(key, 'M0002', h, 'POST', path, body, now) == MerchantSign::INVALID_SIGNATURE, 'other merchant') check.call(MerchantSign.verify(key, code, h, 'POST', path, body, now + MerchantSign::TIMESTAMP_WINDOW_MS + 1) == MerchantSign::TIMESTAMP_EXPIRED, 'stale') puts "merchant_sign.rb: self-test passed #{sig}" end