# Verify callback signatures


Each callback carries `x-ca-signature`, a base64 RSA-SHA256 signature (PKCS#1 v1.5) made with the gateway's private key. Verify it with the public key from `GET /pubkey/`.

## What is signed

| Callback method | Signed bytes |
| --- | --- |
| `GET` | The full request URL as it was sent: scheme, host, path and the original query string in its original order. |
| `POST` (form or JSON) | The exact raw request body, before parsing. |

> [!WARNING]
> Do not re-encode the query string or re-serialize the body. Capture the raw bytes first and parse afterwards.

> [!WARNING]
> Behind a proxy or load balancer, rebuild the URL from your own configured external origin (scheme, host and port if present) plus the original request target. Never take the host from an untrusted `Host` or `X-Forwarded-*` header.

## Get the public key

```bash
curl "https://api.suawave.com/pubkey/"
```

The response contains a PKIX `PUBLIC KEY` PEM in the `pubkey` field. Keys are RSA with at least 2048 bits. Fetch it over HTTPS once, store it, and refresh it only when you are told about a key rotation. Do not fetch it on every callback. Reject keys shorter than 2048 bits.

## Examples

Each example takes the HTTP method, the request target (path and query, exactly as received), the raw body, the signature header, the public key PEM and your trusted origin, and returns whether the signature is valid. A failed check must answer HTTP 401 and do no work.

Get the request target from your framework's raw request URI (Express `req.originalUrl`, PHP `$_SERVER['REQUEST_URI']`, ASP.NET Core `GetEncodedPathAndQuery()`), and the raw body before any JSON or form parsing (Express `express.raw`, PHP `file_get_contents('php://input')`).

### Go

The Go example uses only the standard library and is compiled and tested against the gateway's own signer.

```go
package verify

import (
	"bytes"
	"crypto"
	"crypto/rsa"
	"crypto/sha256"
	"crypto/x509"
	"encoding/base64"
	"encoding/pem"
	"errors"
	"io"
	"net/http"
)

func Callback(r *http.Request, publicPEM []byte, trustedOrigin string) error {
	block, _ := pem.Decode(publicPEM)
	if block == nil {
		return errors.New("invalid public key PEM")
	}
	parsed, err := x509.ParsePKIXPublicKey(block.Bytes)
	if err != nil {
		return err
	}
	key, ok := parsed.(*rsa.PublicKey)
	if !ok || key.N.BitLen() < 2048 {
		return errors.New("invalid RSA public key")
	}
	var signed []byte
	switch r.Method {
	case http.MethodGet:
		signed = []byte(trustedOrigin + r.RequestURI)
	case http.MethodPost:
		signed, err = io.ReadAll(io.LimitReader(r.Body, 65537))
		if err != nil {
			return err
		}
		if len(signed) > 65536 {
			return errors.New("callback body too large")
		}
		r.Body = io.NopCloser(bytes.NewReader(signed))
	default:
		return errors.New("unsupported callback method")
	}
	signature, err := base64.StdEncoding.DecodeString(r.Header.Get("x-ca-signature"))
	if err != nil {
		return err
	}
	digest := sha256.Sum256(signed)
	return rsa.VerifyPKCS1v15(key, crypto.SHA256, digest[:], signature)
}
```

### C#

```csharp
using System;
using System.Security.Cryptography;
using System.Text;

public static class CallbackVerifier
{
    public static bool Verify(string method, string requestUri, byte[] rawBody,
        string signatureBase64, string publicKeyPem, string trustedOrigin)
    {
        byte[] signed;
        if (method == "GET")
        {
            signed = Encoding.UTF8.GetBytes(trustedOrigin + requestUri);
        }
        else if (method == "POST" && rawBody.Length <= 65536)
        {
            signed = rawBody;
        }
        else
        {
            return false;
        }

        byte[] signature;
        try
        {
            signature = Convert.FromBase64String(signatureBase64);
        }
        catch (FormatException)
        {
            return false;
        }

        using var rsa = RSA.Create();
        rsa.ImportFromPem(publicKeyPem);
        return rsa.VerifyData(signed, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
    }
}
```

### JavaScript (Node.js)

```javascript
const crypto = require('crypto');

function verifyCallback({ method, requestUri, rawBody, signature }, publicKeyPem, trustedOrigin) {
  let signed;
  if (method === 'GET') {
    signed = Buffer.from(trustedOrigin + requestUri, 'utf8');
  } else if (method === 'POST') {
    if (!Buffer.isBuffer(rawBody) || rawBody.length > 65536) return false;
    signed = rawBody;
  } else {
    return false;
  }
  if (!signature || !/^[A-Za-z0-9+/]+={0,2}$/.test(signature)) return false;
  const verifier = crypto.createVerify('RSA-SHA256');
  verifier.update(signed);
  return verifier.verify(publicKeyPem, signature, 'base64');
}

module.exports = { verifyCallback };
```

### PHP

```php
<?php

function verify_callback(string $method, string $requestUri, string $rawBody, string $signatureBase64, string $publicKeyPem, string $trustedOrigin): bool
{
    if ($method === 'GET') {
        $signed = $trustedOrigin . $requestUri;
    } elseif ($method === 'POST') {
        if (strlen($rawBody) > 65536) {
            return false;
        }
        $signed = $rawBody;
    } else {
        return false;
    }
    $signature = base64_decode($signatureBase64, true);
    if ($signature === false) {
        return false;
    }
    return openssl_verify($signed, $signature, $publicKeyPem, OPENSSL_ALGO_SHA256) === 1;
}
```

## After verification

A valid signature proves the request came from the gateway. It does not prove the payment is the one you expect. Also:

- Compare the `nonce` and order reference in your callback URL with the stored order.
- Check `address_in`, `coin` and `value_coin` against what you expect.
- Persist the `uuid` and ignore repeats.
- Answer `200` with `*ok*` within 30 seconds.
