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
), thekeyid
, 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.