// Package merchantsign is the signing toolkit for Go 1.18+ (standard library only). // // It 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: go test ./... package merchantsign import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "net/http" "strconv" "strings" "time" ) // Header names. const ( HeaderMerchantCode = "X-Merchant-Code" HeaderRequestID = "X-Request-Id" HeaderTimestamp = "X-Timestamp" HeaderSignature = "X-Signature" ) // TimestampWindow |now - X-Timestamp| must be within this. const TimestampWindow = 300 * time.Second // Verification results, usable directly as the envelope code. const ( OK = "OK" InvalidSignature = "INVALID_SIGNATURE" TimestampExpired = "TIMESTAMP_EXPIRED" ) // BodySHA256 lower-case hex SHA-256 of the exact body bytes (empty body included). func BodySHA256(rawBody []byte) string { h := sha256.Sum256(rawBody) return hex.EncodeToString(h[:]) } // StringToSign METHOD \n PATH \n X-Merchant-Code \n X-Timestamp \n X-Request-Id \n hex(SHA256(body)). func StringToSign(method, path, merchantCode, timestamp, requestID string, rawBody []byte) string { return strings.Join([]string{strings.ToUpper(method), path, merchantCode, timestamp, requestID, BodySHA256(rawBody)}, "\n") } // Sign X-Signature: lower-case hex HMAC-SHA256(secretKey, stringToSign). func Sign(secretKey, method, path, merchantCode, timestamp, requestID string, rawBody []byte) string { mac := hmac.New(sha256.New, []byte(secretKey)) mac.Write([]byte(StringToSign(method, path, merchantCode, timestamp, requestID, rawBody))) return hex.EncodeToString(mac.Sum(nil)) } // NewRequestID a fresh X-Request-Id (32 hex characters). Use a new one for every request, retries included. func NewRequestID() string { b := make([]byte, 16) _, _ = rand.Read(b) return hex.EncodeToString(b) } // SignRequest sets Content-Type and the four signing headers on req. Send exactly rawBody — the bytes that were signed. func SignRequest(req *http.Request, merchantCode, secretKey string, rawBody []byte, now time.Time) { ts := strconv.FormatInt(now.UnixMilli(), 10) rid := NewRequestID() req.Header.Set("Content-Type", "application/json; charset=utf-8") req.Header.Set(HeaderMerchantCode, merchantCode) req.Header.Set(HeaderRequestID, rid) req.Header.Set(HeaderTimestamp, ts) req.Header.Set(HeaderSignature, Sign(secretKey, req.Method, req.URL.EscapedPath(), merchantCode, ts, rid, rawBody)) } // Verify checks one incoming request (our Wallet API calls to you) and returns OK, InvalidSignature or TimestampExpired. // Read the raw body first and verify BEFORE decoding JSON; path is r.URL.EscapedPath(). func Verify(secretKey, merchantCode string, h http.Header, method, path string, rawBody []byte, now time.Time) string { code, rid, ts, sig := h.Get(HeaderMerchantCode), h.Get(HeaderRequestID), h.Get(HeaderTimestamp), strings.ToLower(h.Get(HeaderSignature)) if code == "" || rid == "" || ts == "" || sig == "" || code != merchantCode { return InvalidSignature } ms, err := strconv.ParseInt(ts, 10, 64) if err != nil { return TimestampExpired } if d := now.Sub(time.UnixMilli(ms)); d > TimestampWindow || d < -TimestampWindow { return TimestampExpired } if !hmac.Equal([]byte(Sign(secretKey, method, path, code, ts, rid, rawBody)), []byte(sig)) { return InvalidSignature } return OK } // Envelope a response body: {"code","message","data"}. Business results are always HTTP 200. func Envelope(code, message string, data any) []byte { if message == "" { message = code } out := map[string]any{"code": code, "message": message} if data != nil { out["data"] = data } b, _ := json.Marshal(out) return b }