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.
AI security calls are only as trustworthy as the trail behind them. Three concrete cases where the signature pays off:
BLOCK.
The user proceeded anyway via a different rail, lost funds, then claimed you never
warned them. Show the signed audit record — the chain of custody is intact.The signature is HMAC-SHA256 over a canonical, pipe-separated string built from five commitment fields:
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:
tg_sig_{key_id}_{hex_digest}
↑ ↑
v1 64 hex chars (sha256)
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".
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.
When we rotate the signing key, this fingerprint changes and key_id bumps
(e.g. v1 → v2). Older signatures keep their original
key_id in the prefix, and our verifier loads the historical key
automatically — your stored signatures stay valid indefinitely.
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.
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)
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)); }
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; }
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.
{
"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"
}
{
"valid": true,
"key_id": "v1",
"key_fingerprint": "fe18dc2a18c4260b",
"canonical_form": "tg_AGT_ecad5004|BLOCK|EQAa…|100.000000|2026-05-11T13:49:20.490480Z"
}
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.
| Code | Meaning | Body 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: "..."} |
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.
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.
Only the five canonical fields. Specifically excluded:
risks[] — these can be expanded between scans (new detectors land),
and we don't want past signatures to "break" when our taxonomy grows.confidence — exact floats are bucketed in public views and may drift
across minor scoring updates.reasoning — free-form text, frequently rephrased.agent_wallet, target_wallet, mcp_server — not
commitment-grade, can be PII, never in public verify either.
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.
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".
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.
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.
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.
| Date | Change | Affects |
|---|---|---|
| 2026-05 | Initial publication. Algorithm = HMAC-SHA256, key_id = v1. | — |
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.