AttendiBotAttendiBot

Independent Verification

Offline receipt verification and transparency log proofs.

AttendiBot supports three verification modes with increasing independence from AttendiBot infrastructure.

Trust models

ModeInputTrust requiredDetects tamperingDetects deletion
UUID lookupSession UUIDAttendiBot API + databaseYesNo
Offline receiptExport/webhook JSONNone (math only)YesNo
Transparency logReceipt + inclusion proof + STHNone for proof math; optional anchor for STH freshnessYesYes

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):

  1. session_id (UUID string)
  2. guild_id (integer)
  3. user_id (integer)
  4. channel_id (integer)
  5. period_id (UUID string)
  6. joined_at (RFC 3339 UTC, microsecond precision when stored)
  7. left_at (RFC 3339 UTC)
  8. duration_seconds (integer)

Period snapshot payload field order:

  1. period_id
  2. guild_id
  3. period_index
  4. started_at
  5. ended_at
  6. entries (array of leaderboard entries)

Verification steps:

  1. Rebuild canonical JSON bytes for the payload (v1 rules above)
  2. payload_hash must equal SHA256(canonical_bytes) as lowercase hex
  3. signature must verify as Ed25519 detached signature over canonical bytes using public_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 keys
  • GET /.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

EndpointDescription
GET /verify/log/{guild_id}/sthLatest 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

  1. Confirm receipt signature (offline steps above)
  2. Confirm inclusion_proof recomputes to sth.root_hash
  3. Confirm STH signature verifies with guild public key
  4. Optionally compare STH to an externally anchored copy

Backfill historical records

Admins run (dry-run first):

/admin backfill-transparency-log dry_run:true

Or 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 1 for 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.

Edit on GitHub

On this page