Docs/Audit Signatures

Audit Signatures

Every TonWise AI Agent Guard decision is signed with HMAC-SHA256 over a canonical commitment string. The signature lets your users — or your auditor — cryptographically verify that we actually issued the decision you're showing them, on the fields you're showing them, at the time you're showing them.

Why this exists

AI security calls are only as trustworthy as the trail behind them. Three concrete cases where the signature pays off:

The algorithm

The signature is HMAC-SHA256 over a canonical, pipe-separated string built from five commitment fields:

Canonical form
canonical = telemetry_id + "|" + decision + "|" + target_contract + "|" + value_usd + "|" + computed_at
            └────────────┘   └──────┘   └────────────────┘   └─────────┘   └────────────┘
            "tg_AGT_…"      ALLOW/      ToN address          float, 6      ISO-8601 UTC
                            REVIEW/                          decimals      "2026-05-12T22:01:22Z"
                            BLOCK

The five fields are exactly the values that appear in the response body of POST /api/v1/agent-guard. The signature prefix encodes the key generation so future rotations stay verifiable:

Signature format
tg_sig_{key_id}_{hex_digest}
        ↑       ↑
        v1      64 hex chars (sha256)

Field semantics

telemetry_id
"tg_AGT_…" — unique per decision
decision
"ALLOW" | "REVIEW" | "BLOCK"
target_contract
TON address (raw, friendly form as we received it)
value_usd
Estimated USD, formatted with exactly 6 decimals
computed_at
ISO-8601 UTC, exactly as appears in the response

Formatting matters

Float formatting in particular: value_usd is rendered with Python's %.6f — i.e. always six decimals, no scientific notation, no trailing-zero trimming. 10.0 becomes "10.000000"; 0 becomes "0.000000". If your verifier formats it differently, the signature won't match even though the value is "equal".

Public key fingerprint

The signing key is symmetric — we keep the actual key bytes server-side. What we publish is a SHA-256 fingerprint of that key (truncated to 16 hex chars). Verifying clients can call /api/v1/audit/verify and compare the returned fingerprint to this one; if it matches, both sides are using the same key generation.

Current verify key fingerprint
fe18dc2a18c4260b
key_id: v1

When we rotate the signing key, this fingerprint changes and key_id bumps (e.g. v1v2). Older signatures keep their original key_id in the prefix, and our verifier loads the historical key automatically — your stored signatures stay valid indefinitely.

Offline verification recipes

You don't need to call us to verify a signature. The algorithm and the verify-key bytes are the only things you need — the verify-key bytes you get from us once, out-of-band, and store as your own secret.

Python

python 3.10+
import hmac, hashlib

def verify_tonwise(
    signature: str,        # "tg_sig_v1_abc123..."
    telemetry_id: str,
    decision: str,
    target_contract: str,
    value_usd: float,
    computed_at: str,     # ISO-8601, exactly as received
    verify_key: bytes,     # the key bytes you got from TonWise
) -> bool:
    canonical = f"{telemetry_id}|{decision}|{target_contract}|{value_usd:.6f}|{computed_at}"
    expected  = hmac.new(verify_key, canonical.encode(), hashlib.sha256).hexdigest()
    # Strip "tg_sig_{key_id}_" prefix
    actual    = signature.split("_", 3)[3] if signature.startswith("tg_sig_") else signature
    return hmac.compare_digest(expected, actual)

Node.js

node 18+
const crypto = require('crypto');

function verifyTonWise({
  signature, telemetryId, decision, targetContract, valueUsd, computedAt, verifyKey
}) {
  const canonical = `${telemetryId}|${decision}|${targetContract}|${valueUsd.toFixed(6)}|${computedAt}`;
  const expected  = crypto.createHmac('sha256', verifyKey).update(canonical).digest('hex');
  const actual    = signature.startsWith('tg_sig_') ? signature.split('_')[3] : signature;
  // Constant-time compare to prevent timing oracles
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(actual));
}

Browser (Web Crypto API)

browser · Web Crypto
async function verifyTonWise({ signature, telemetryId, decision, targetContract, valueUsd, computedAt, verifyKey }) {
  const canonical = `${telemetryId}|${decision}|${targetContract}|${valueUsd.toFixed(6)}|${computedAt}`;
  const key = await crypto.subtle.importKey(
    'raw', verifyKey,
    { name: 'HMAC', hash: 'SHA-256' },
    false, ['sign']
  );
  const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(canonical));
  const hex = Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join('');
  const actual = signature.startsWith('tg_sig_') ? signature.split('_')[3] : signature;
  return hex === actual;
}

Hosted verification endpoint

If you'd rather not implement HMAC client-side — or you want a third party (auditor, insurer, user's lawyer) to verify without sharing your verify-key — call our hosted endpoint. It's public, unauthenticated, IP-rate-limited.

Method
POST
URL
https://tonguard.app/api/v1/audit/verify
Auth
none (public, IP-rate-limited)
Rate
60 requests / min / IP

Request body

JSON
{
  "signature":       "tg_sig_v1_abc123...",
  "telemetry_id":    "tg_AGT_ecad5004",
  "decision":        "BLOCK",
  "target_contract": "EQAa…",
  "value_usd":       100.0,
  "computed_at":     "2026-05-11T13:49:20.490480Z"
}

Response (200)

JSON
{
  "valid":           true,
  "key_id":          "v1",
  "key_fingerprint": "fe18dc2a18c4260b",
  "canonical_form":  "tg_AGT_ecad5004|BLOCK|EQAa…|100.000000|2026-05-11T13:49:20.490480Z"
}

cURL example

shell
curl -sS -X POST https://tonguard.app/api/v1/audit/verify \
  -H "Content-Type: application/json" \
  -d '{
    "signature":       "tg_sig_v1_abc123...",
    "telemetry_id":    "tg_AGT_ecad5004",
    "decision":        "BLOCK",
    "target_contract": "EQAa…",
    "value_usd":       100.0,
    "computed_at":     "2026-05-11T13:49:20.490480Z"
  }'

The endpoint returns the canonical_form it actually checked against — handy for debugging "why doesn't my signature verify": diff your canonical against ours and the formatting issue will be obvious.

Status codes

CodeMeaningBody shape
200 Verification result returned {valid, key_id, key_fingerprint, canonical_form}
400 Malformed JSON or missing required field {error: "..."}
422 Field types invalid (value_usd not numeric, etc.) {error: "..."}
429 Per-IP rate limit exceeded {error: "..."}
503 Signing key not configured server-side (should never happen in prod) {error: "..."}

Note on negative results

A 200 with {"valid": false} is the normal "this signature doesn't match" response — it is not an error. Only HTTP-level failures (4xx/5xx) mean the verification couldn't be attempted.

FAQ

How long are signatures valid?

Forever, in principle. We keep all historical signing keys; rotation only changes which key is active for new decisions. A signature minted in v1 three years ago will still verify under v1 today.

What does not the signature cover?

Only the five canonical fields. Specifically excluded:

What the signature does commit: that, at computed_at, for this telemetry_id against target_contract with value_usd estimated, we issued decision. That's the core liability surface.

What happens if my computed_at doesn't match exactly?

You will fail to verify. Store the response's computed_at verbatim, including the microseconds and the "Z" suffix. If you're saving the response to a database, save it as a string, not a parsed datetime — round-tripping through a datetime parser is the #1 cause of "I'm sure the signature should match".

Can I rotate my view of the verify key?

Yes — rotation is operator-driven on our side, but you can hold multiple verify keys simultaneously. For each historical key_id you've encountered, keep the key. The signature prefix tells you which one to use. If you only ever care about the latest, just always use the current one and accept that old receipts won't verify until you fetch the historical key from us.

Is HMAC enough? Why not Ed25519 / asymmetric?

For our threat model, yes. HMAC is symmetric, so verifying requires the verify-key bytes — which is fine: we share the verify-key out-of-band with anyone who has a legitimate audit need. Asymmetric signing would let anyone verify without contacting us at all, which is the strictly nicer property — but it would also let an attacker who compromised our signing key forge unlimited backdated signatures. With HMAC, an attacker would also need to have ever held the same verify-key. We may add an Ed25519 track in a future major version for clients who want pure-public verification; the HMAC track will remain available.

Where does the verify-key live on your side?

Environment variable AGENT_GUARD_SIGNING_KEY, never on disk, never in the database, never logged. Loaded at process start. Rotation by redeploy. We do not have key escrow.

Changelog

DateChangeAffects
2026-05Initial publication. Algorithm = HMAC-SHA256, key_id = v1.

Found a problem?

Signature won't verify against a real receipt and you're stuck? Open a ticket via the contact form and include the canonical_form our endpoint returned vs. the one you computed — the diff is usually the answer.