Signing samples

Reference implementations of the ZooZ Operator API v1 signature scheme (signing.md) in six languages. Each file contains the same four building blocks, with no third-party dependencies:

Function Purpose
request canonical v1\nMETHOD\npath\nquery\ntimestamp\nnonce\nhex(sha256(rawBody))
response canonical v1\nRESPONSE\nrequestNonce\ntimestamp\nhex(sha256(rawBody))
sign / verify v1= + lowercase hex HMAC-SHA256; verification is constant-time
is fresh abs(now - timestamp) <= 300 seconds

Running a sample recomputes the published test vectors (see signing.md), checks that a genuine signature is accepted and a tampered one is rejected, and exits with status 1 on any mismatch. Copy the functions into your code base. Leave the test-vector check in your unit tests.

#How to run

Language File Command Requirements
Node.js (TypeScript) node/zooz-signature.ts node zooz-signature.ts Node 22.18+ or 23.6+ (runs TypeScript directly). Older: npx tsx zooz-signature.ts
Python python/zooz_signature.py python3 zooz_signature.py Python 3.8+
PHP php/zooz_signature.php php zooz_signature.php PHP 8.0+
C# (.NET) csharp/ZooZSignature.cs dotnet run ZooZSignature.cs .NET 10 SDK for the one-file run. The ZooZSignature class itself works on .NET 6+
Java java/ZooZSignature.java java ZooZSignature.java Java 17+
Go go/zoozsignature.go go run zoozsignature.go Go 1.18+

Run each command from the sample's own folder.

#Expected output

Every sample prints exactly this:

Text
request body sha256  : 6114f6c4fe63af1458b115e2c4c295b88f4b73cb7a86bec728c4aa0f466937da  OK
request signature    : v1=84a363dd364fdd0962ca1c02274405f0fc860e59d1e2adba474e6164d7d7000d  OK
response body sha256 : 523ade2f8c8982b55cb95eeebda35ac6e030e3e123d48a7126a66f2cdb7b4c78  OK
response signature   : v1=839bf4984976d32e6be6694e2ca9661d6104bbb9fef13e9cbf2398e50bd31f49  OK
verify response      : accepted OK
verify tampered      : rejected OK
All test vectors match.

The secret in the samples is the public test-vector secret. Never use it in any environment. Load real secrets from your secret store or environment. Never log them.

#Using the functions in a real handler

  • Verifying a wallet call from ZooZ: read the raw request body before any JSON parsing. Build the request canonical with the method, the path and query exactly as received, and the X-ZooZ-Timestamp and X-ZooZ-Nonce headers. Pick the secret by X-ZooZ-Key-Id. Run the checks in the order given in signing.md.
  • Signing your wallet response: serialise your JSON once, sign those exact bytes with the request's nonce, and send the same bytes as the body.
  • Calling ZooZ (POST /api/v1/operator/sessions): serialise the body once, sign it, and send exactly those bytes. Before you use the result, verify the response signature with the nonce you sent.

#Source files

#Node.js (TypeScript)

zooz-signature.ts

TypeScript
// ZooZ Operator API v1 - request/response signing and verification (Node.js, no dependencies).
//
// Run:  node zooz-signature.ts        (Node 22.18+ / 23.6+ run TypeScript directly)
//       npx tsx zooz-signature.ts     (older Node versions)
// It recomputes the published test vectors and exits with status 1 if any value differs.
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';

export const VERSION = 'v1';
export const MAX_CLOCK_SKEW_SECONDS = 300;

const sha256Hex = (data: Uint8Array): string => createHash('sha256').update(data).digest('hex');

/**
 * Canonical string of a request. `path` has no scheme/host; `query` has no leading '?' ('' if none).
 * `rawBody` must be the exact bytes sent or received - never re-serialized JSON.
 */
export function requestCanonical(method: string, path: string, query: string, timestamp: number, nonce: string, rawBody: Uint8Array): string {
  return [VERSION, method.toUpperCase(), path, query.replace(/^\?/, ''), String(timestamp), nonce, sha256Hex(rawBody)].join('\n');
}

/** Canonical string of a response. It is bound to the nonce of the request it answers. */
export function responseCanonical(requestNonce: string, timestamp: number, rawBody: Uint8Array): string {
  return [VERSION, 'RESPONSE', requestNonce, String(timestamp), sha256Hex(rawBody)].join('\n');
}

/** Value of the X-ZooZ-Signature header: 'v1=' + lowercase hex HMAC-SHA256. */
export function sign(secret: Uint8Array, canonical: string): string {
  return `${VERSION}=${createHmac('sha256', secret).update(canonical, 'utf8').digest('hex')}`;
}

/** Constant-time check of an X-ZooZ-Signature header value. */
export function verify(secret: Uint8Array, canonical: string, header: string | null | undefined): boolean {
  if (!header || !header.startsWith(`${VERSION}=`)) return false;
  const hex = header.slice(VERSION.length + 1);
  if (!/^[0-9a-fA-F]{64}$/.test(hex)) return false;
  const expected = createHmac('sha256', secret).update(canonical, 'utf8').digest();
  return timingSafeEqual(Buffer.from(hex, 'hex'), expected);
}

/** True when the timestamp is within +/-300 seconds of the local clock. */
export function isFresh(timestamp: number, now: number = Math.floor(Date.now() / 1000)): boolean {
  return Math.abs(now - timestamp) <= MAX_CLOCK_SKEW_SECONDS;
}

// ---------------------------------------------------------------- published test vectors (operator-api section 7)
const SECRET = Buffer.from('Wm9vWi10ZXN0LXZlY3Rvci1zZWNyZXQtMzItYnl0ZXMhIQ==', 'base64');
const NONCE = '5f0c2d64-7a1b-4c8e-9d3f-2b6a1e0c9f11';
const REQUEST_BODY = Buffer.from(
  '{"transactionId":"r1-bet","roundId":"r1","gameId":"safari-king","sessionId":"s-42","playerId":"12345","currency":"EUR","amount":1.00}',
  'utf8');
const RESPONSE_BODY = Buffer.from('{"status":"ok","balance":999.00,"operatorTransactionId":"tx-1"}', 'utf8');
const EXPECTED: Record<string, string> = {
  'request body sha256': '6114f6c4fe63af1458b115e2c4c295b88f4b73cb7a86bec728c4aa0f466937da',
  'request signature': 'v1=84a363dd364fdd0962ca1c02274405f0fc860e59d1e2adba474e6164d7d7000d',
  'response body sha256': '523ade2f8c8982b55cb95eeebda35ac6e030e3e123d48a7126a66f2cdb7b4c78',
  'response signature': 'v1=839bf4984976d32e6be6694e2ca9661d6104bbb9fef13e9cbf2398e50bd31f49',
};

const reqCanonical = requestCanonical('POST', '/wallet/debit', '', 1790000000, NONCE, REQUEST_BODY);
const resCanonical = responseCanonical(NONCE, 1790000001, RESPONSE_BODY);
const actual: Record<string, string> = {
  'request body sha256': sha256Hex(REQUEST_BODY),
  'request signature': sign(SECRET, reqCanonical),
  'response body sha256': sha256Hex(RESPONSE_BODY),
  'response signature': sign(SECRET, resCanonical),
};

let ok = true;
for (const [name, value] of Object.entries(actual)) {
  const match = value === EXPECTED[name];
  ok &&= match;
  console.log(`${name.padEnd(21)}: ${value}  ${match ? 'OK' : 'MISMATCH'}`);
}

// A receiver must accept the genuine response and reject any change (here: the credit endpoint instead of debit).
const accepted = verify(SECRET, resCanonical, EXPECTED['response signature']);
const rejected = !verify(SECRET, requestCanonical('POST', '/wallet/credit', '', 1790000000, NONCE, REQUEST_BODY), EXPECTED['request signature']);
ok &&= accepted && rejected;
console.log(`${'verify response'.padEnd(21)}: ${accepted ? 'accepted OK' : 'REJECTED - MISMATCH'}`);
console.log(`${'verify tampered'.padEnd(21)}: ${rejected ? 'rejected OK' : 'ACCEPTED - MISMATCH'}`);

console.log(ok ? 'All test vectors match.' : 'TEST VECTOR MISMATCH');
process.exitCode = ok ? 0 : 1;

#Python 3

zooz_signature.py

Python
#!/usr/bin/env python3
"""ZooZ Operator API v1 - request/response signing and verification (Python 3.8+, standard library only).

Run:  python3 zooz_signature.py
It recomputes the published test vectors and exits with status 1 if any value differs.
"""
import base64
import hashlib
import hmac
import sys
import time

VERSION = "v1"
MAX_CLOCK_SKEW_SECONDS = 300


def _sha256_hex(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


def request_canonical(method: str, path: str, query: str, timestamp: int, nonce: str, raw_body: bytes) -> str:
    """Canonical string of a request. `path` has no scheme/host; `query` has no leading '?' ('' if none).
    `raw_body` must be the exact bytes sent or received - never re-serialized JSON."""
    return "\n".join([VERSION, method.upper(), path, query.lstrip("?"), str(timestamp), nonce, _sha256_hex(raw_body)])


def response_canonical(request_nonce: str, timestamp: int, raw_body: bytes) -> str:
    """Canonical string of a response. It is bound to the nonce of the request it answers."""
    return "\n".join([VERSION, "RESPONSE", request_nonce, str(timestamp), _sha256_hex(raw_body)])


def sign(secret: bytes, canonical: str) -> str:
    """Value of the X-ZooZ-Signature header: 'v1=' + lowercase hex HMAC-SHA256."""
    return VERSION + "=" + hmac.new(secret, canonical.encode("utf-8"), hashlib.sha256).hexdigest()


def verify(secret: bytes, canonical: str, header) -> bool:
    """Constant-time check of an X-ZooZ-Signature header value."""
    if not header or not header.startswith(VERSION + "="):
        return False
    return hmac.compare_digest(sign(secret, canonical).encode("ascii"), header.lower().encode("ascii", "replace"))


def is_fresh(timestamp: int, now=None) -> bool:
    """True when the timestamp is within +/-300 seconds of the local clock."""
    now = int(time.time()) if now is None else now
    return abs(now - timestamp) <= MAX_CLOCK_SKEW_SECONDS


# ---------------------------------------------------------------- published test vectors (operator-api section 7)
SECRET = base64.b64decode("Wm9vWi10ZXN0LXZlY3Rvci1zZWNyZXQtMzItYnl0ZXMhIQ==")
NONCE = "5f0c2d64-7a1b-4c8e-9d3f-2b6a1e0c9f11"
REQUEST_BODY = (b'{"transactionId":"r1-bet","roundId":"r1","gameId":"safari-king","sessionId":"s-42",'
                b'"playerId":"12345","currency":"EUR","amount":1.00}')
RESPONSE_BODY = b'{"status":"ok","balance":999.00,"operatorTransactionId":"tx-1"}'
EXPECTED = {
    "request body sha256": "6114f6c4fe63af1458b115e2c4c295b88f4b73cb7a86bec728c4aa0f466937da",
    "request signature": "v1=84a363dd364fdd0962ca1c02274405f0fc860e59d1e2adba474e6164d7d7000d",
    "response body sha256": "523ade2f8c8982b55cb95eeebda35ac6e030e3e123d48a7126a66f2cdb7b4c78",
    "response signature": "v1=839bf4984976d32e6be6694e2ca9661d6104bbb9fef13e9cbf2398e50bd31f49",
}


def main() -> int:
    req_canonical = request_canonical("POST", "/wallet/debit", "", 1790000000, NONCE, REQUEST_BODY)
    res_canonical = response_canonical(NONCE, 1790000001, RESPONSE_BODY)
    actual = {
        "request body sha256": _sha256_hex(REQUEST_BODY),
        "request signature": sign(SECRET, req_canonical),
        "response body sha256": _sha256_hex(RESPONSE_BODY),
        "response signature": sign(SECRET, res_canonical),
    }
    ok = True
    for name, value in actual.items():
        match = value == EXPECTED[name]
        ok &= match
        print(f"{name:<21}: {value}  {'OK' if match else 'MISMATCH'}")

    # A receiver must accept the genuine response and reject any change (here: the credit endpoint instead of debit).
    accepted = verify(SECRET, res_canonical, EXPECTED["response signature"])
    tampered = request_canonical("POST", "/wallet/credit", "", 1790000000, NONCE, REQUEST_BODY)
    rejected = not verify(SECRET, tampered, EXPECTED["request signature"])
    ok &= accepted and rejected
    print(f"{'verify response':<21}: {'accepted OK' if accepted else 'REJECTED - MISMATCH'}")
    print(f"{'verify tampered':<21}: {'rejected OK' if rejected else 'ACCEPTED - MISMATCH'}")

    print("All test vectors match." if ok else "TEST VECTOR MISMATCH")
    return 0 if ok else 1


if __name__ == "__main__":
    sys.exit(main())

#PHP 8

zooz_signature.php

PHP
<?php
// ZooZ Operator API v1 - request/response signing and verification (PHP 8.0+, no extensions beyond the defaults).
//
// Run:  php zooz_signature.php
// It recomputes the published test vectors and exits with status 1 if any value differs.
declare(strict_types=1);

const ZOOZ_VERSION = 'v1';
const ZOOZ_MAX_CLOCK_SKEW_SECONDS = 300;

/**
 * Canonical string of a request. $path has no scheme/host; $query has no leading '?' ('' if none).
 * $rawBody must be the exact bytes sent or received (e.g. file_get_contents('php://input')) - never re-encoded JSON.
 */
function zooz_request_canonical(string $method, string $path, string $query, int $timestamp, string $nonce, string $rawBody): string
{
    return implode("\n", [ZOOZ_VERSION, strtoupper($method), $path, ltrim($query, '?'), (string)$timestamp, $nonce, hash('sha256', $rawBody)]);
}

/** Canonical string of a response. It is bound to the nonce of the request it answers. */
function zooz_response_canonical(string $requestNonce, int $timestamp, string $rawBody): string
{
    return implode("\n", [ZOOZ_VERSION, 'RESPONSE', $requestNonce, (string)$timestamp, hash('sha256', $rawBody)]);
}

/** Value of the X-ZooZ-Signature header: 'v1=' + lowercase hex HMAC-SHA256. $secret is the base64-decoded key. */
function zooz_sign(string $secret, string $canonical): string
{
    return ZOOZ_VERSION . '=' . hash_hmac('sha256', $canonical, $secret);
}

/** Constant-time check of an X-ZooZ-Signature header value. */
function zooz_verify(string $secret, string $canonical, ?string $header): bool
{
    if ($header === null || !str_starts_with($header, ZOOZ_VERSION . '=')) {
        return false;
    }
    return hash_equals(zooz_sign($secret, $canonical), strtolower($header));
}

/** True when the timestamp is within +/-300 seconds of the local clock. */
function zooz_is_fresh(int $timestamp, ?int $now = null): bool
{
    return abs(($now ?? time()) - $timestamp) <= ZOOZ_MAX_CLOCK_SKEW_SECONDS;
}

// ------------------------------------------------------------------ published test vectors (operator-api section 7)
if (PHP_SAPI === 'cli' && realpath($argv[0] ?? '') === __FILE__) {
    $secret = base64_decode('Wm9vWi10ZXN0LXZlY3Rvci1zZWNyZXQtMzItYnl0ZXMhIQ==', true);
    $nonce = '5f0c2d64-7a1b-4c8e-9d3f-2b6a1e0c9f11';
    $requestBody = '{"transactionId":"r1-bet","roundId":"r1","gameId":"safari-king","sessionId":"s-42",'
        . '"playerId":"12345","currency":"EUR","amount":1.00}';
    $responseBody = '{"status":"ok","balance":999.00,"operatorTransactionId":"tx-1"}';
    $expected = [
        'request body sha256' => '6114f6c4fe63af1458b115e2c4c295b88f4b73cb7a86bec728c4aa0f466937da',
        'request signature' => 'v1=84a363dd364fdd0962ca1c02274405f0fc860e59d1e2adba474e6164d7d7000d',
        'response body sha256' => '523ade2f8c8982b55cb95eeebda35ac6e030e3e123d48a7126a66f2cdb7b4c78',
        'response signature' => 'v1=839bf4984976d32e6be6694e2ca9661d6104bbb9fef13e9cbf2398e50bd31f49',
    ];

    $reqCanonical = zooz_request_canonical('POST', '/wallet/debit', '', 1790000000, $nonce, $requestBody);
    $resCanonical = zooz_response_canonical($nonce, 1790000001, $responseBody);
    $actual = [
        'request body sha256' => hash('sha256', $requestBody),
        'request signature' => zooz_sign($secret, $reqCanonical),
        'response body sha256' => hash('sha256', $responseBody),
        'response signature' => zooz_sign($secret, $resCanonical),
    ];

    $ok = true;
    foreach ($actual as $name => $value) {
        $match = $value === $expected[$name];
        $ok = $ok && $match;
        printf("%-21s: %s  %s\n", $name, $value, $match ? 'OK' : 'MISMATCH');
    }

    // A receiver must accept the genuine response and reject any change (here: the credit endpoint instead of debit).
    $accepted = zooz_verify($secret, $resCanonical, $expected['response signature']);
    $rejected = !zooz_verify($secret, zooz_request_canonical('POST', '/wallet/credit', '', 1790000000, $nonce, $requestBody),
        $expected['request signature']);
    $ok = $ok && $accepted && $rejected;
    printf("%-21s: %s\n", 'verify response', $accepted ? 'accepted OK' : 'REJECTED - MISMATCH');
    printf("%-21s: %s\n", 'verify tampered', $rejected ? 'rejected OK' : 'ACCEPTED - MISMATCH');

    echo $ok ? "All test vectors match.\n" : "TEST VECTOR MISMATCH\n";
    exit($ok ? 0 : 1);
}

#C# (.NET)

ZooZSignature.cs

C#
// ZooZ Operator API v1 - request/response signing and verification (C# / .NET 6+, no packages).
//
// Run:  dotnet run ZooZSignature.cs      (.NET 10 SDK "file-based app")
// On older SDKs copy the ZooZSignature class into your project and call ZooZSignature.SelfTest().
// It recomputes the published test vectors and exits with status 1 if any value differs.
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;

return ZooZSignature.SelfTest();

public static class ZooZSignature
{
    public const string Version = "v1";
    public const int MaxClockSkewSeconds = 300;

    /// <summary>
    /// Canonical string of a request. <paramref name="path"/> has no scheme/host; <paramref name="query"/> has no
    /// leading '?' ("" if none). <paramref name="rawBody"/> must be the exact bytes sent or received.
    /// </summary>
    public static string RequestCanonical(string method, string path, string query, long timestamp, string nonce, byte[] rawBody) =>
        string.Join("\n", Version, method.ToUpperInvariant(), path, query.TrimStart('?'),
            timestamp.ToString(System.Globalization.CultureInfo.InvariantCulture), nonce, Sha256Hex(rawBody));

    /// <summary>Canonical string of a response. It is bound to the nonce of the request it answers.</summary>
    public static string ResponseCanonical(string requestNonce, long timestamp, byte[] rawBody) =>
        string.Join("\n", Version, "RESPONSE", requestNonce, timestamp.ToString(System.Globalization.CultureInfo.InvariantCulture), Sha256Hex(rawBody));

    /// <summary>Value of the X-ZooZ-Signature header: "v1=" + lowercase hex HMAC-SHA256.</summary>
    public static string Sign(byte[] secret, string canonical) => Version + "=" + Hex(Hmac(secret, canonical));

    /// <summary>Constant-time check of an X-ZooZ-Signature header value.</summary>
    public static bool Verify(byte[] secret, string canonical, string? header)
    {
        if (header is null || !header.StartsWith(Version + "=", StringComparison.Ordinal)) return false;
        byte[] given;
        try
        {
            given = Convert.FromHexString(header.Substring(Version.Length + 1));
        }
        catch (FormatException)
        {
            return false;
        }
        return CryptographicOperations.FixedTimeEquals(given, Hmac(secret, canonical));
    }

    /// <summary>True when the timestamp is within ±300 seconds of the local clock.</summary>
    public static bool IsFresh(long timestamp, DateTimeOffset now) => Math.Abs(now.ToUnixTimeSeconds() - timestamp) <= MaxClockSkewSeconds;

    private static byte[] Hmac(byte[] secret, string canonical)
    {
        using var hmac = new HMACSHA256(secret);
        return hmac.ComputeHash(Encoding.UTF8.GetBytes(canonical));
    }

    private static string Sha256Hex(byte[] data)
    {
        using var sha = SHA256.Create();
        return Hex(sha.ComputeHash(data));
    }

    private static string Hex(byte[] bytes) => Convert.ToHexString(bytes).ToLowerInvariant();

    // ------------------------------------------------------------ published test vectors (operator-api section 7)
    public static int SelfTest()
    {
        var secret = Convert.FromBase64String("Wm9vWi10ZXN0LXZlY3Rvci1zZWNyZXQtMzItYnl0ZXMhIQ==");
        const string nonce = "5f0c2d64-7a1b-4c8e-9d3f-2b6a1e0c9f11";
        var requestBody = Encoding.UTF8.GetBytes(
            "{\"transactionId\":\"r1-bet\",\"roundId\":\"r1\",\"gameId\":\"safari-king\",\"sessionId\":\"s-42\",\"playerId\":\"12345\",\"currency\":\"EUR\",\"amount\":1.00}");
        var responseBody = Encoding.UTF8.GetBytes("{\"status\":\"ok\",\"balance\":999.00,\"operatorTransactionId\":\"tx-1\"}");
        var expected = new Dictionary<string, string>
        {
            ["request body sha256"] = "6114f6c4fe63af1458b115e2c4c295b88f4b73cb7a86bec728c4aa0f466937da",
            ["request signature"] = "v1=84a363dd364fdd0962ca1c02274405f0fc860e59d1e2adba474e6164d7d7000d",
            ["response body sha256"] = "523ade2f8c8982b55cb95eeebda35ac6e030e3e123d48a7126a66f2cdb7b4c78",
            ["response signature"] = "v1=839bf4984976d32e6be6694e2ca9661d6104bbb9fef13e9cbf2398e50bd31f49",
        };

        var reqCanonical = RequestCanonical("POST", "/wallet/debit", "", 1790000000, nonce, requestBody);
        var resCanonical = ResponseCanonical(nonce, 1790000001, responseBody);
        var actual = new List<KeyValuePair<string, string>>
        {
            new("request body sha256", Sha256Hex(requestBody)),
            new("request signature", Sign(secret, reqCanonical)),
            new("response body sha256", Sha256Hex(responseBody)),
            new("response signature", Sign(secret, resCanonical)),
        };

        var ok = true;
        foreach (var (name, value) in actual)
        {
            var match = value == expected[name];
            ok &= match;
            Console.WriteLine($"{name,-21}: {value}  {(match ? "OK" : "MISMATCH")}");
        }

        // A receiver must accept the genuine response and reject any change (here: the credit endpoint instead of debit).
        var accepted = Verify(secret, resCanonical, expected["response signature"]);
        var rejected = !Verify(secret, RequestCanonical("POST", "/wallet/credit", "", 1790000000, nonce, requestBody), expected["request signature"]);
        ok &= accepted && rejected;
        Console.WriteLine($"{"verify response",-21}: {(accepted ? "accepted OK" : "REJECTED - MISMATCH")}");
        Console.WriteLine($"{"verify tampered",-21}: {(rejected ? "rejected OK" : "ACCEPTED - MISMATCH")}");

        Console.WriteLine(ok ? "All test vectors match." : "TEST VECTOR MISMATCH");
        return ok ? 0 : 1;
    }
}

#Java 17+

ZooZSignature.java

Java
// ZooZ Operator API v1 - request/response signing and verification (Java 17+, JDK only).
//
// Run:  java ZooZSignature.java
// It recomputes the published test vectors and exits with status 1 if any value differs.
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.util.Base64;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.Map;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public final class ZooZSignature {
    public static final String VERSION = "v1";
    public static final long MAX_CLOCK_SKEW_SECONDS = 300;
    private static final HexFormat HEX = HexFormat.of(); // lowercase

    private ZooZSignature() {}

    /**
     * Canonical string of a request. {@code path} has no scheme/host; {@code query} has no leading '?' ("" if none).
     * {@code rawBody} must be the exact bytes sent or received - never re-serialized JSON.
     */
    public static String requestCanonical(String method, String path, String query, long timestamp, String nonce, byte[] rawBody) {
        String q = query.startsWith("?") ? query.substring(1) : query;
        return String.join("\n", VERSION, method.toUpperCase(java.util.Locale.ROOT), path, q, Long.toString(timestamp), nonce, sha256Hex(rawBody));
    }

    /** Canonical string of a response. It is bound to the nonce of the request it answers. */
    public static String responseCanonical(String requestNonce, long timestamp, byte[] rawBody) {
        return String.join("\n", VERSION, "RESPONSE", requestNonce, Long.toString(timestamp), sha256Hex(rawBody));
    }

    /** Value of the X-ZooZ-Signature header: "v1=" + lowercase hex HMAC-SHA256. */
    public static String sign(byte[] secret, String canonical) {
        return VERSION + "=" + HEX.formatHex(hmac(secret, canonical));
    }

    /** Constant-time check of an X-ZooZ-Signature header value. */
    public static boolean verify(byte[] secret, String canonical, String header) {
        if (header == null || !header.startsWith(VERSION + "=")) return false;
        byte[] given;
        try {
            given = HEX.parseHex(header.substring(VERSION.length() + 1));
        } catch (IllegalArgumentException e) {
            return false;
        }
        return MessageDigest.isEqual(given, hmac(secret, canonical)); // constant time since Java 6u17
    }

    /** True when the timestamp is within +/-300 seconds of the local clock. */
    public static boolean isFresh(long timestamp, long nowEpochSeconds) {
        return Math.abs(nowEpochSeconds - timestamp) <= MAX_CLOCK_SKEW_SECONDS;
    }

    private static byte[] hmac(byte[] secret, String canonical) {
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(secret, "HmacSHA256"));
            return mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8));
        } catch (GeneralSecurityException e) {
            throw new IllegalStateException(e);
        }
    }

    private static String sha256Hex(byte[] data) {
        try {
            return HEX.formatHex(MessageDigest.getInstance("SHA-256").digest(data));
        } catch (GeneralSecurityException e) {
            throw new IllegalStateException(e);
        }
    }

    // ------------------------------------------------------------ published test vectors (operator-api section 7)
    public static void main(String[] args) {
        byte[] secret = Base64.getDecoder().decode("Wm9vWi10ZXN0LXZlY3Rvci1zZWNyZXQtMzItYnl0ZXMhIQ==");
        String nonce = "5f0c2d64-7a1b-4c8e-9d3f-2b6a1e0c9f11";
        byte[] requestBody = ("{\"transactionId\":\"r1-bet\",\"roundId\":\"r1\",\"gameId\":\"safari-king\",\"sessionId\":\"s-42\","
                + "\"playerId\":\"12345\",\"currency\":\"EUR\",\"amount\":1.00}").getBytes(StandardCharsets.UTF_8);
        byte[] responseBody = "{\"status\":\"ok\",\"balance\":999.00,\"operatorTransactionId\":\"tx-1\"}".getBytes(StandardCharsets.UTF_8);
        Map<String, String> expected = Map.of(
                "request body sha256", "6114f6c4fe63af1458b115e2c4c295b88f4b73cb7a86bec728c4aa0f466937da",
                "request signature", "v1=84a363dd364fdd0962ca1c02274405f0fc860e59d1e2adba474e6164d7d7000d",
                "response body sha256", "523ade2f8c8982b55cb95eeebda35ac6e030e3e123d48a7126a66f2cdb7b4c78",
                "response signature", "v1=839bf4984976d32e6be6694e2ca9661d6104bbb9fef13e9cbf2398e50bd31f49");

        String reqCanonical = requestCanonical("POST", "/wallet/debit", "", 1790000000L, nonce, requestBody);
        String resCanonical = responseCanonical(nonce, 1790000001L, responseBody);
        Map<String, String> actual = new LinkedHashMap<>();
        actual.put("request body sha256", sha256Hex(requestBody));
        actual.put("request signature", sign(secret, reqCanonical));
        actual.put("response body sha256", sha256Hex(responseBody));
        actual.put("response signature", sign(secret, resCanonical));

        boolean ok = true;
        for (Map.Entry<String, String> e : actual.entrySet()) {
            boolean match = e.getValue().equals(expected.get(e.getKey()));
            ok &= match;
            System.out.printf("%-21s: %s  %s%n", e.getKey(), e.getValue(), match ? "OK" : "MISMATCH");
        }

        // A receiver must accept the genuine response and reject any change (here: the credit endpoint instead of debit).
        boolean accepted = verify(secret, resCanonical, expected.get("response signature"));
        boolean rejected = !verify(secret, requestCanonical("POST", "/wallet/credit", "", 1790000000L, nonce, requestBody),
                expected.get("request signature"));
        ok &= accepted && rejected;
        System.out.printf("%-21s: %s%n", "verify response", accepted ? "accepted OK" : "REJECTED - MISMATCH");
        System.out.printf("%-21s: %s%n", "verify tampered", rejected ? "rejected OK" : "ACCEPTED - MISMATCH");

        System.out.println(ok ? "All test vectors match." : "TEST VECTOR MISMATCH");
        System.exit(ok ? 0 : 1);
    }
}

#Go

zoozsignature.go

Go
// ZooZ Operator API v1 - request/response signing and verification (Go 1.18+, standard library only).
//
// Run:  go run zoozsignature.go
// It recomputes the published test vectors and exits with status 1 if any value differs.
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"fmt"
	"os"
	"strconv"
	"strings"
	"time"
)

const (
	Version             = "v1"
	MaxClockSkewSeconds = 300
)

func sha256Hex(data []byte) string {
	sum := sha256.Sum256(data)
	return hex.EncodeToString(sum[:])
}

// RequestCanonical builds the canonical string of a request. path has no scheme/host; query has no
// leading '?' ("" if none). rawBody must be the exact bytes sent or received - never re-marshalled JSON.
func RequestCanonical(method, path, query string, timestamp int64, nonce string, rawBody []byte) string {
	return strings.Join([]string{Version, strings.ToUpper(method), path, strings.TrimPrefix(query, "?"),
		strconv.FormatInt(timestamp, 10), nonce, sha256Hex(rawBody)}, "\n")
}

// ResponseCanonical builds the canonical string of a response, bound to the nonce of the request it answers.
func ResponseCanonical(requestNonce string, timestamp int64, rawBody []byte) string {
	return strings.Join([]string{Version, "RESPONSE", requestNonce, strconv.FormatInt(timestamp, 10), sha256Hex(rawBody)}, "\n")
}

func mac(secret []byte, canonical string) []byte {
	m := hmac.New(sha256.New, secret)
	m.Write([]byte(canonical))
	return m.Sum(nil)
}

// Sign returns the X-ZooZ-Signature header value: "v1=" + lowercase hex HMAC-SHA256.
func Sign(secret []byte, canonical string) string {
	return Version + "=" + hex.EncodeToString(mac(secret, canonical))
}

// Verify checks an X-ZooZ-Signature header value in constant time.
func Verify(secret []byte, canonical, header string) bool {
	if !strings.HasPrefix(header, Version+"=") {
		return false
	}
	given, err := hex.DecodeString(header[len(Version)+1:])
	if err != nil {
		return false
	}
	return hmac.Equal(given, mac(secret, canonical))
}

// IsFresh reports whether the timestamp is within +/-300 seconds of now.
func IsFresh(timestamp int64, now time.Time) bool {
	d := now.Unix() - timestamp
	if d < 0 {
		d = -d
	}
	return d <= MaxClockSkewSeconds
}

// ---------------------------------------------------------------- published test vectors (operator-api section 7)
func main() {
	secret, _ := base64.StdEncoding.DecodeString("Wm9vWi10ZXN0LXZlY3Rvci1zZWNyZXQtMzItYnl0ZXMhIQ==")
	nonce := "5f0c2d64-7a1b-4c8e-9d3f-2b6a1e0c9f11"
	requestBody := []byte(`{"transactionId":"r1-bet","roundId":"r1","gameId":"safari-king","sessionId":"s-42","playerId":"12345","currency":"EUR","amount":1.00}`)
	responseBody := []byte(`{"status":"ok","balance":999.00,"operatorTransactionId":"tx-1"}`)
	expected := map[string]string{
		"request body sha256":  "6114f6c4fe63af1458b115e2c4c295b88f4b73cb7a86bec728c4aa0f466937da",
		"request signature":    "v1=84a363dd364fdd0962ca1c02274405f0fc860e59d1e2adba474e6164d7d7000d",
		"response body sha256": "523ade2f8c8982b55cb95eeebda35ac6e030e3e123d48a7126a66f2cdb7b4c78",
		"response signature":   "v1=839bf4984976d32e6be6694e2ca9661d6104bbb9fef13e9cbf2398e50bd31f49",
	}

	reqCanonical := RequestCanonical("POST", "/wallet/debit", "", 1790000000, nonce, requestBody)
	resCanonical := ResponseCanonical(nonce, 1790000001, responseBody)
	actual := [][2]string{
		{"request body sha256", sha256Hex(requestBody)},
		{"request signature", Sign(secret, reqCanonical)},
		{"response body sha256", sha256Hex(responseBody)},
		{"response signature", Sign(secret, resCanonical)},
	}

	ok := true
	for _, a := range actual {
		match := a[1] == expected[a[0]]
		ok = ok && match
		status := "OK"
		if !match {
			status = "MISMATCH"
		}
		fmt.Printf("%-21s: %s  %s\n", a[0], a[1], status)
	}

	// A receiver must accept the genuine response and reject any change (here: the credit endpoint instead of debit).
	accepted := Verify(secret, resCanonical, expected["response signature"])
	rejected := !Verify(secret, RequestCanonical("POST", "/wallet/credit", "", 1790000000, nonce, requestBody), expected["request signature"])
	ok = ok && accepted && rejected
	fmt.Printf("%-21s: %s\n", "verify response", map[bool]string{true: "accepted OK", false: "REJECTED - MISMATCH"}[accepted])
	fmt.Printf("%-21s: %s\n", "verify tampered", map[bool]string{true: "rejected OK", false: "ACCEPTED - MISMATCH"}[rejected])

	if !ok {
		fmt.Println("TEST VECTOR MISMATCH")
		os.Exit(1)
	}
	fmt.Println("All test vectors match.")
}