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:
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-TimestampandX-ZooZ-Nonceheaders. Pick the secret byX-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
// 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
#!/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
// 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
// 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
// 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
// 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.")
}