Verify webhooks

Numeral offers two ways to verify webhooks: IP addresses and webhook signature. Although they are not mandatory, we strongly recommend using either one and ideally both.

1. IP filtering

Webhooks sent by Numeral will always originate from the same IPs. We recommend filtering the following IPs:

13.37.193.204
13.37.147.243
15.237.134.222
13.37.79.36

2. Webhook signature

2.1. Introduction

Numeral webhooks are signed using HTTP message signatures and a standard “signature base” approach.

Each request comes with two specific HTTP headers:

  • Signature-Input: Describes how the signature was generated (which components are signed, the signing algorithm, the key ID, etc.).
  • Signature: Contains the actual signature bytes in Base64 form.

Verifying these signatures is strongly recommended to ensure that each request truly originates from Numeral and has not been altered. The signature verification process uses standard RSA signatures with PKCS#1 v1.5 padding (“rsa-v1_5-sha256”) and a matching Numeral public key.

2.2. Signature verification process

Under this new system, Numeral constructs a content digest of the webhook body and includes it in the HTTP message signature. To verify, you must:

2.2.1. Locate the Signature-Input and Signature Headers

Numeral sets:

  • Signature-Input: Declares which fields are signed, the signing algorithm (alg), the keyid, and a creation time (created).
Signature-Input: sigx=("@method" "@authority" "@request-target" "content-digest");alg="rsa-v1_5-sha256";keyid="x";created=1734087053
  • Signature: Holds the actual signature bytes in Base64, labeled by the same name (sigx):
Signature: sigx=:<Base64Signature>:

2.2.2. Compute the content-digest

  • Hash only the raw request body (exact JSON, no modifications) using SHA-256.
  • Encode the resulting hash in Base64.
  • Format it as sha-256=::.

Numeral includes this as a header line in the signature base (see below).

2.2.3. Construct the Signature Base from Signature-Input

The Signature-Input header identifies which pseudo-headers and HTTP fields Numeral has signed. Typically, this includes:

  • @method: The HTTP method (POST).
  • @authority: The domain or host:port from the request URL.
  • @request-target: The request path (plus query if any).
  • @content-digest: The sha-256=: line you computed.

Numeral also references @signature-params inside the same signature base, which includes the algorithm, the key ID, and the creation timestamp.

2.2.4. Reproduce Those Lines Exactly and Concatenate Them

For example, if Signature-Input says it signs ("@method" "@authority" "@request-target" "content-digest"), you create a multi-line string like:

"@method": POST
"@authority": <your domain>
"@request-target": <the path + query, e.g. /webhook?foo=bar>
"content-digest": sha-256=:<Base64OfBodyHash>:
"@signature-params": ("@method" "@authority" "@request-target" "content-digest");alg="rsa-v1_5-sha256";keyid="test-key-id-2";created=1734087053

This string is your “signature base.”

2.2.5. Verify with Numeral’s Public Key

  • Convert Numeral’s PEM public key into a usable format for your environment.
  • Base64-decode the Signature header’s signature bytes.
  • Use RSA PKCS#1 v1.5 with SHA-256 (often labeled “rsa-v1_5-sha256”) to verify that multi-line string.

If verification succeeds, you can be sure Numeral sent the message and that the body was not altered.

2.3. Key rotation

Numeral may rotate its public keys over time. The keyid in Signature-Input indicates which key was used. Numeral will supply updated PEM public keys well in advance of any rotation deadlines. Make sure your verification logic can handle multiple potential key IDs concurrently if needed.

2.4. Code samples

Below are some code samples in various languages. Contact us at [email protected] if you'd like us to document additional languages.

package main

import (
	"crypto"
	"crypto/rsa"
	"crypto/sha256"
	"crypto/x509"
	"encoding/base64"
	"encoding/pem"
	"errors"
	"fmt"
	"log"
	"strings"
)

// signatureHeader and signatureInputHeader demonstrate multiple signatures
// and signature inputs for two labels: "sigtest-key-1" and "sigtest-key-2".
//
// In a real scenario, these would come from HTTP headers:
//
//	Signature:       sigtest-key-2=:<base64>: sigtest-key-1=:<base64>:
//	Signature-Input: sigtest-key-2=("@method" "@authority" "@request-target" "content-digest");...
var (
	signatureHeader      = `sigtest-key-2=:Jx/NEyQFDL016AVXCFdfC2Ggh/rdJk/zBYYcyQ36QGPYx/Vpxsm74C3dvdK0NHvaPfKw/iecLdHsedKwqetVe9e8eGGS+f86UBCCt4H4gcpSz/Pver7vRH3Y7mDwmKxKoCS+pCeDdgYbrj2ubI4evA8N56mW59vx9CYGNoH31gZIbsSlwrqhSB5zNUs+kulV57G2wJQVkXXfNxZCWEuHdTu9tJ9IsZI/eB+GzXbLqy7Xfchna1/lEk9pGMDWwLs1WFTZ6CQqNpFZ0ObrklIbdsg4Ij6kt6CbLv319yN7hhwrSMQNXjOB8ear9tLQmh0l6gkKG9mhgdWwYWKD4lCPAA==: sigtest-key-1=:AgCVKZg4MRgQgUJp5BWGA68IQzffRfD4tCE3854mY75tnGwBhP58G6nQ7tTs83K8asG4QlEBvY4DNehkatyb6aJ0gBpYWw4SaGrNtN9YV56YiRm0UDgMJvRNwVlsmmIGXi3QS8jrjxiYxWl7mIDWMmsN8WuQ22/K/IFFqBtzKfDQ4lbo8DJSVcn6I9J2nwWAoYPawQQT+SttImQwyx58ElfYQAdUQXaOk1J4W/wyxsi5jdLIz5ESLOf9I0goE0C/wSPbytnxgA18yVUJOwB3GO/TkmAd4I7dCVqodpCsPSwVbCGwKn3E4TnFuJk7zuHq7wlcCPaZvFgmMYWzbsLhDw==:`
	signatureInputHeader = `sigtest-key-2=("@method" "@authority" "@request-target" "content-digest");alg="rsa-v1_5-sha256";keyid="test-key-2";created=1737191021 sigtest-key-1=("@method" "@authority" "@request-target" "content-digest");alg="rsa-v1_5-sha256";keyid="test-key-1";created=1737191021`
)

// publicKeyPEM1 and publicKeyPEM2 are dummy RSA public keys. We reuse the same
// PEM for both "test-key-1" and "test-key-2" in this example. In real code,
// you'd provide separate PEM strings (or load from files, DB, KMS, etc.).
var (
	publicKeyPEM1 = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx0dGNKhTtKCuopFDYKGX
rlN712P2JayanbT1KsW7A5pR2dG9lTA1ZGh5xEyR2F6LUhMRYTtALWe5M2oEHTkK
yQ9mHJImQ+EIvtDGtxPtfeHm5rKl1jFmBPWb+Yh1JWqUjtOTsBbdOwSfAdAlb4Xw
n8/qBJ6LA1qlWbIvKWsRYj7S/70qSe4Yuqj3SKURMPEdZohTbb9U3viNMnFiDjav
nQbevra/H1Kc50sM8OugCSSSdsRQf8oXOb+MfvFlg6oEm8Js/affgUBOROH/s9t0
wW2HJgP1dshsnYI9yT1km6d/gLPUvw7gJMNR/rfkAPIkgpQijOMdVwwqInh/9rjl
jQIDAQAB
-----END PUBLIC KEY-----`

	// Simulate a rotation: here it's the same key, but in real usage it might differ.
	publicKeyPEM2 = publicKeyPEM1
)

// body is the JSON request body that was supposedly signed.
var body = `{"id":"9bee7f87-6da4-4ef2-a8f6-265fa16c5983","object":"event","topic":"payment_order","type":"approved","data":{"id":"5ac55abb-fe0c-4f23-8610-aaafca8d959c","type":"sepa","amount":21300,"object":"payment_order","status":"approved","purpose":"","currency":"EUR","metadata":{},"bank_data":{"file_id":"","message_id":"","end_to_end_id":"5ac55abbfe0c4f238610aaafca8d959c","transaction_id":"5ac55abbfe0c4f238610aaafca8d959c","original_instruction_id":"5ac55abbfe0c4f238610aaafca8d959c"},"direction":"credit","reference":"Beetax","created_at":"2025-01-17T13:52:14.794145Z","fee_option":"","value_date":"","auto_approval":false,"custom_fields":{},"retry_details":null,"status_details":"","idempotency_key":"","payment_file_id":"","treasury_option":"","connected_account":"00000000-1111-1111-1111-000000000011","receiving_account":{"bank_code":"SOMEBIC9XXX","holder_name":"test","account_number":"NL61ABNA3605998615","holder_address":{"city":"","line_1":"","line_2":"","country":"","postal_code":"","street_name":"","region_state":"","building_number":""}},"reconciled_amount":0,"originating_account":{"bank_code":"SOMEBIC9XXX","holder_name":"123123","account_number":"NL61ABNA3605998615","holder_address":{"city":"","line_1":"","line_2":"","country":"","postal_code":"","street_name":"","region_state":"","building_number":""},"creditor_identifier":""},"ultimate_originator":{"holder_name":"","holder_address":null,"organization_identification":{"other":"","bank_code":""}},"connected_account_id":"00000000-1111-1111-1111-000000000011","direct_debit_mandate":null,"receiving_account_id":"","reconciliation_status":"unreconciled","confidentiality_option":"","originating_account_id":"","direct_debit_mandate_id":"","requested_execution_date":""},"status":"created","status_details":"","related_object_id":"5ac55abb-fe0c-4f23-8610-aaafca8d959c","related_object_type":"payment_order","idempotency_key":"3c32d3e2-d4e1-4249-b5ee-be4927faf67f","created_at":"2025-01-18T09:03:41.643234Z"}`

func main() {
	//
	// 1) Load RSA public keys, storing them in a map keyed by the "keyid" used in the signature input.
	//    This map demonstrates how you might handle key rotation over time.
	//

	pubKey1, err := parseRSAPublicKeyFromPEM(publicKeyPEM1)
	if err != nil {
		log.Fatalf("Failed to parse publicKeyPEM1: %v", err)
	}
	pubKey2, err := parseRSAPublicKeyFromPEM(publicKeyPEM2)
	if err != nil {
		log.Fatalf("Failed to parse publicKeyPEM2: %v", err)
	}
	publicKeys := map[string]*rsa.PublicKey{
		"test-key-1": pubKey1,
		"test-key-2": pubKey2,
	}

	//
	// 2) We know from the "Signature-Input" header that two labels exist: "sigtest-key-1" and "sigtest-key-2".
	//    In real usage, you might parse the entire "Signature-Input" to discover these labels automatically.
	//

	labels := []string{"sigtest-key-1", "sigtest-key-2"}

	//
	// 3) For each label:
	//    - Extract "created" and "keyid" from the "Signature-Input" header
	//    - Extract the base64 signature from the "Signature" header
	//    - Look up the correct public key from your map
	//    - Build the signature base string
	//    - Verify the signature
	//

	for _, label := range labels {
		createdVal, err := findCreated(signatureInputHeader, label)
		if err != nil {
			log.Printf("Skipping label=%q: cannot find 'created' parameter: %v", label, err)
			continue
		}
		keyIDVal, err := findKeyID(signatureInputHeader, label)
		if err != nil {
			log.Printf("Skipping label=%q: cannot find 'keyid' parameter: %v", label, err)
			continue
		}
		base64Sig, err := findSignature(signatureHeader, label)
		if err != nil {
			log.Printf("Skipping label=%q: cannot find base64 signature: %v", label, err)
			continue
		}

		sigBytes, err := base64.StdEncoding.DecodeString(base64Sig)
		if err != nil {
			log.Printf("Skipping label=%q: base64 decode error: %v", label, err)
			continue
		}

		// Look up the correct public key for the discovered key ID (e.g., "test-key-1" or "test-key-2").
		pubKey, found := publicKeys[keyIDVal]
		if !found {
			log.Printf("Skipping label=%q: no public key found for keyid=%q", label, keyIDVal)
			continue
		}

		// Compute a SHA-256 of the request body for the "content-digest"
		bodyHash := sha256.Sum256([]byte(body))
		encodedHash := base64.StdEncoding.EncodeToString(bodyHash[:])

		// Build the signature base string. Here we sign:
		//   @method=POST, @authority, @request-target, content-digest,
		//   plus the final line for "@signature-params".
		signatureBase := fmt.Sprintf(
			"\"@method\": POST\n"+
				"\"@authority\": %s\n"+
				"\"@request-target\": %s\n"+
				"\"content-digest\": sha-256=:%s:\n"+
				"\"@signature-params\": (\"@method\" \"@authority\" \"@request-target\" \"content-digest\");"+
				"alg=\"rsa-v1_5-sha256\";keyid=\"%s\";created=%s",
			"httpdump.app",
			"/dumps/91db320b-c734-49e3-9f89-64518106c5c3",
			encodedHash,
			keyIDVal,   // e.g. "test-key-1"
			createdVal, // e.g. 1737191021
		)

		// Verify with RSA PKCS #1 v1.5
		hashed := sha256.Sum256([]byte(signatureBase))
		if err := rsa.VerifyPKCS1v15(pubKey, crypto.SHA256, hashed[:], sigBytes); err != nil {
			log.Printf("❌ Verification FAILED for label=%q (keyid=%q): %v", label, keyIDVal, err)
		} else {
			log.Printf("✅ Verified signature for label=%q (keyid=%q, created=%s)", label, keyIDVal, createdVal)
		}
	}
}

// -----------------------------------------------------------------------------
//   Parsing Helpers
// -----------------------------------------------------------------------------

// parseRSAPublicKeyFromPEM parses a PEM-encoded RSA public key.
func parseRSAPublicKeyFromPEM(pemString string) (*rsa.PublicKey, error) {
	block, _ := pem.Decode([]byte(pemString))
	if block == nil {
		return nil, errors.New("failed to parse PEM block for public key")
	}
	key, err := x509.ParsePKIXPublicKey(block.Bytes)
	if err != nil {
		return nil, err
	}
	rsaPub, ok := key.(*rsa.PublicKey)
	if !ok {
		return nil, errors.New("parsed key is not an RSA public key")
	}
	return rsaPub, nil
}

// findCreated locates and returns the numeric 'created=...' value from signatureInput
// for a given label (e.g., "sigtest-key-2=...created=1737191021...").
func findCreated(signatureInput, label string) (string, error) {
	start := strings.Index(signatureInput, label+"=")
	if start < 0 {
		return "", fmt.Errorf("could not find label=%q in signatureInput", label)
	}
	sub := signatureInput[start:]

	idx := strings.Index(sub, "created=")
	if idx < 0 {
		return "", fmt.Errorf("missing created= param for label=%q", label)
	}
	sub2 := sub[idx+len("created="):]

	// Gather only numeric characters
	var sb strings.Builder
	for i := 0; i < len(sub2); i++ {
		if sub2[i] < '0' || sub2[i] > '9' {
			break
		}
		sb.WriteByte(sub2[i])
	}
	created := sb.String()
	if created == "" {
		return "", fmt.Errorf("no numeric created= found for label=%q", label)
	}
	return created, nil
}

// findKeyID locates and returns the keyid="..." parameter for a given label
// in the signatureInput.
func findKeyID(signatureInput, label string) (string, error) {
	start := strings.Index(signatureInput, label+"=")
	if start < 0 {
		return "", fmt.Errorf("could not find label=%q in signatureInput", label)
	}
	sub := signatureInput[start:]

	idx := strings.Index(sub, `keyid="`)
	if idx < 0 {
		return "", fmt.Errorf("missing keyid= param for label=%q", label)
	}
	sub2 := sub[idx+len(`keyid="`):]

	var sb strings.Builder
	for i := 0; i < len(sub2); i++ {
		if sub2[i] == '"' {
			break
		}
		sb.WriteByte(sub2[i])
	}
	keyid := sb.String()
	if keyid == "" {
		return "", fmt.Errorf("empty keyid for label=%q", label)
	}
	return keyid, nil
}

// findSignature extracts the base64-encoded signature from signatureHeader for a given label,
// e.g. "sigtest-key-2=:...:". We search for label+"=:" and parse until the next colon.
func findSignature(signatureHeader, label string) (string, error) {
	start := strings.Index(signatureHeader, label+"=:")
	if start < 0 {
		return "", fmt.Errorf("could not find label=%q in signatureHeader", label)
	}
	sub := signatureHeader[start+len(label+"=:"):]

	end := strings.IndexByte(sub, ':')
	if end < 0 {
		// If no trailing colon, take the whole remainder
		end = len(sub)
	}
	base64Sig := strings.TrimSpace(sub[:end])
	if base64Sig == "" {
		return "", fmt.Errorf("empty signature for label=%q", label)
	}
	return base64Sig, nil
}

import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.MessageDigest;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;

public class MultiSignatureExample {

    // Signature and Signature-Input headers with two labels: "sigtest-key-1" and "sigtest-key-2".
    // In real usage, these come from HTTP headers.
    private static final String signatureHeader = 
            "sigtest-key-2=:Jx/NEyQFDL016AVXCFdfC2Ggh/rdJk/zBYYcyQ36QGPYx/Vpxsm74C3dvdK0NHvaPfKw/iecLdHsedKwqetVe9e8eGGS+f86UBCCt4H4gcpSz/Pver7vRH3Y7mDwmKxKoCS+pCeDdgYbrj2ubI4evA8N56mW59vx9CYGNoH31gZIbsSlwrqhSB5zNUs+kulV57G2wJQVkXXfNxZCWEuHdTu9tJ9IsZI/eB+GzXbLqy7Xfchna1/lEk9pGMDWwLs1WFTZ6CQqNpFZ0ObrklIbdsg4Ij6kt6CbLv319yN7hhwrSMQNXjOB8ear9tLQmh0l6gkKG9mhgdWwYWKD4lCPAA==: " +
            "sigtest-key-1=:AgCVKZg4MRgQgUJp5BWGA68IQzffRfD4tCE3854mY75tnGwBhP58G6nQ7tTs83K8asG4QlEBvY4DNehkatyb6aJ0gBpYWw4SaGrNtN9YV56YiRm0UDgMJvRNwVlsmmIGXi3QS8jrjxiYxWl7mIDWMmsN8WuQ22/K/IFFqBtzKfDQ4lbo8DJSVcn6I9J2nwWAoYPawQQT+SttImQwyx58ElfYQAdUQXaOk1J4W/wyxsi5jdLIz5ESLOf9I0goE0C/wSPbytnxgA18yVUJOwB3GO/TkmAd4I7dCVqodpCsPSwVbCGwKn3E4TnFuJk7zuHq7wlcCPaZvFgmMYWzbsLhDw==:";

    private static final String signatureInputHeader =
            "sigtest-key-2=(\"@method\" \"@authority\" \"@request-target\" \"content-digest\");alg=\"rsa-v1_5-sha256\";keyid=\"test-key-2\";created=1737191021 " +
            "sigtest-key-1=(\"@method\" \"@authority\" \"@request-target\" \"content-digest\");alg=\"rsa-v1_5-sha256\";keyid=\"test-key-1\";created=1737191021";

    // Example RSA Public Keys (PEM). For demonstration, both "test-key-1" and "test-key-2" reuse the same RSA key.
    // In a real scenario, they can differ (to illustrate key rotation).
    private static final String publicKeyPEM1 = 
        "-----BEGIN PUBLIC KEY-----\n" +
        "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx0dGNKhTtKCuopFDYKGX\n" +
        "rlN712P2JayanbT1KsW7A5pR2dG9lTA1ZGh5xEyR2F6LUhMRYTtALWe5M2oEHTkK\n" +
        "yQ9mHJImQ+EIvtDGtxPtfeHm5rKl1jFmBPWb+Yh1JWqUjtOTsBbdOwSfAdAlb4Xw\n" +
        "n8/qBJ6LA1qlWbIvKWsRYj7S/70qSe4Yuqj3SKURMPEdZohTbb9U3viNMnFiDjav\n" +
        "nQbevra/H1Kc50sM8OugCSSSdsRQf8oXOb+MfvFlg6oEm8Js/affgUBOROH/s9t0\n" +
        "wW2HJgP1dshsnYI9yT1km6d/gLPUvw7gJMNR/rfkAPIkgpQijOMdVwwqInh/9rjl\n" +
        "jQIDAQAB\n" +
        "-----END PUBLIC KEY-----";

    // Simulate rotation by having a second key for "test-key-2". In real usage, it may be different.
    private static final String publicKeyPEM2 = publicKeyPEM1;

    // Example JSON body used to build the "content-digest".
    private static final String body = 
        "{\"id\":\"9bee7f87-6da4-4ef2-a8f6-265fa16c5983\",\"object\":\"event\",\"topic\":\"payment_order\",\"type\":\"approved\",\"data\":{\"id\":\"5ac55abb-fe0c-4f23-8610-aaafca8d959c\",\"type\":\"sepa\",\"amount\":21300,\"object\":\"payment_order\",\"status\":\"approved\",\"purpose\":\"\",\"currency\":\"EUR\",\"metadata\":{},\"bank_data\":{\"file_id\":\"\",\"message_id\":\"\",\"end_to_end_id\":\"5ac55abbfe0c4f238610aaafca8d959c\",\"transaction_id\":\"5ac55abbfe0c4f238610aaafca8d959c\",\"original_instruction_id\":\"5ac55abbfe0c4f238610aaafca8d959c\"},\"direction\":\"credit\",\"reference\":\"Beetax\",\"created_at\":\"2025-01-17T13:52:14.794145Z\",\"fee_option\":\"\",\"value_date\":\"\",\"auto_approval\":false,\"custom_fields\":{},\"retry_details\":null,\"status_details\":\"\",\"idempotency_key\":\"\",\"payment_file_id\":\"\",\"treasury_option\":\"\",\"connected_account\":\"00000000-1111-1111-1111-000000000011\",\"receiving_account\":{\"bank_code\":\"SOMEBIC9XXX\",\"holder_name\":\"test\",\"account_number\":\"NL61ABNA3605998615\",\"holder_address\":{\"city\":\"\",\"line_1\":\"\",\"line_2\":\"\",\"country\":\"\",\"postal_code\":\"\",\"street_name\":\"\",\"region_state\":\"\",\"building_number\":\"\"}},\"reconciled_amount\":0,\"originating_account\":{\"bank_code\":\"SOMEBIC9XXX\",\"holder_name\":\"123123\",\"account_number\":\"NL61ABNA3605998615\",\"holder_address\":{\"city\":\"\",\"line_1\":\"\",\"line_2\":\"\",\"country\":\"\",\"postal_code\":\"\",\"street_name\":\"\",\"region_state\":\"\",\"building_number\":\"\"},\"creditor_identifier\":\"\"},\"ultimate_originator\":{\"holder_name\":\"\",\"holder_address\":null,\"organization_identification\":{\"other\":\"\",\"bank_code\":\"\"}},\"connected_account_id\":\"00000000-1111-1111-1111-000000000011\",\"direct_debit_mandate\":null,\"receiving_account_id\":\"\",\"reconciliation_status\":\"unreconciled\",\"confidentiality_option\":\"\",\"originating_account_id\":\"\",\"direct_debit_mandate_id\":\"\",\"requested_execution_date\":\"\"},\"status\":\"created\",\"status_details\":\"\",\"related_object_id\":\"5ac55abb-fe0c-4f23-8610-aaafca8d959c\",\"related_object_type\":\"payment_order\",\"idempotency_key\":\"3c32d3e2-d4e1-4249-b5ee-be4927faf67f\",\"created_at\":\"2025-01-18T09:03:41.643234Z\"}";

    public static void main(String[] args) {
        try {
            // 1) Parse our two public keys (simulating rotation).
            PublicKey pk1 = parseRSAPublicKeyFromPEM(publicKeyPEM1);
            PublicKey pk2 = parseRSAPublicKeyFromPEM(publicKeyPEM2);

            // 2) Store them in a map keyed by "test-key-1" and "test-key-2".
            Map<String, PublicKey> publicKeys = new HashMap<>();
            publicKeys.put("test-key-1", pk1);
            publicKeys.put("test-key-2", pk2);

            // 3) Suppose we have 2 labels in the headers: "sigtest-key-1" and "sigtest-key-2".
            //    (In real code, you might discover these by parsing the entire Signature-Input).
            String[] labels = {"sigtest-key-1", "sigtest-key-2"};

            // 4) For each label, parse out "created", parse out "keyid", parse out base64 signature, then verify.
            for (String label : labels) {
                String createdVal = findCreated(signatureInputHeader, label);
                if (createdVal == null) {
                    System.out.printf("Skipping label=%s: no 'created' found.\n", label);
                    continue;
                }

                String keyIDVal = findKeyID(signatureInputHeader, label);
                if (keyIDVal == null) {
                    System.out.printf("Skipping label=%s: no 'keyid' found.\n", label);
                    continue;
                }

                String base64Sig = findSignature(signatureHeader, label);
                if (base64Sig == null) {
                    System.out.printf("Skipping label=%s: no signature found.\n", label);
                    continue;
                }

                byte[] sigBytes = Base64.getDecoder().decode(base64Sig);

                // 4a) Look up the correct public key from our map
                PublicKey pubKey = publicKeys.get(keyIDVal);
                if (pubKey == null) {
                    System.out.printf("Skipping label=%s: no public key found for keyid=%s\n", label, keyIDVal);
                    continue;
                }

                // 4b) Build "content-digest" from the body
                byte[] bodySha256 = sha256Hash(body.getBytes(StandardCharsets.UTF_8));
                String encodedHash = Base64.getEncoder().encodeToString(bodySha256);
                String contentDigest = "sha-256=:" + encodedHash + ":";

                // 4c) Construct the signature base (like the Go code).
                String signatureBase = String.format(
                    "\"@method\": POST\n" +
                    "\"@authority\": %s\n" +
                    "\"@request-target\": %s\n" +
                    "\"content-digest\": %s\n" +
                    "\"@signature-params\": (\"@method\" \"@authority\" \"@request-target\" \"content-digest\");" +
                    "alg=\"rsa-v1_5-sha256\";keyid=\"%s\";created=%s",
                    "httpdump.app",
                    "/dumps/91db320b-c734-49e3-9f89-64518106c5c3",
                    contentDigest,
                    keyIDVal,
                    createdVal
                );

                // 4d) Verify
                boolean verified = verifyRSASignature(pubKey, signatureBase.getBytes(StandardCharsets.UTF_8), sigBytes);
                if (verified) {
                    System.out.printf("✅ Verified signature for label=%s (keyid=%s, created=%s)\n", label, keyIDVal, createdVal);
                } else {
                    System.out.printf("❌ Verification FAILED for label=%s (keyid=%s)\n", label, keyIDVal);
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    // -------------------------------------------------------------------------
    //   Helper: parse an RSA Public Key from PEM
    // -------------------------------------------------------------------------
    public static PublicKey parseRSAPublicKeyFromPEM(String pemString) throws Exception {
        // Remove PEM headers/footers and whitespace
        String clean = pemString
            .replace("-----BEGIN PUBLIC KEY-----", "")
            .replace("-----END PUBLIC KEY-----", "")
            .replaceAll("\\s", "");

        byte[] decoded = Base64.getDecoder().decode(clean);
        X509EncodedKeySpec spec = new X509EncodedKeySpec(decoded);
        KeyFactory kf = KeyFactory.getInstance("RSA");
        return kf.generatePublic(spec);
    }

    // -------------------------------------------------------------------------
    //   Helper: findCreated - direct substring search for "created=12345"
    // -------------------------------------------------------------------------
    private static String findCreated(String signatureInput, String label) {
        int labelIdx = signatureInput.indexOf(label + "=");
        if (labelIdx < 0) {
            return null;
        }
        String sub = signatureInput.substring(labelIdx);

        int idx = sub.indexOf("created=");
        if (idx < 0) {
            return null;
        }

        // Move past "created="
        String sub2 = sub.substring(idx + "created=".length());

        // Gather digits
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < sub2.length(); i++) {
            char c = sub2.charAt(i);
            if (c < '0' || c > '9') {
                break;
            }
            sb.append(c);
        }
        return (sb.length() > 0) ? sb.toString() : null;
    }

    // -------------------------------------------------------------------------
    //   Helper: findKeyID - direct substring search for keyid="..."
    // -------------------------------------------------------------------------
    private static String findKeyID(String signatureInput, String label) {
        int labelIdx = signatureInput.indexOf(label + "=");
        if (labelIdx < 0) {
            return null;
        }
        String sub = signatureInput.substring(labelIdx);

        int idx = sub.indexOf("keyid=\"");
        if (idx < 0) {
            return null;
        }
        String sub2 = sub.substring(idx + "keyid=\"".length());

        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < sub2.length(); i++) {
            char c = sub2.charAt(i);
            if (c == '"') {
                break;
            }
            sb.append(c);
        }
        return sb.length() > 0 ? sb.toString() : null;
    }

    // -------------------------------------------------------------------------
    //   Helper: findSignature - look for "label=:<base64>:"
    // -------------------------------------------------------------------------
    private static String findSignature(String signatureHeader, String label) {
        String marker = label + "=:";
        int start = signatureHeader.indexOf(marker);
        if (start < 0) {
            return null;
        }
        String sub = signatureHeader.substring(start + marker.length());

        // ends at next colon
        int end = sub.indexOf(':');
        if (end < 0) {
            end = sub.length();
        }
        String base64Sig = sub.substring(0, end).trim();
        return base64Sig.isEmpty() ? null : base64Sig;
    }

    // -------------------------------------------------------------------------
    //   Helper: Compute SHA-256
    // -------------------------------------------------------------------------
    private static byte[] sha256Hash(byte[] data) throws Exception {
        MessageDigest md = MessageDigest.getInstance("SHA-256");
        return md.digest(data);
    }

    // -------------------------------------------------------------------------
    //   Helper: Verify RSA PKCS#1 v1.5 with SHA-256
    // -------------------------------------------------------------------------
    private static boolean verifyRSASignature(PublicKey publicKey, byte[] data, byte[] signatureBytes) {
        try {
            Signature sig = Signature.getInstance("SHA256withRSA");
            sig.initVerify(publicKey);
            sig.update(data);
            return sig.verify(signatureBytes);
        } catch (Exception e) {
            return false;
        }
    }
}

/**
 * verifyMultiSignatures.js
 *
 * A Node.js script illustrating how to verify multiple signatures
 * and signature inputs.
 *
 */

const crypto = require('crypto');

// Multiple signatures in the "Signature" header
const signatureHeader = `sigtest-key-2=:Jx/NEyQFDL016AVXCFdfC2Ggh/rdJk/zBYYcyQ36QGPYx/Vpxsm74C3dvdK0NHvaPfKw/iecLdHsedKwqetVe9e8eGGS+f86UBCCt4H4gcpSz/Pver7vRH3Y7mDwmKxKoCS+pCeDdgYbrj2ubI4evA8N56mW59vx9CYGNoH31gZIbsSlwrqhSB5zNUs+kulV57G2wJQVkXXfNxZCWEuHdTu9tJ9IsZI/eB+GzXbLqy7Xfchna1/lEk9pGMDWwLs1WFTZ6CQqNpFZ0ObrklIbdsg4Ij6kt6CbLv319yN7hhwrSMQNXjOB8ear9tLQmh0l6gkKG9mhgdWwYWKD4lCPAA==: sigtest-key-1=:AgCVKZg4MRgQgUJp5BWGA68IQzffRfD4tCE3854mY75tnGwBhP58G6nQ7tTs83K8asG4QlEBvY4DNehkatyb6aJ0gBpYWw4SaGrNtN9YV56YiRm0UDgMJvRNwVlsmmIGXi3QS8jrjxiYxWl7mIDWMmsN8WuQ22/K/IFFqBtzKfDQ4lbo8DJSVcn6I9J2nwWAoYPawQQT+SttImQwyx58ElfYQAdUQXaOk1J4W/wyxsi5jdLIz5ESLOf9I0goE0C/wSPbytnxgA18yVUJOwB3GO/TkmAd4I7dCVqodpCsPSwVbCGwKn3E4TnFuJk7zuHq7wlcCPaZvFgmMYWzbsLhDw==:`;

// Multiple signature inputs in the "Signature-Input" header
const signatureInputHeader = `sigtest-key-2=("@method" "@authority" "@request-target" "content-digest");alg="rsa-v1_5-sha256";keyid="test-key-2";created=1737191021 sigtest-key-1=("@method" "@authority" "@request-target" "content-digest");alg="rsa-v1_5-sha256";keyid="test-key-1";created=1737191021`;

// We reuse the same RSA public key for both test-key-1 and test-key-2 (in real usage, they may differ).
const publicKeyPEM1 = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx0dGNKhTtKCuopFDYKGX
rlN712P2JayanbT1KsW7A5pR2dG9lTA1ZGh5xEyR2F6LUhMRYTtALWe5M2oEHTkK
yQ9mHJImQ+EIvtDGtxPtfeHm5rKl1jFmBPWb+Yh1JWqUjtOTsBbdOwSfAdAlb4Xw
n8/qBJ6LA1qlWbIvKWsRYj7S/70qSe4Yuqj3SKURMPEdZohTbb9U3viNMnFiDjav
nQbevra/H1Kc50sM8OugCSSSdsRQf8oXOb+MfvFlg6oEm8Js/affgUBOROH/s9t0
wW2HJgP1dshsnYI9yT1km6d/gLPUvw7gJMNR/rfkAPIkgpQijOMdVwwqInh/9rjl
jQIDAQAB
-----END PUBLIC KEY-----`;

const publicKeyPEM2 = publicKeyPEM1; // Simulate rotation (same key) - in real scenarios, they'd differ.

// The body that was presumably signed
const body = `{"id":"9bee7f87-6da4-4ef2-a8f6-265fa16c5983","object":"event","topic":"payment_order","type":"approved","data":{"id":"5ac55abb-fe0c-4f23-8610-aaafca8d959c","type":"sepa","amount":21300,"object":"payment_order","status":"approved","purpose":"","currency":"EUR","metadata":{},"bank_data":{"file_id":"","message_id":"","end_to_end_id":"5ac55abbfe0c4f238610aaafca8d959c","transaction_id":"5ac55abbfe0c4f238610aaafca8d959c","original_instruction_id":"5ac55abbfe0c4f238610aaafca8d959c"},"direction":"credit","reference":"Beetax","created_at":"2025-01-17T13:52:14.794145Z","fee_option":"","value_date":"","auto_approval":false,"custom_fields":{},"retry_details":null,"status_details":"","idempotency_key":"","payment_file_id":"","treasury_option":"","connected_account":"00000000-1111-1111-1111-000000000011","receiving_account":{"bank_code":"SOMEBIC9XXX","holder_name":"test","account_number":"NL61ABNA3605998615","holder_address":{"city":"","line_1":"","line_2":"","country":"","postal_code":"","street_name":"","region_state":"","building_number":""}},"reconciled_amount":0,"originating_account":{"bank_code":"SOMEBIC9XXX","holder_name":"123123","account_number":"NL61ABNA3605998615","holder_address":{"city":"","line_1":"","line_2":"","country":"","postal_code":"","street_name":"","region_state":"","building_number":""},"creditor_identifier":""},"ultimate_originator":{"holder_name":"","holder_address":null,"organization_identification":{"other":"","bank_code":""}},"connected_account_id":"00000000-1111-1111-1111-000000000011","direct_debit_mandate":null,"receiving_account_id":"","reconciliation_status":"unreconciled","confidentiality_option":"","originating_account_id":"","direct_debit_mandate_id":"","requested_execution_date":""},"status":"created","status_details":"","related_object_id":"5ac55abb-fe0c-4f23-8610-aaafca8d959c","related_object_type":"payment_order","idempotency_key":"3c32d3e2-d4e1-4249-b5ee-be4927faf67f","created_at":"2025-01-18T09:03:41.643234Z"}`;

// -----------------------------------------------------------------------------
//  2) We store multiple public keys keyed by their "keyid" (like test-key-1, test-key-2).
//     This is how you handle rotation: add/replace entries as needed.
// -----------------------------------------------------------------------------
const publicKeys = {
  "test-key-1": crypto.createPublicKey(publicKeyPEM1),
  "test-key-2": crypto.createPublicKey(publicKeyPEM2),
};

// -----------------------------------------------------------------------------
//  3) We'll parse & verify for these two labels: "sigtest-key-1" and "sigtest-key-2".
// -----------------------------------------------------------------------------
const labels = ["sigtest-key-1", "sigtest-key-2"];

// -----------------------------------------------------------------------------
//  MAIN LOGIC
// -----------------------------------------------------------------------------
function main() {
  for (const label of labels) {
    try {
      // a) Extract created & keyid from the signatureInputHeader
      const createdVal = findCreated(signatureInputHeader, label);
      if (!createdVal) {
        console.log(`Skipping label=${label}: no created= found.`);
        continue;
      }
      const keyIDVal = findKeyID(signatureInputHeader, label);
      if (!keyIDVal) {
        console.log(`Skipping label=${label}: no keyid= found.`);
        continue;
      }

      // b) Extract the base64 signature from the signatureHeader
      const base64Sig = findSignature(signatureHeader, label);
      if (!base64Sig) {
        console.log(`Skipping label=${label}: no signature found.`);
        continue;
      }
      const sigBuf = Buffer.from(base64Sig, 'base64');

      // c) Look up the appropriate public key by keyID
      const pubKeyObj = publicKeys[keyIDVal];
      if (!pubKeyObj) {
        console.log(`Skipping label=${label}: no public key for keyid=${keyIDVal}`);
        continue;
      }

      // d) Build the content-digest from the body (SHA-256)
      const bodyHash = crypto.createHash('sha256').update(body, 'utf8').digest('base64');
      const contentDigest = `sha-256=:${bodyHash}:`;

      // e) Build the signature base string
      const signatureBase = 
        `"@method": POST\n` +
        `"@authority": httpdump.app\n` +
        `"@request-target": /dumps/91db320b-c734-49e3-9f89-64518106c5c3\n` +
        `"content-digest": ${contentDigest}\n` +
        `"@signature-params": ("@method" "@authority" "@request-target" "content-digest");` +
        `alg="rsa-v1_5-sha256";keyid="${keyIDVal}";created=${createdVal}`;

      // f) Verify signature with RSA-SHA256 (PKCS#1 v1.5)
      const isValid = verifySignature(pubKeyObj, signatureBase, sigBuf);
      if (isValid) {
        console.log(`✅ Verified label=${label} (keyid=${keyIDVal}, created=${createdVal})`);
      } else {
        console.log(`❌ Verification FAILED for label=${label} (keyid=${keyIDVal})`);
      }
    } catch (err) {
      console.log(`Error processing label=${label}:`, err.message);
    }
  }
}

main();

// -----------------------------------------------------------------------------
//  HELPER FUNCTIONS
// -----------------------------------------------------------------------------

/**
 * findCreated(signatureInput, label)
 * Direct substring search for "created=12345" in the chunk for the given label.
 * E.g.:  sigtest-key-2=("@method"...);...;created=1737191021
 */
function findCreated(sigInputHeader, label) {
  const idx = sigInputHeader.indexOf(`${label}=`);
  if (idx < 0) return null;
  const sub = sigInputHeader.substring(idx);
  
  const createdMarker = "created=";
  const cIdx = sub.indexOf(createdMarker);
  if (cIdx < 0) return null;

  const after = sub.substring(cIdx + createdMarker.length);
  let digits = "";
  for (let i = 0; i < after.length; i++) {
    const ch = after[i];
    if (ch < '0' || ch > '9') break;
    digits += ch;
  }
  return digits || null;
}

/**
 * findKeyID(signatureInput, label)
 * Direct substring search for keyid="..."
 * E.g.: keyid="test-key-2"
 */
function findKeyID(sigInputHeader, label) {
  const idx = sigInputHeader.indexOf(`${label}=`);
  if (idx < 0) return null;
  const sub = sigInputHeader.substring(idx);

  const keyIdMarker = `keyid="`;
  const kIdx = sub.indexOf(keyIdMarker);
  if (kIdx < 0) return null;

  const after = sub.substring(kIdx + keyIdMarker.length);
  let keyid = "";
  for (let i = 0; i < after.length; i++) {
    if (after[i] === '"') break;
    keyid += after[i];
  }
  return keyid || null;
}

/**
 * findSignature(signatureHeader, label)
 * Extract the base64 from: label=:<base64>:
 */
function findSignature(signatureHeader, label) {
  const marker = `${label}=:`;
  const start = signatureHeader.indexOf(marker);
  if (start < 0) return null;
  const remainder = signatureHeader.substring(start + marker.length);

  // Look for the next colon
  const endColon = remainder.indexOf(':');
  if (endColon < 0) {
    // If no trailing colon, take the remainder
    return remainder.trim();
  }
  return remainder.substring(0, endColon).trim();
}

/**
 * verifySignature(publicKey, signatureBase, sigBuf)
 * Use Node's crypto (RSA-SHA256 with PKCS#1 v1.5).
 */
function verifySignature(pubKeyObj, signatureBase, sigBuf) {
  try {
    const verifier = crypto.createVerify('RSA-SHA256');
    verifier.update(signatureBase, 'utf8');
    return verifier.verify(
      {
        key: pubKeyObj,
        padding: crypto.constants.RSA_PKCS1_PADDING,
      },
      sigBuf
    );
  } catch (err) {
    return false;
  }
}

<?php

// Multiple signatures in the "Signature" header
$signatureHeader = 'sigtest-key-2=:Jx/NEyQFDL016AVXCFdfC2Ggh/rdJk/zBYYcyQ36QGPYx/Vpxsm74C3dvdK0NHvaPfKw/iecLdHsedKwqetVe9e8eGGS+f86UBCCt4H4gcpSz/Pver7vRH3Y7mDwmKxKoCS+pCeDdgYbrj2ubI4evA8N56mW59vx9CYGNoH31gZIbsSlwrqhSB5zNUs+kulV57G2wJQVkXXfNxZCWEuHdTu9tJ9IsZI/eB+GzXbLqy7Xfchna1/lEk9pGMDWwLs1WFTZ6CQqNpFZ0ObrklIbdsg4Ij6kt6CbLv319yN7hhwrSMQNXjOB8ear9tLQmh0l6gkKG9mhgdWwYWKD4lCPAA==: sigtest-key-1=:AgCVKZg4MRgQgUJp5BWGA68IQzffRfD4tCE3854mY75tnGwBhP58G6nQ7tTs83K8asG4QlEBvY4DNehkatyb6aJ0gBpYWw4SaGrNtN9YV56YiRm0UDgMJvRNwVlsmmIGXi3QS8jrjxiYxWl7mIDWMmsN8WuQ22/K/IFFqBtzKfDQ4lbo8DJSVcn6I9J2nwWAoYPawQQT+SttImQwyx58ElfYQAdUQXaOk1J4W/wyxsi5jdLIz5ESLOf9I0goE0C/wSPbytnxgA18yVUJOwB3GO/TkmAd4I7dCVqodpCsPSwVbCGwKn3E4TnFuJk7zuHq7wlcCPaZvFgmMYWzbsLhDw==:';

// Multiple signature inputs in the "Signature-Input" header
$signatureInputHeader = 'sigtest-key-2=("@method" "@authority" "@request-target" "content-digest");alg="rsa-v1_5-sha256";keyid="test-key-2";created=1737191021 sigtest-key-1=("@method" "@authority" "@request-target" "content-digest");alg="rsa-v1_5-sha256";keyid="test-key-1";created=1737191021';

// We reuse the same RSA public key for both "test-key-1" and "test-key-2" (in real usage, they may differ).
$publicKeyPEM1 = <<<PEM
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx0dGNKhTtKCuopFDYKGX
rlN712P2JayanbT1KsW7A5pR2dG9lTA1ZGh5xEyR2F6LUhMRYTtALWe5M2oEHTkK
yQ9mHJImQ+EIvtDGtxPtfeHm5rKl1jFmBPWb+Yh1JWqUjtOTsBbdOwSfAdAlb4Xw
n8/qBJ6LA1qlWbIvKWsRYj7S/70qSe4Yuqj3SKURMPEdZohTbb9U3viNMnFiDjav
nQbevra/H1Kc50sM8OugCSSSdsRQf8oXOb+MfvFlg6oEm8Js/affgUBOROH/s9t0
wW2HJgP1dshsnYI9yT1km6d/gLPUvw7gJMNR/rfkAPIkgpQijOMdVwwqInh/9rjl
jQIDAQAB
-----END PUBLIC KEY-----
PEM;

$publicKeyPEM2 = $publicKeyPEM1; // For rotation, same key here, but can be different in real usage

$body = '{"id":"9bee7f87-6da4-4ef2-a8f6-265fa16c5983","object":"event","topic":"payment_order","type":"approved","data":{"id":"5ac55abb-fe0c-4f23-8610-aaafca8d959c","type":"sepa","amount":21300,"object":"payment_order","status":"approved","purpose":"","currency":"EUR","metadata":{},"bank_data":{"file_id":"","message_id":"","end_to_end_id":"5ac55abbfe0c4f238610aaafca8d959c","transaction_id":"5ac55abbfe0c4f238610aaafca8d959c","original_instruction_id":"5ac55abbfe0c4f238610aaafca8d959c"},"direction":"credit","reference":"Beetax","created_at":"2025-01-17T13:52:14.794145Z","fee_option":"","value_date":"","auto_approval":false,"custom_fields":{},"retry_details":null,"status_details":"","idempotency_key":"","payment_file_id":"","treasury_option":"","connected_account":"00000000-1111-1111-1111-000000000011","receiving_account":{"bank_code":"SOMEBIC9XXX","holder_name":"test","account_number":"NL61ABNA3605998615","holder_address":{"city":"","line_1":"","line_2":"","country":"","postal_code":"","street_name":"","region_state":"","building_number":""}},"reconciled_amount":0,"originating_account":{"bank_code":"SOMEBIC9XXX","holder_name":"123123","account_number":"NL61ABNA3605998615","holder_address":{"city":"","line_1":"","line_2":"","country":"","postal_code":"","street_name":"","region_state":"","building_number":""},"creditor_identifier":""},"ultimate_originator":{"holder_name":"","holder_address":null,"organization_identification":{"other":"","bank_code":""}},"connected_account_id":"00000000-1111-1111-1111-000000000011","direct_debit_mandate":null,"receiving_account_id":"","reconciliation_status":"unreconciled","confidentiality_option":"","originating_account_id":"","direct_debit_mandate_id":"","requested_execution_date":""},"status":"created","status_details":"","related_object_id":"5ac55abb-fe0c-4f23-8610-aaafca8d959c","related_object_type":"payment_order","idempotency_key":"3c32d3e2-d4e1-4249-b5ee-be4927faf67f","created_at":"2025-01-18T09:03:41.643234Z"}';

// -----------------------------------------------------------------------------
// 2) We store multiple keys in a map keyed by their "keyid", for rotation
// -----------------------------------------------------------------------------
$publicKeys = [
    "test-key-1" => $publicKeyPEM1,
    "test-key-2" => $publicKeyPEM2,
];

// We'll parse/verify for these label strings
$labels = ["sigtest-key-1", "sigtest-key-2"];

// -----------------------------------------------------------------------------
// MAIN
// -----------------------------------------------------------------------------
foreach ($labels as $label) {
    try {
        // a) find created= for this label
        $createdVal = findCreated($signatureInputHeader, $label);
        if ($createdVal === null) {
            echo "Skipping label=$label: no created= found.\n";
            continue;
        }

        // b) find keyid="..."
        $keyIDVal = findKeyID($signatureInputHeader, $label);
        if ($keyIDVal === null) {
            echo "Skipping label=$label: no keyid= found.\n";
            continue;
        }

        // c) find the base64 signature for this label
        $base64Sig = findSignature($signatureHeader, $label);
        if ($base64Sig === null) {
            echo "Skipping label=$label: no signature found.\n";
            continue;
        }
        $decodedSignature = base64_decode($base64Sig);
        if ($decodedSignature === false) {
            echo "Skipping label=$label: base64 decode error.\n";
            continue;
        }

        // d) find the public key by keyid
        if (!isset($publicKeys[$keyIDVal])) {
            echo "Skipping label=$label: no public key for keyid=$keyIDVal\n";
            continue;
        }
        $pubKeyResource = openssl_pkey_get_public($publicKeys[$keyIDVal]);
        if (!$pubKeyResource) {
            echo "Skipping label=$label: invalid public key for keyid=$keyIDVal\n";
            continue;
        }

        // e) build content-digest from the body
        $contentDigest = buildContentDigest($body);

        // f) build the signature base
        $signatureBase = buildSignatureBase($contentDigest, $keyIDVal, $createdVal);

        // g) verify
        $verifyResult = openssl_verify($signatureBase, $decodedSignature, $pubKeyResource, OPENSSL_ALGO_SHA256);

        if ($verifyResult === 1) {
            echo "✅ Verified label=$label (keyid=$keyIDVal, created=$createdVal)\n";
        } elseif ($verifyResult === 0) {
            echo "❌ Verification FAILED for label=$label (keyid=$keyIDVal)\n";
        } else {
            echo "Error during verification for label=$label.\n";
        }
    } catch (Exception $ex) {
        echo "Exception for label=$label: ".$ex->getMessage()."\n";
    }
}

// -----------------------------------------------------------------------------
// Helper Functions
// -----------------------------------------------------------------------------

/**
 * findCreated: direct substring search for created=NNNN in the chunk for a label.
 */
function findCreated(string $sigInputHeader, string $label): ?string {
    $idx = strpos($sigInputHeader, $label.'=');
    if ($idx === false) return null;

    $sub = substr($sigInputHeader, $idx);
    $marker = 'created=';
    $cidx = strpos($sub, $marker);
    if ($cidx === false) return null;

    $after = substr($sub, $cidx + strlen($marker));
    $digits = '';
    for ($i=0; $i<strlen($after); $i++) {
        if ($after[$i] < '0' || $after[$i] > '9') break;
        $digits .= $after[$i];
    }
    return $digits !== '' ? $digits : null;
}

/**
 * findKeyID: direct substring search for keyid="..."
 */
function findKeyID(string $sigInputHeader, string $label): ?string {
    $idx = strpos($sigInputHeader, $label.'=');
    if ($idx === false) return null;

    $sub = substr($sigInputHeader, $idx);
    $marker = 'keyid="';
    $kidx = strpos($sub, $marker);
    if ($kidx === false) return null;

    $after = substr($sub, $kidx + strlen($marker));
    $keyid = '';
    for ($i=0; $i<strlen($after); $i++) {
        if ($after[$i] === '"') break;
        $keyid .= $after[$i];
    }
    return $keyid !== '' ? $keyid : null;
}

/**
 * findSignature: label=:<base64>:
 */
function findSignature(string $signatureHeader, string $label): ?string {
    $marker = $label.'=:';
    $start = strpos($signatureHeader, $marker);
    if ($start === false) return null;

    $remainder = substr($signatureHeader, $start + strlen($marker));
    $endPos = strpos($remainder, ':');
    if ($endPos === false) {
        return trim($remainder);
    }
    return trim(substr($remainder, 0, $endPos));
}

/**
 * buildContentDigest: just SHA-256 of the body.
 */
function buildContentDigest(string $bodyContent): string {
    $hashBinary = hash('sha256', $bodyContent, true);
    $base64Hash = base64_encode($hashBinary);
    return 'sha-256=:'.$base64Hash.':';
}

/**
 * buildSignatureBase: the string to sign.
 */
function buildSignatureBase(string $contentDigest, string $keyID, string $created): string {
    return
        "\"@method\": POST\n" .
        "\"@authority\": httpdump.app\n" .
        "\"@request-target\": /dumps/91db320b-c734-49e3-9f89-64518106c5c3\n" .
        "\"content-digest\": $contentDigest\n" .
        "\"@signature-params\": (\"@method\" \"@authority\" \"@request-target\" \"content-digest\");" .
        "alg=\"rsa-v1_5-sha256\";keyid=\"$keyID\";created=$created";
}
?>

#!/usr/bin/env python3
"""
verify_multi_signatures.py

We store multiple public keys in a dict keyed by "test-key-1" and "test-key-2",
mirroring the key rotation approach.
"""

import re
import base64
import logging

from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.serialization import load_pem_public_key
from cryptography.exceptions import InvalidSignature

# Multiple signatures in the "Signature" header
signatureHeader = (
    "sigtest-key-2=:Jx/NEyQFDL016AVXCFdfC2Ggh/rdJk/zBYYcyQ36QGPYx/Vpxsm74C3dvdK0NHvaPfKw/"
    "iecLdHsedKwqetVe9e8eGGS+f86UBCCt4H4gcpSz/Pver7vRH3Y7mDwmKxKoCS+pCeDdgYbrj2ubI4evA8N56"
    "mW59vx9CYGNoH31gZIbsSlwrqhSB5zNUs+kulV57G2wJQVkXXfNxZCWEuHdTu9tJ9IsZI/eB+GzXbLqy7Xfch"
    "na1/lEk9pGMDWwLs1WFTZ6CQqNpFZ0ObrklIbdsg4Ij6kt6CbLv319yN7hhwrSMQNXjOB8ear9tLQmh0l6gkK"
    "G9mhgdWwYWKD4lCPAA==: sigtest-key-1=:AgCVKZg4MRgQgUJp5BWGA68IQzffRfD4tCE3854mY75tnGwBhP58G6nQ7tTs83K8asG4QlEBvY4DNehkatyb6aJ0gBpYWw4SaGrNtN9YV56YiRm0UDgMJvRNwVlsmmIGXi3QS8jrjxiYxWl7mIDWMmsN8WuQ22/K/IFFqBtzKfDQ4lbo8DJSVcn6I9J2nwWAoYPawQQT+SttImQwyx58ElfYQAdUQXaOk1J4W/wyxsi5jdLIz5ESLOf9I0goE0C/wSPbytnxgA18yVUJOwB3GO/TkmAd4I7dCVqodpCsPSwVbCGwKn3E4TnFuJk7zuHq7wlcCPaZvFgmMYWzbsLhDw==:"
)

# Multiple signature inputs in the "Signature-Input" header
signatureInputHeader = (
    'sigtest-key-2=("@method" "@authority" "@request-target" "content-digest");alg="rsa-v1_5-sha256";keyid="test-key-2";created=1737191021 '
    'sigtest-key-1=("@method" "@authority" "@request-target" "content-digest");alg="rsa-v1_5-sha256";keyid="test-key-1";created=1737191021'
)

# For demonstration, we reuse the same RSA public key for both "test-key-1" and "test-key-2".
# In a real environment, you'd likely have distinct keys for each keyid.
publicKeyPEM1 = b"""-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx0dGNKhTtKCuopFDYKGX
rlN712P2JayanbT1KsW7A5pR2dG9lTA1ZGh5xEyR2F6LUhMRYTtALWe5M2oEHTkK
yQ9mHJImQ+EIvtDGtxPtfeHm5rKl1jFmBPWb+Yh1JWqUjtOTsBbdOwSfAdAlb4Xw
n8/qBJ6LA1qlWbIvKWsRYj7S/70qSe4Yuqj3SKURMPEdZohTbb9U3viNMnFiDjav
nQbevra/H1Kc50sM8OugCSSSdsRQf8oXOb+MfvFlg6oEm8Js/affgUBOROH/s9t0
wW2HJgP1dshsnYI9yT1km6d/gLPUvw7gJMNR/rfkAPIkgpQijOMdVwwqInh/9rjl
jQIDAQAB
-----END PUBLIC KEY-----
"""

publicKeyPEM2 = publicKeyPEM1  # Simulate rotation by reusing same key

body = (
    '{"id":"9bee7f87-6da4-4ef2-a8f6-265fa16c5983","object":"event","topic":"payment_order","type":"approved","data":{"id":"5ac55abb-fe0c-4f23-8610-aaafca8d959c","type":"sepa","amount":21300,"object":"payment_order","status":"approved","purpose":"","currency":"EUR","metadata":{},"bank_data":{"file_id":"","message_id":"","end_to_end_id":"5ac55abbfe0c4f238610aaafca8d959c","transaction_id":"5ac55abbfe0c4f238610aaafca8d959c","original_instruction_id":"5ac55abbfe0c4f238610aaafca8d959c"},"direction":"credit","reference":"Beetax","created_at":"2025-01-17T13:52:14.794145Z","fee_option":"","value_date":"","auto_approval":false,"custom_fields":{},"retry_details":null,"status_details":"","idempotency_key":"","payment_file_id":"","treasury_option":"","connected_account":"00000000-1111-1111-1111-000000000011","receiving_account":{"bank_code":"SOMEBIC9XXX","holder_name":"test","account_number":"NL61ABNA3605998615","holder_address":{"city":"","line_1":"","line_2":"","country":"","postal_code":"","street_name":"","region_state":"","building_number":""}},"reconciled_amount":0,"originating_account":{"bank_code":"SOMEBIC9XXX","holder_name":"123123","account_number":"NL61ABNA3605998615","holder_address":{"city":"","line_1":"","line_2":"","country":"","postal_code":"","street_name":"","region_state":"","building_number":""},"creditor_identifier":""},"ultimate_originator":{"holder_name":"","holder_address":null,"organization_identification":{"other":"","bank_code":""}},"connected_account_id":"00000000-1111-1111-1111-000000000011","direct_debit_mandate":null,"receiving_account_id":"","reconciliation_status":"unreconciled","confidentiality_option":"","originating_account_id":"","direct_debit_mandate_id":"","requested_execution_date":""},"status":"created","status_details":"","related_object_id":"5ac55abb-fe0c-4f23-8610-aaafca8d959c","related_object_type":"payment_order","idempotency_key":"3c32d3e2-d4e1-4249-b5ee-be4927faf67f","created_at":"2025-01-18T09:03:41.643234Z"}'
)

# -----------------------------------------------------------------------------
# 2) Key Rotation Map: keyid -> PublicKey Object
# -----------------------------------------------------------------------------
publicKeys = {
    "test-key-1": load_pem_public_key(publicKeyPEM1),
    "test-key-2": load_pem_public_key(publicKeyPEM2),
}

# We'll parse & verify for these label strings
labels = ["sigtest-key-1", "sigtest-key-2"]


# -----------------------------------------------------------------------------
# HELPER FUNCTIONS
# -----------------------------------------------------------------------------

def find_created(sigInputHeader: str, label: str) -> str or None:
    """
    Direct substring search for "created=12345" in the chunk for a given label,
    like sigtest-key-1=...;created=NNN
    """
    start_idx = sigInputHeader.find(label + "=")
    if start_idx < 0:
        return None
    sub = sigInputHeader[start_idx:]
    marker = "created="
    c_idx = sub.find(marker)
    if c_idx < 0:
        return None

    after = sub[c_idx + len(marker):]
    digits = []
    for ch in after:
        if not ch.isdigit():
            break
        digits.append(ch)
    return "".join(digits) if digits else None


def find_keyid(sigInputHeader: str, label: str) -> str or None:
    """
    Direct substring search for keyid="...".
    """
    start_idx = sigInputHeader.find(label + "=")
    if start_idx < 0:
        return None
    sub = sigInputHeader[start_idx:]
    pattern = 'keyid="'
    k_idx = sub.find(pattern)
    if k_idx < 0:
        return None

    after = sub[k_idx + len(pattern):]
    keyid_chars = []
    for ch in after:
        if ch == '"':
            break
        keyid_chars.append(ch)
    return "".join(keyid_chars) if keyid_chars else None


def find_signature(sigHeader: str, label: str) -> str or None:
    """
    Extract the base64 from label=:<base64>:
    """
    marker = label + "=:"
    start_pos = sigHeader.find(marker)
    if start_pos < 0:
        return None
    remainder = sigHeader[start_pos + len(marker):]
    # Next colon is the end
    colon_pos = remainder.find(':')
    if colon_pos < 0:
        return remainder.strip()
    return remainder[:colon_pos].strip()


def build_content_digest(body_str: str) -> str:
    """
    "sha-256=:<base64 of sha256(body)>:"
    """
    digest_obj = hashes.Hash(hashes.SHA256())
    digest_obj.update(body_str.encode("utf-8"))
    raw_hash = digest_obj.finalize()
    b64hash = base64.b64encode(raw_hash).decode("utf-8")
    return f"sha-256=:{b64hash}:"


def build_signature_base(content_digest: str, keyid: str, created: str) -> str:
    """
    Build the signature base string
    """
    return (
        "\"@method\": POST\n"
        "\"@authority\": httpdump.app\n"
        "\"@request-target\": /dumps/91db320b-c734-49e3-9f89-64518106c5c3\n"
        f"\"content-digest\": {content_digest}\n"
        "\"@signature-params\": (\"@method\" \"@authority\" \"@request-target\" \"content-digest\");"
        f"alg=\"rsa-v1_5-sha256\";keyid=\"{keyid}\";created={created}"
    )


def verify_signature(pub_key, signature_base: str, sig_bytes: bytes) -> bool:
    """
    RSA-SHA256 PKCS#1 v1.5 using cryptography
    """
    try:
        pub_key.verify(
            sig_bytes,
            signature_base.encode("utf-8"),
            padding.PKCS1v15(),
            hashes.SHA256()
        )
        return True
    except InvalidSignature:
        return False


# -----------------------------------------------------------------------------
# MAIN
# -----------------------------------------------------------------------------
def main():
    logging.basicConfig(level=logging.INFO)

    for label in labels:
        try:
            # a) Extract created
            created_val = find_created(signatureInputHeader, label)
            if not created_val:
                logging.info(f"Skipping label={label}: no created= found.")
                continue

            # b) Extract keyid
            keyid_val = find_keyid(signatureInputHeader, label)
            if not keyid_val:
                logging.info(f"Skipping label={label}: no keyid= found.")
                continue

            # c) Extract signature
            base64_sig = find_signature(signatureHeader, label)
            if not base64_sig:
                logging.info(f"Skipping label={label}: no signature found.")
                continue

            sig_bytes = base64.b64decode(base64_sig)

            # d) Look up the public key
            if keyid_val not in publicKeys:
                logging.info(f"Skipping label={label}: no public key for keyid={keyid_val}.")
                continue
            pub_key = publicKeys[keyid_val]

            # e) Build content digest from the body
            content_digest = build_content_digest(body)

            # f) Build signature base
            signature_base = build_signature_base(content_digest, keyid_val, created_val)

            # g) Verify
            is_valid = verify_signature(pub_key, signature_base, sig_bytes)
            if is_valid:
                logging.info(f"✅ Verified label={label} (keyid={keyid_val}, created={created_val})")
            else:
                logging.info(f"❌ Verification FAILED for label={label} (keyid={keyid_val})")
        except Exception as ex:
            logging.error(f"Error with label={label}: {ex}")


if __name__ == "__main__":
    main()

#!/usr/bin/env ruby

require 'openssl'
require 'base64'

SIGNATURE_HEADER = <<~SIG.chomp
  sigtest-key-2=:Jx/NEyQFDL016AVXCFdfC2Ggh/rdJk/zBYYcyQ36QGPYx/Vpxsm74C3dvdK0NHvaPfKw/iecLdHsedKwqetVe9e8eGGS+f86UBCCt4H4gcpSz/Pver7vRH3Y7mDwmKxKoCS+pCeDdgYbrj2ubI4evA8N56mW59vx9CYGNoH31gZIbsSlwrqhSB5zNUs+kulV57G2wJQVkXXfNxZCWEuHdTu9tJ9IsZI/eB+GzXbLqy7Xfchna1/lEk9pGMDWwLs1WFTZ6CQqNpFZ0ObrklIbdsg4Ij6kt6CbLv319yN7hhwrSMQNXjOB8ear9tLQmh0l6gkKG9mhgdWwYWKD4lCPAA==: sigtest-key-1=:AgCVKZg4MRgQgUJp5BWGA68IQzffRfD4tCE3854mY75tnGwBhP58G6nQ7tTs83K8asG4QlEBvY4DNehkatyb6aJ0gBpYWw4SaGrNtN9YV56YiRm0UDgMJvRNwVlsmmIGXi3QS8jrjxiYxWl7mIDWMmsN8WuQ22/K/IFFqBtzKfDQ4lbo8DJSVcn6I9J2nwWAoYPawQQT+SttImQwyx58ElfYQAdUQXaOk1J4W/wyxsi5jdLIz5ESLOf9I0goE0C/wSPbytnxgA18yVUJOwB3GO/TkmAd4I7dCVqodpCsPSwVbCGwKn3E4TnFuJk7zuHq7wlcCPaZvFgmMYWzbsLhDw==:
SIG

SIGNATURE_INPUT_HEADER = <<~INPUT.chomp
  sigtest-key-2=("@method" "@authority" "@request-target" "content-digest");alg="rsa-v1_5-sha256";keyid="test-key-2";created=1737191021 sigtest-key-1=("@method" "@authority" "@request-target" "content-digest");alg="rsa-v1_5-sha256";keyid="test-key-1";created=1737191021
INPUT

PUBLIC_KEY_PEM_1 = <<~PEM
  -----BEGIN PUBLIC KEY-----
  MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx0dGNKhTtKCuopFDYKGX
  rlN712P2JayanbT1KsW7A5pR2dG9lTA1ZGh5xEyR2F6LUhMRYTtALWe5M2oEHTkK
  yQ9mHJImQ+EIvtDGtxPtfeHm5rKl1jFmBPWb+Yh1JWqUjtOTsBbdOwSfAdAlb4Xw
  n8/qBJ6LA1qlWbIvKWsRYj7S/70qSe4Yuqj3SKURMPEdZohTbb9U3viNMnFiDjav
  nQbevra/H1Kc50sM8OugCSSSdsRQf8oXOb+MfvFlg6oEm8Js/affgUBOROH/s9t0
  wW2HJgP1dshsnYI9yT1km6d/gLPUvw7gJMNR/rfkAPIkgpQijOMdVwwqInh/9rjl
  jQIDAQAB
  -----END PUBLIC KEY-----
PEM

PUBLIC_KEY_PEM_2 = PUBLIC_KEY_PEM_1 # same key, simulating rotation

BODY = '{"id":"9bee7f87-6da4-4ef2-a8f6-265fa16c5983","object":"event","topic":"payment_order","type":"approved","data":{"id":"5ac55abb-fe0c-4f23-8610-aaafca8d959c","type":"sepa","amount":21300,"object":"payment_order","status":"approved","purpose":"","currency":"EUR","metadata":{},"bank_data":{"file_id":"","message_id":"","end_to_end_id":"5ac55abbfe0c4f238610aaafca8d959c","transaction_id":"5ac55abbfe0c4f238610aaafca8d959c","original_instruction_id":"5ac55abbfe0c4f238610aaafca8d959c"},"direction":"credit","reference":"Beetax","created_at":"2025-01-17T13:52:14.794145Z","fee_option":"","value_date":"","auto_approval":false,"custom_fields":{},"retry_details":null,"status_details":"","idempotency_key":"","payment_file_id":"","treasury_option":"","connected_account":"00000000-1111-1111-1111-000000000011","receiving_account":{"bank_code":"SOMEBIC9XXX","holder_name":"test","account_number":"NL61ABNA3605998615","holder_address":{"city":"","line_1":"","line_2":"","country":"","postal_code":"","street_name":"","region_state":"","building_number":""}},"reconciled_amount":0,"originating_account":{"bank_code":"SOMEBIC9XXX","holder_name":"123123","account_number":"NL61ABNA3605998615","holder_address":{"city":"","line_1":"","line_2":"","country":"","postal_code":"","street_name":"","region_state":"","building_number":""},"creditor_identifier":""},"ultimate_originator":{"holder_name":"","holder_address":null,"organization_identification":{"other":"","bank_code":""}},"connected_account_id":"00000000-1111-1111-1111-000000000011","direct_debit_mandate":null,"receiving_account_id":"","reconciliation_status":"unreconciled","confidentiality_option":"","originating_account_id":"","direct_debit_mandate_id":"","requested_execution_date":""},"status":"created","status_details":"","related_object_id":"5ac55abb-fe0c-4f23-8610-aaafca8d959c","related_object_type":"payment_order","idempotency_key":"3c32d3e2-d4e1-4249-b5ee-be4927faf67f","created_at":"2025-01-18T09:03:41.643234Z"}'

# -----------------------------------------------------------------------------
# 2) Key Rotation Map (keyid => RSA key)
# -----------------------------------------------------------------------------
PUBLIC_KEYS = {
  "test-key-1" => OpenSSL::PKey::RSA.new(PUBLIC_KEY_PEM_1),
  "test-key-2" => OpenSSL::PKey::RSA.new(PUBLIC_KEY_PEM_2)
}

LABELS = ["sigtest-key-1", "sigtest-key-2"]

# -----------------------------------------------------------------------------
# HELPER FUNCTIONS
# -----------------------------------------------------------------------------

def find_created(sig_input_header, label)
  idx = sig_input_header.index("#{label}=")
  return nil unless idx

  sub = sig_input_header[idx..]
  marker = "created="
  cidx = sub.index(marker)
  return nil unless cidx

  after = sub[(cidx + marker.size)..]
  after[/^\d+/]  # match leading digits
end

def find_keyid(sig_input_header, label)
  idx = sig_input_header.index("#{label}=")
  return nil unless idx

  sub = sig_input_header[idx..]
  m = sub.match(/keyid="([^"]+)"/)
  m ? m[1] : nil
end

def find_signature(sig_header, label)
  marker = "#{label}=:"
  start_idx = sig_header.index(marker)
  return nil unless start_idx

  remainder = sig_header[(start_idx + marker.size)..]
  end_colon = remainder.index(':')
  if end_colon
    remainder[0...end_colon].strip
  else
    remainder.strip
  end
end

def build_content_digest(body_str)
  sha = OpenSSL::Digest::SHA256.new
  bin = sha.digest(body_str)
  "sha-256=:#{Base64.strict_encode64(bin)}:"
end

def build_signature_base(content_digest, keyid, created)
  [
    %{"@method": POST},
    %{"@authority": httpdump.app},
    %{"@request-target": /dumps/91db320b-c734-49e3-9f89-64518106c5c3},
    %{"content-digest": #{content_digest}},
    %{"@signature-params": ("@method" "@authority" "@request-target" "content-digest");alg="rsa-v1_5-sha256";keyid="#{keyid}";created=#{created}}
  ].join("\n")
end

def verify_signature(rsa_key, signature_base, sig_bin)
  rsa_key.verify(OpenSSL::Digest::SHA256.new, sig_bin, signature_base)
end

# -----------------------------------------------------------------------------
# MAIN
# -----------------------------------------------------------------------------
LABELS.each do |label|
  begin
    created_val = find_created(SIGNATURE_INPUT_HEADER, label)
    unless created_val
      puts "Skipping label=#{label}: no created= found."
      next
    end

    keyid_val = find_keyid(SIGNATURE_INPUT_HEADER, label)
    unless keyid_val
      puts "Skipping label=#{label}: no keyid= found."
      next
    end

    base64_sig = find_signature(SIGNATURE_HEADER, label)
    unless base64_sig
      puts "Skipping label=#{label}: no signature found."
      next
    end
    sig_bin = Base64.decode64(base64_sig)

    unless PUBLIC_KEYS[keyid_val]
      puts "Skipping label=#{label}: no public key for keyid=#{keyid_val}"
      next
    end
    rsa_key = PUBLIC_KEYS[keyid_val]

    content_digest = build_content_digest(BODY)
    signature_base = build_signature_base(content_digest, keyid_val, created_val)

    if verify_signature(rsa_key, signature_base, sig_bin)
      puts "✅ Verified label=#{label} (keyid=#{keyid_val}, created=#{created_val})"
    else
      puts "❌ Verification FAILED for label=#{label} (keyid=#{keyid_val})"
    end
  rescue => e
    puts "Error with label=#{label}: #{e}"
  end
end

/**
 * verifyMultiSignature.ts
 *
 */

import * as crypto from 'crypto';

// "Signature" header with 2 labeled signatures: sigtest-key-2, sigtest-key-1
const signatureHeader = `sigtest-key-2=:Jx/NEyQFDL016AVXCFdfC2Ggh/rdJk/zBYYcyQ36QGPYx/Vpxsm74C3dvdK0NHvaPfKw/iecLdHsedKwqetVe9e8eGGS+f86UBCCt4H4gcpSz/Pver7vRH3Y7mDwmKxKoCS+pCeDdgYbrj2ubI4evA8N56mW59vx9CYGNoH31gZIbsSlwrqhSB5zNUs+kulV57G2wJQVkXXfNxZCWEuHdTu9tJ9IsZI/eB+GzXbLqy7Xfchna1/lEk9pGMDWwLs1WFTZ6CQqNpFZ0ObrklIbdsg4Ij6kt6CbLv319yN7hhwrSMQNXjOB8ear9tLQmh0l6gkKG9mhgdWwYWKD4lCPAA==: sigtest-key-1=:AgCVKZg4MRgQgUJp5BWGA68IQzffRfD4tCE3854mY75tnGwBhP58G6nQ7tTs83K8asG4QlEBvY4DNehkatyb6aJ0gBpYWw4SaGrNtN9YV56YiRm0UDgMJvRNwVlsmmIGXi3QS8jrjxiYxWl7mIDWMmsN8WuQ22/K/IFFqBtzKfDQ4lbo8DJSVcn6I9J2nwWAoYPawQQT+SttImQwyx58ElfYQAdUQXaOk1J4W/wyxsi5jdLIz5ESLOf9I0goE0C/wSPbytnxgA18yVUJOwB3GO/TkmAd4I7dCVqodpCsPSwVbCGwKn3E4TnFuJk7zuHq7wlcCPaZvFgmMYWzbsLhDw==:`;

// "Signature-Input" header with 2 labeled inputs: sigtest-key-2, sigtest-key-1
const signatureInputHeader = `sigtest-key-2=("@method" "@authority" "@request-target" "content-digest");alg="rsa-v1_5-sha256";keyid="test-key-2";created=1737191021 sigtest-key-1=("@method" "@authority" "@request-target" "content-digest");alg="rsa-v1_5-sha256";keyid="test-key-1";created=1737191021`;

// For demonstration, we reuse the same public key PEM for both "test-key-1" and "test-key-2".
// In a real scenario, you'd likely have separate keys for each key ID.
const publicKeyPEM1 = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx0dGNKhTtKCuopFDYKGX
rlN712P2JayanbT1KsW7A5pR2dG9lTA1ZGh5xEyR2F6LUhMRYTtALWe5M2oEHTkK
yQ9mHJImQ+EIvtDGtxPtfeHm5rKl1jFmBPWb+Yh1JWqUjtOTsBbdOwSfAdAlb4Xw
n8/qBJ6LA1qlWbIvKWsRYj7S/70qSe4Yuqj3SKURMPEdZohTbb9U3viNMnFiDjav
nQbevra/H1Kc50sM8OugCSSSdsRQf8oXOb+MfvFlg6oEm8Js/affgUBOROH/s9t0
wW2HJgP1dshsnYI9yT1km6d/gLPUvw7gJMNR/rfkAPIkgpQijOMdVwwqInh/9rjl
jQIDAQAB
-----END PUBLIC KEY-----`;

const publicKeyPEM2 = publicKeyPEM1; // Simulating rotation, but reusing the same key

const body = `{"id":"9bee7f87-6da4-4ef2-a8f6-265fa16c5983","object":"event","topic":"payment_order","type":"approved","data":{"id":"5ac55abb-fe0c-4f23-8610-aaafca8d959c","type":"sepa","amount":21300,"object":"payment_order","status":"approved","purpose":"","currency":"EUR","metadata":{},"bank_data":{"file_id":"","message_id":"","end_to_end_id":"5ac55abbfe0c4f238610aaafca8d959c","transaction_id":"5ac55abbfe0c4f238610aaafca8d959c","original_instruction_id":"5ac55abbfe0c4f238610aaafca8d959c"},"direction":"credit","reference":"Beetax","created_at":"2025-01-17T13:52:14.794145Z","fee_option":"","value_date":"","auto_approval":false,"custom_fields":{},"retry_details":null,"status_details":"","idempotency_key":"","payment_file_id":"","treasury_option":"","connected_account":"00000000-1111-1111-1111-000000000011","receiving_account":{"bank_code":"SOMEBIC9XXX","holder_name":"test","account_number":"NL61ABNA3605998615","holder_address":{"city":"","line_1":"","line_2":"","country":"","postal_code":"","street_name":"","region_state":"","building_number":""}},"reconciled_amount":0,"originating_account":{"bank_code":"SOMEBIC9XXX","holder_name":"123123","account_number":"NL61ABNA3605998615","holder_address":{"city":"","line_1":"","line_2":"","country":"","postal_code":"","street_name":"","region_state":"","building_number":""},"creditor_identifier":""},"ultimate_originator":{"holder_name":"","holder_address":null,"organization_identification":{"other":"","bank_code":""}},"connected_account_id":"00000000-1111-1111-1111-000000000011","direct_debit_mandate":null,"receiving_account_id":"","reconciliation_status":"unreconciled","confidentiality_option":"","originating_account_id":"","direct_debit_mandate_id":"","requested_execution_date":""},"status":"created","status_details":"","related_object_id":"5ac55abb-fe0c-4f23-8610-aaafca8d959c","related_object_type":"payment_order","idempotency_key":"3c32d3e2-d4e1-4249-b5ee-be4927faf67f","created_at":"2025-01-18T09:03:41.643234Z"}`;

// -----------------------------------------------------------------------------
// 2) Create a map of public keys keyed by "test-key-1" / "test-key-2" (rotation demo)
// -----------------------------------------------------------------------------
const publicKeys: Record<string, crypto.KeyObject> = {
  "test-key-1": crypto.createPublicKey(publicKeyPEM1),
  "test-key-2": crypto.createPublicKey(publicKeyPEM2),
};

// We want to parse & verify for these label strings
const labels = ["sigtest-key-1", "sigtest-key-2"];

// -----------------------------------------------------------------------------
function main() {
  for (const label of labels) {
    try {
      // a) find created= for this label
      const createdVal = findCreated(signatureInputHeader, label);
      if (!createdVal) {
        console.log(`Skipping label=${label}: no created= found.`);
        continue;
      }
      // b) find keyid="..."
      const keyIDVal = findKeyID(signatureInputHeader, label);
      if (!keyIDVal) {
        console.log(`Skipping label=${label}: no keyid= found.`);
        continue;
      }

      // c) find the signature for this label
      const base64Sig = findSignature(signatureHeader, label);
      if (!base64Sig) {
        console.log(`Skipping label=${label}: no signature found.`);
        continue;
      }

      const sigBuf = Buffer.from(base64Sig, 'base64');

      // d) find the corresponding public key by the keyid
      const pubKey = publicKeys[keyIDVal];
      if (!pubKey) {
        console.log(`Skipping label=${label}: no public key for keyid=${keyIDVal}`);
        continue;
      }

      // e) Build the content-digest from the body
      const bodyHash = crypto.createHash('sha256').update(body, 'utf8').digest('base64');
      const contentDigest = `sha-256=:${bodyHash}:`;

      // f) Build the signature base
      // note that the final line references @signature-params plus alg, keyid, created
      const signatureBase = 
        `"@method": POST\n` +
        `"@authority": httpdump.app\n` +
        `"@request-target": /dumps/91db320b-c734-49e3-9f89-64518106c5c3\n` +
        `"content-digest": ${contentDigest}\n` +
        `"@signature-params": ("@method" "@authority" "@request-target" "content-digest");` +
        `alg="rsa-v1_5-sha256";keyid="${keyIDVal}";created=${createdVal}`;

      // g) Verify the signature (RSA-SHA256 PKCS#1 v1.5)
      const isValid = verifySignature(pubKey, signatureBase, sigBuf);
      if (isValid) {
        console.log(`✅ Verified label=${label} (keyid=${keyIDVal}, created=${createdVal})`);
      } else {
        console.log(`❌ Verification FAILED for label=${label} (keyid=${keyIDVal})`);
      }

    } catch (err: any) {
      console.log(`Error with label=${label}: ${err.message}`);
    }
  }
}

main();

// -----------------------------------------------------------------------------
// HELPER FUNCTIONS
// -----------------------------------------------------------------------------

/**
 * findCreated
 * Direct substring search for created=NNNN in the chunk for a given label
 */
function findCreated(sigInputHeader: string, label: string): string | null {
  const idx = sigInputHeader.indexOf(`${label}=`);
  if (idx < 0) return null;
  const sub = sigInputHeader.substring(idx);

  const cIdx = sub.indexOf("created=");
  if (cIdx < 0) return null;

  const after = sub.substring(cIdx + "created=".length);
  let digits = "";
  for (let i = 0; i < after.length; i++) {
    const ch = after[i];
    if (ch < '0' || ch > '9') break;
    digits += ch;
  }
  return digits || null;
}

/**
 * findKeyID
 * Direct substring search for keyid="..."
 */
function findKeyID(sigInputHeader: string, label: string): string | null {
  const idx = sigInputHeader.indexOf(`${label}=`);
  if (idx < 0) return null;
  const sub = sigInputHeader.substring(idx);

  const keyMarker = `keyid="`;
  const kIdx = sub.indexOf(keyMarker);
  if (kIdx < 0) return null;

  const after = sub.substring(kIdx + keyMarker.length);
  let keyid = "";
  for (let i = 0; i < after.length; i++) {
    if (after[i] === '"') break;
    keyid += after[i];
  }
  return keyid || null;
}

/**
 * findSignature
 * Extract the base64 from: label=:<base64>:
 */
function findSignature(signatureHeader: string, label: string): string | null {
  const marker = `${label}=:`;
  const start = signatureHeader.indexOf(marker);
  if (start < 0) return null;
  const remainder = signatureHeader.substring(start + marker.length);

  // Next colon is the end
  const endColon = remainder.indexOf(':');
  if (endColon < 0) {
    return remainder.trim();
  }
  return remainder.substring(0, endColon).trim();
}

/**
 * verifySignature
 * Node's RSA-SHA256 PKCS#1 v1.5 approach
 */
function verifySignature(pubKey: crypto.KeyObject, data: string, sigBuf: Buffer): boolean {
  try {
    const verifier = crypto.createVerify('RSA-SHA256');
    verifier.update(data, 'utf8');
    // Alternatively: verifier.end() if streaming
    return verifier.verify(
      {
        key: pubKey,
        padding: crypto.constants.RSA_PKCS1_PADDING,
      },
      sigBuf
    );
  } catch {
    return false;
  }
}

2.2.5. Public keys

{"records":[{"id":"nml-owsk-sandboxeuw3-1734623147","pem_value":"-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAp5dqYIHMtuBaKAvFjRMn\nuKQzfbzg86rciPyCHpxZidBJ16A1W9PR636gZDLzzPUusCpDhNszK/wYpaTO5GKc\nfuulmyps7Lb/N95hkIYSQvDd3GiF4ddNkCfN2q17k6h9uwjDW2imv/68H6vVK/5y\nGOh76hJ2gAjPBtg40xPA/C/0Mn+0gG2i/pO2SsudXCCJii7TEUiapgSuJ9V7lMRJ\ndNrv0Qr+VK+0S7uaOHFEM23yjbTxlOMJW+OZEvulYgxnS5y4vximwirGJ9Gd3idM\nWtriWeF7G8N2CWtjcdAps916cQO7vU2QHBDmG2bGHYN1hCYI9iq54Yy2FghpBnGt\nkmEmKAVL1yFkoX2j8L33mKZv2Xk6bxonQEpyv3l6PfP9b7TmZxsMFtFbSV8/B28M\nCaDQtnFSD34oYcsODjBHypwpzKirNutwV5CAzxpwtC3K0Klxyz71gMtSUiS7vELG\n1+SajsWpE8sD0aPSTSdoAaVNh1ZqiUvZvLpIse9LreJa9XXcL7GM/Io8WHzkiL2z\nYm4DobFtE5xj3x045eYWJq82M1qkd8y+OX5zEkbrYs9iBOc9u1Lh6Xrb4enl/XDG\nkjHqtp/WGFQroVtET9iUxhhgbP1k0kD8PgyJb5znc6RtdvWyBJGNgnXZVbnnCh8f\nZQGzU37Hwl/BBfWCyFCF3q0CAwEAAQ==\n-----END PUBLIC KEY-----\n","status":"active"}]}
{"records":[{"id":"nml-owsk-prodeuw3-1734623575","pem_value":"-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAymwgjt5omZeT+7p5I6sV\nmA1WstFjZ62Uyw0UPmwDXq704Qb+DBMG9NSJiS8vb8rDv8InXF9sJWiGAPe5wjfw\nRUVReO1qnPcCj7fMKI8CsQ/LWce3hO1hZrYUWLCsvNFtgV1oy+/sKJNJHchpmue1\ntedwmsRpSx/BdhCdU/YBCZCUp0kFS2NP8WuLEUO9tjHSM9mv6tPMPxgCOmMswEyW\nkMvmMym/MQnpVpPi8l1t1m+dFWYHlHUbfsD0whJHo8JaMtwFsYJ0b128SqZ0Sq/o\n3bn9pReutZDpG58W0niCrDr8knWi47rZglZjojTB1caQqFbNl/qVG6RYQth+gzFJ\nCyd/9BivE0+6pgu95cKPgFYUKvV5zLv+meZCYZj7rYF+ATUIZeaKyib0x4i1Ao15\nFdrxqmSHOHPrScLWzcKe5PtguzbaVNkkU1yJ7Wy3NWBlrUkzb0ycfILcME8XMN2d\nauX9BEE21+KQuo2cTq1MoEIaFc++TCNIgH3Y6qfE5oRj/1UXMxrhpXPkldej1S2Y\nzPV3T0dLDbmFv25vbWvlx/pT2awW1aOyGYcRyNWanNrOSZaQPRGWSN/w8cVLsBg6\nFyT4CUu7Eo/Jx2NToybZsTxKlk6U6KdB/1ZAIwkX9EjCmJg/espkCBAi6PUSY0ZW\nc3Df6gaO/lYlNlVnWyFZGFcCAwEAAQ==\n-----END PUBLIC KEY-----","status":"active"}]}

3. API key

Numeral webhook URLs can be protected using an API key. When enabled, all POST requests sent by Numeral will include an X-API-Key header filled with a pre-agreed secure key. Numeral customers can then authenticate all incoming requests based on this API key.