ServicesAttestation

Attestation

Proof that a Snug identity controls an account somewhere else. The service issues a nonce-bearing challenge text, the user posts it publicly - a tweet, a gist, a DNS TXT record - and the server fetches that URL back and records a ledger entry binding the handle to the Snug user.

When to reach for it: verified-developer badges from a public gist, creator verification from a social post, domain-ownership proof during org onboarding, impersonation-resistant marketplace listings, a provable cross-platform handle for an AI agent.

When not to: if you control the login flow and the user can simply sign in with the platform, OAuth account linking in Auth is stronger and needs no public post. Contested facts across several parties belong to Consensus; content sealed for conditional later release belongs to Vault.

Concepts

  • The challenge is a seed, not a record. generate-text mints a nonce and stashes a short-lived seed keyed by (Snug user, platform, identity); nothing reaches the ledger until verification succeeds.
  • Verification is a server-side fetch. You hand over a public URL; the server retrieves it and checks the body carries both the nonce and your Snug user id. No platform API keys, no OAuth - an unreadable page cannot be verified.
  • platform picks the retrieval strategy. dns and domain resolve TXT records over DNS-over-HTTPS; github and gist normalize the URL to its raw form; anything else is fetched as a plain HTTP body.
  • One verified claim per platform:identity, globally - not per user. A second user proving the same handle gets 409 identity_claimed until the first claim is revoked or expires.
  • Attestations are revocable and optionally time-boxed. expires_in_seconds sets a lifetime; a background sweep flips lapsed proofs to expired and releases the handle for re-claim.
  • resolve is the public side; everything else is private. Any signed-in caller can resolve any handle, but reading, listing, auditing, and revoking a proof are owner-or-admin only.

Prove an identity

snug attestation generate-text --platform github --identity docs4-alice
# Proof Text: I am proving my identity on github. I am the user 'docs-wave' on
#   Snug, and I am claiming the handle 'docs4-alice'. This is proof ID: c5de7-a12e-8324f.

snug attestation verify --platform github --identity docs4-alice \
  --proof-url https://gist.github.com/alice/abc123

The same over HTTP, with the envelope:

curl -X POST -H "Authorization: Bearer $SNUG_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"platform":"twitter","identity":"docs4-bob","proof_url":"https://example.com/proof.txt"}' \
  http://localhost:4000/api/v1/proofs/verify
{
  "status": 200,
  "msg": "OK",
  "data": {
    "proof_id": "proof_c22fff562c434a3f9b531146a24f24b4",
    "verified": true,
    "platform": "twitter",
    "identity": "docs4-bob",
    "signature_verified": false,
    "attestation_url": "/api/v1/proofs/proof_c22fff562c434a3f9b531146a24f24b4",
    "verified_at": "2026-08-28T22:54:39.221066Z",
    "expires_at": null
  }
}

signature_verified: false means this is a possession proof: whoever could post to that URL controls the account. To upgrade it to a cryptographic proof, see Signed proofs. The fetched body only has to contain the nonce and the Snug user id, so a post that wraps the proof text in surrounding prose still verifies. Content missing the nonce is 400 invalid_proof_text; a URL the server cannot read is 404 proof_not_found.

Read attestations back

snug attestation get --proof-id proof_663ab96eeaa742bdacac8e216ec3a8d6
snug attestation list                                # the caller's own proofs
snug attestation list --platform github --page-size 2
snug attestation audit --proof-id proof_663ab96eeaa742bdacac8e216ec3a8d6 --limit 10

list returns a proofs array plus the standard pagination object (25 per page by default) and takes only platform, page, and page_size - not the shared search grammar. audit returns the per-proof event stream newest-first (verified, revoked, expired), defaulting to 50 events and capped by the stream's 500-event limit. Listing another user's proofs, or reading one you do not own, is 403. A platform admin token bypasses that, and by passing user_id in the body of generate-text and verify it can also complete a proof on another identity's behalf. That same user_id from a non-admin token is 403 user_mismatch.

Resolve a handle

The lookup a badge renders from - any authenticated caller may ask, and the answer names the owning Snug user. proof is trimmed below; it carries the same fields as get:

snug --output json attestation resolve --platform github --identity docs4-alice
{
  "platform": "github",
  "identity": "docs4-alice",
  "verified": true,
  "proof": {
    "proof_id": "proof_663ab96eeaa742bdacac8e216ec3a8d6",
    "user_id": "docs-wave",
    "signature_verified": false,
    "verified_at": "2026-08-28T22:54:39.180770Z"
  }
}

An unclaimed handle is still 200, with verified: false and a null proof. Unauthenticated callers get 401: "public" here means visible to any caller, not anonymous.

Revoke and expire

snug attestation revoke --proof-id proof_c22fff562c434a3f9b531146a24f24b4

Revoking flips the status to revoked, drops the handle out of resolve, and frees it - verified live, a different user claimed the same twitter:docs4-bob handle immediately afterwards. Revoking twice is 409 already_revoked.

Time-boxing is the same release on a timer: pass expires_in_seconds (1 second to 1 year) at verify time and a background sweep transitions the proof to expired. The sweep is on by default. Its lag depends on where expires_at falls in the tick - two 1-second-lifetime runs landed the expired audit event 7 and 53 seconds after the deadline - so budget up to one full sweep interval, 60 seconds by default.

Behaviors worth knowing

  • Regenerating invalidates the previous text. Each generate-text for the same (user, platform, identity) overwrites the seed, so only the newest nonce verifies; posting an older proof text is 400 invalid_proof_text. Seeds also lapse on their own after 15 minutes.
  • Expiry is swept, not evaluated on read. Between expires_at and the next sweep tick a proof still reads verified and still resolves - observed 43 seconds past its deadline. Compare expires_at yourself if the boundary has to be exact.
  • Re-verifying is idempotent, not a refresh. Proving a handle you already hold returns the original proof_id and verified_at; it neither mints a second record nor extends a lifetime. To move an expiry, revoke and prove again.
  • Branch on error, not msg. Unrelated 400s surface under the invalid_proof_text error code behind a "Proof text does not contain a valid nonce" prefix - an out-of-range expires_in_seconds and a disallowed URL scheme both do.

Limits and configuration

Platform names up to 64 characters, identities up to 256, proof URLs up to 2048, a 15-minute seed TTL, a 6-hour resolved-identity cache, a 10-second fetch timeout, and a 1-year maximum lifetime - all tunable, with the expiry worker's interval and batch size, via the ATTESTATION_* variables in the Attestation CONFIG reference.

Reference

On this page