Independent Verification
Offline receipt verification and transparency log proofs.
AttendiBot supports three verification modes with increasing independence from AttendiBot infrastructure.
Trust models
| Mode | Input | Trust required | Detects tampering | Detects deletion |
|---|---|---|---|---|
| UUID lookup | Session UUID | AttendiBot API + database | Yes | No |
| Offline receipt | Export/webhook JSON | None (math only) | Yes | No |
| Transparency log | Receipt + inclusion proof + STH | None for proof math; optional anchor for STH freshness | Yes | Yes |
Signing versions
v1 (current default)
AttendiBot v1 signs the Serde JSON serialization of the payload struct with no extra whitespace.
Session payload field order (must match exactly):
session_id(UUID string)guild_id(integer)user_id(integer)channel_id(integer)period_id(UUID string)joined_at(RFC 3339 UTC, microsecond precision when stored)left_at(RFC 3339 UTC)duration_seconds(integer)
Period snapshot payload field order:
period_idguild_idperiod_indexstarted_atended_atentries(array of leaderboard entries)
Verification steps:
- Rebuild canonical JSON bytes for the payload (v1 rules above)
payload_hashmust equalSHA256(canonical_bytes)as lowercase hexsignaturemust verify as Ed25519 detached signature over canonical bytes usingpublic_key(base64, 32 raw bytes)
v2 (planned cross-language)
Future records may use RFC 8785 JCS canonicalization (signing_version: 2 in receipts). v1 records remain verifiable with v1 rules indefinitely.
Offline verification
Session receipt JSON
Pro exports and session.completed webhooks include a self-contained receipt:
{
"signing_version": 1,
"guild_id": 123456789,
"period_id": "550e8400-e29b-41d4-a716-446655440000",
"signing_key_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"public_key": "base64-ed25519-public-key",
"signed_payload": {
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"guild_id": 123456789,
"user_id": 987654321,
"channel_id": 111222333,
"period_id": "6ba7b811-9dad-11d1-80b4-00c04fd430c8",
"joined_at": "2026-07-01T12:00:00.000000Z",
"left_at": "2026-07-01T13:30:00.000000Z",
"duration_seconds": 5400
},
"signature": "base64-ed25519-signature",
"payload_hash": "lowercase-sha256-hex",
"log_index": 42,
"inclusion_proof": { "...": "..." },
"sth": { "...": "..." }
}CLI (no network)
attendibot-verify session --receipt receipt.json
attendibot-verify period --receipt period-receipt.json
attendibot-verify inclusion --proof proof.json --root-hash abc123...Build from the backend workspace: cargo build --bin attendibot-verify
Web offline verifier
On attendibot.com/#verifier, use the Offline receipt tab to paste or upload receipt JSON. Verification runs entirely in your browser via Web Crypto.
HTTP offline verify (no DB lookup)
POST /verify/receipt/session
Content-Type: application/json
{ ... SessionReceipt ... }Returns { "valid": true } or { "valid": false, "reason": "hash_mismatch" | "signature_mismatch" }.
Public key discovery
Unauthenticated endpoints:
GET /verify/keys/{guild_id}— active and retired public keysGET /.well-known/attendibot/{guild_id}.json— same document via the web BFF
Embed public_key in receipts so offline verification never requires a live key fetch.
Transparency log (RFC 6962-style)
Each guild maintains an append-only Merkle log of signed records.
Leaf hash: SHA256(0x00 || canonical_json({ record_type, record_id, payload_hash, signature, signing_key_id }))
Signed tree head (STH): Ed25519 signature over v1 JSON of { guild_id, tree_size, root_hash, timestamp }
Public endpoints
| Endpoint | Description |
|---|---|
GET /verify/log/{guild_id}/sth | Latest signed tree head |
GET /verify/log/{guild_id}/proof/{log_index} | Inclusion proof for a leaf |
GET /verify/log/{guild_id}/consistency?from_size=&to_size= | Consistency proof between sizes |
Verify inclusion offline
- Confirm receipt signature (offline steps above)
- Confirm
inclusion_proofrecomputes tosth.root_hash - Confirm STH signature verifies with guild public key
- Optionally compare STH to an externally anchored copy
Backfill historical records
Admins run (dry-run first):
/admin backfill-transparency-log dry_run:trueOr dashboard: Signing → Backfill transparency log
External anchoring
Set TRANSPARENCY_ANCHOR_URL on the bot to POST signed tree heads every 5 minutes. Anchors are stored in guild_sth_anchors for audit.
Third parties can monitor the anchor URL independently to detect retroactive log rewriting.
Reference: Python v1 session verify
import base64, hashlib, json
from nacl.signing import VerifyKey
receipt = json.load(open("receipt.json"))
payload = receipt["signed_payload"]
canonical = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
assert hashlib.sha256(canonical.encode()).hexdigest() == receipt["payload_hash"]
vk = VerifyKey(base64.b64decode(receipt["public_key"]))
sig = base64.b64decode(receipt["signature"])
vk.verify(canonical.encode(), sig)
print("valid")Reference: Node.js v1 session verify
import { createHash } from "node:crypto";
import { verify } from "node:crypto";
const receipt = JSON.parse(await fs.readFile("receipt.json", "utf8"));
const canonical = JSON.stringify(receipt.signed_payload);
const hash = createHash("sha256").update(canonical).digest("hex");
if (hash !== receipt.payload_hash) throw new Error("hash mismatch");
const ok = verify(null, Buffer.from(canonical), { key: receipt.public_key, format: "base64", type: "ed25519" }, Buffer.from(receipt.signature, "base64"));
if (!ok) throw new Error("signature mismatch");
console.log("valid");Rate limits and input bounds
Public verification surfaces apply 30 requests per minute per IP on both the AttendiBot API (/verify/*) and the marketing-site BFF (/api/verify/*, /.well-known/attendibot/*). Exceeding the limit returns HTTP 429 with a Retry-After header.
Input validation highlights:
- Guild IDs must be valid Discord snowflakes (5–22 decimal digits).
- Session IDs must be UUIDs for server-assisted lookup.
- Receipt JSON accepted by offline verification in the browser is capped at 512 KiB; proof/STH JSON pasted in the transparency checker is capped at 64 KiB.
- Backend receipt POST bodies are capped at 1 MiB via the global HTTP body limit.
- Signing version must be
1for receipt verification today; v2 JCS is planned but not yet accepted.
Rate limiting is in-memory per process. Deployments with multiple API replicas should treat the limit as approximate unless a shared store is added later.
Related docs
- Signing & Verification
- Commands —
/admin verify-session,/admin backfill-transparency-log - Dashboard — export and signing tools