Aeonis Passport specification

Version 1.0 · 18 October 2026 · Covers SDK 1.0.0, 1.1.0 and 1.2.0 · Test vectors

This page defines how an Aeonis Passport reaches a world and how a world checks it. It is written so you can build your own verifier without the Aeonis SDK. The words must, should and may carry their usual RFC 2119 meaning.

  1. Terms
  2. How a pass arrives
  3. Pass format and claims
  4. Key set (JWKS) and rotation
  5. Verification steps
  6. Replay protection
  7. What is shared, what never is
  8. Guest fallback
  9. Automatic avatar looks
  10. Ownership proof
  11. Versioning and deprecation
  12. Test vectors

1. Terms

TermMeaning
PassportA visitor’s Aeonis identity: a display name and two avatar colours. Kept by Aeonis; it never holds an email address.
WorldA website a creator connects to Aeonis, identified by its origin (scheme, host and port, for example https://world.example).
PassA short-lived signed token (JWT) that Aeonis makes for one visitor and one world when the visitor travels there and agrees to share.
Issuerhttps://search.aeonis.pro. The only issuer in v1.
AudienceThe origin of the world the pass was made for. A pass is only valid on that origin.
Key set (JWKS)The issuer’s published public keys, at https://search.aeonis.pro/.well-known/jwks.json.
Passport codeThe sub claim, aps_ plus 32 characters. Stable for one visitor on one world, different on every other world (pairwise), so worlds can’t link visitors across worlds by it.
GuestWhat a world gets when there is no valid pass. Worlds must keep working for guests.
LookThe visitor drawn to fit a world, returned by the SDK’s adapt() (section 9).

2. How a pass arrives

Aeonis sends the visitor to the world’s registered address with the pass in the URL fragment: https://world.example/#aeonis_passport=<pass>. Browsers never send the fragment to servers, so the pass doesn’t appear in server logs. A world must remove it from the address bar as soon as it reads it (for example with history.replaceState), and must not log or store the raw pass.

3. Pass format and claims

A pass is a JWS in compact form (header.payload.signature, base64url without padding), signed with Ed25519. The header is {"alg":"EdDSA","typ":"JWT","kid":"…"}. The signature covers the ASCII bytes of header.payload.

ClaimTypeMeaning
issstringAlways https://search.aeonis.pro.
audstringThe world’s origin, exactly as registered (no path, no trailing slash).
substringPassport code for this world (see Terms). Required.
iat, nbf, expnumberSeconds since 1970 (UTC). nbf = iat − 5, exp = iat + 300: a pass lasts 5 minutes.
jtistringA random id, unique per pass. Required. Used for replay protection.
worldstringThe world’s Aeonis id (w_…).
passport.vnumber1.
passport.namestringDisplay name, 1–24 characters, chosen by the visitor. Treat it as untrusted text when you display it.
passport.avatarobjectglb (avatar file URL, served with open CORS), format (glTF-2.0-humanoid), rig (aeonis-ual-1), licence (CC0-1.0) and colors (body, trim as #rrggbb).
passport.stampsnumberHow many worlds the visitor has stamped. A count only, never the list. Left out when Aeonis can’t count them at that moment; the SDK then reports 0.
passport.returnstringWhere to send the visitor home. Only follow it if it starts with https://search.aeonis.pro/.

Verifiers must ignore claims they don’t know. New optional claims may appear in minor versions.

4. Key set (JWKS) and rotation

GET https://search.aeonis.pro/.well-known/jwks.json returns {"keys":[…]}, served with open CORS. Each key has kty OKP, crv Ed25519, x (the public key, base64url), kid (its RFC 7638 SHA-256 thumbprint), use sig, alg EdDSA, and status:

Verifiers should cache the key set (the HTTP cache lifetime is 5 minutes; a verifier may keep its own copy for up to 30 days and refresh it after 1 hour). When a pass names a kid you don’t have, fetch the key set again, at most once a minute. A verifier may also ship a pinned copy of the keys it knows (SDK 1.2.0 pins the keys published when it shipped). A key set fetched after the pinned copy was made takes precedence over it, so a retired key stops working once a verifier has seen the newer key set.

Rotation policy. A new signing key is published as next before it signs, it then becomes current while the old key stays retiring for at least 1 hour, and then the old key is removed. Its private half is destroyed.

5. Verification steps

Check in this order and stop at the first failure. The reason codes are the ones the SDK and the test vectors use.

  1. Split into three parts and decode the header and payload as JSON objects. Reject a pass over 4,000 characters. Otherwise malformed.
  2. The header alg must be EdDSA. Reject anything else, including none and HS256. Otherwise alg.
  3. Find the key whose kid matches the header’s kid in your trusted key set. Otherwise unknown_key.
  4. Verify the Ed25519 signature over header.payload. Otherwise signature. Don’t trust any claim before this step passes.
  5. iss must be https://search.aeonis.pro. Otherwise issuer.
  6. aud must equal your own origin. Otherwise audience.
  7. exp must be later than now − 30 s. Otherwise expired.
  8. iat (and nbf if present) must be earlier than now + 60 s. Otherwise not_yet_valid.
  9. sub and jti must be strings and passport an object. Otherwise claims.
  10. Optionally, reject a jti you have already accepted (section 6). Otherwise replayed.

Browsers without Ed25519 in WebCrypto may ask Aeonis instead: POST https://search.aeonis.pro/api/passport/verify with {"token":"…"}. The audience is taken from the request’s Origin header. The answer is {"ok":true,"claims":{…}} or {"ok":false,"reason":"…"}. This is the only step that needs Aeonis to be reachable.

A minimal verifier (Node 22, no dependencies)

import { webcrypto as c } from 'node:crypto';
const b = s => Buffer.from(s, 'base64url');
export async function verifyPass(token, audience, jwks, now = Date.now() / 1000) {
  const p = String(token).split('.'); if (p.length !== 3 || token.length > 4000) return 'malformed';
  let h, x; try { h = JSON.parse(b(p[0])); x = JSON.parse(b(p[1])); } catch { return 'malformed'; }
  if (!h || !x || typeof x !== 'object') return 'malformed';
  if (h.alg !== 'EdDSA') return 'alg';
  const k = jwks.keys.find(k => k.kid === h.kid && k.kty === 'OKP' && k.crv === 'Ed25519'); if (!k) return 'unknown_key';
  const key = await c.subtle.importKey('jwk', { kty: 'OKP', crv: 'Ed25519', x: k.x }, 'Ed25519', false, ['verify']);
  if (!await c.subtle.verify('Ed25519', key, b(p[2]), Buffer.from(p[0] + '.' + p[1]))) return 'signature';
  if (x.iss !== 'https://search.aeonis.pro') return 'issuer';
  if (x.aud !== audience) return 'audience';
  if (!(x.exp > now - 30)) return 'expired';
  if (!(x.iat < now + 60) || (x.nbf != null && !(x.nbf < now + 60))) return 'not_yet_valid';
  if (typeof x.sub !== 'string' || typeof x.jti !== 'string' || !x.passport || typeof x.passport !== 'object') return 'claims';
  return { ok: true, claims: x };
}

6. Replay protection

A pass is meant to be used once. SDK 1.2.0 records each accepted jti in the world’s localStorage until it expires, so the same pass is refused in every tab of that browser on that site, with no server involved. SDK 1.0.0 and 1.1.0 do this per tab (sessionStorage).

Limits. Browser storage can’t see other browsers or devices, so within its 5 minutes a copied pass could still be accepted once in a different browser. Private windows and cleared storage start empty. If your world has a server, the complete fix is to record each jti there until exp + 60 s and reject repeats. A pass only ever carries what the visitor agreed to share with that one world, and only that world’s origin accepts it.

7. What is shared, and what never is

Only after the visitor agrees, on a consent screen that names the world: display name, avatar colours, stamp count and the passport code for that world. Never shared: email address, sign-in method or account ids, the list of worlds visited, the visitor’s passport codes for other worlds, IP address or location. Aeonis sends no tracking data to worlds. The SDK’s only requests to Aeonis are key-set refreshes, sent without cookies or referrer, and the fallback check in section 5.

8. Guest fallback

When there is no valid pass, the world must keep working and treat the visitor as a guest. The SDK never throws; it returns { guest: true, name: 'Guest', reason, returnUrl } with one of these reasons:

ReasonWhen
no_passportThe visitor came without a pass.
malformed, alg, unknown_key, signature, issuer, audience, expired, not_yet_valid, claims, replayedThe verification step of the same name failed (section 5).
unsupportedThe browser has no Ed25519 in WebCrypto and Aeonis couldn’t be reached to check it.

If the SDK file itself can’t load, your own code decides; the snippet in the guide falls back to a guest. Serving your own copy of the SDK file avoids that case.

9. Automatic avatar looks

adapt(visitor) takes no settings. It records how the page draws (it never changes rendering), picks the largest canvas, waits up to 1.5 s for it to draw, and returns one look. The same passport always gives the same seed, colours and shape in every world.

DetectedInputs it readsReturned look
three.js sceneScenes and renderer (through three.js’s own developer hook), scene size, rigged-mesh height, most common material type, median roughness and metalness, lights, tone mappingkind: '3d', glb, height (scene units) and apply(model): recolours with the scene’s own material type and scales so the feet sit at y = 0
Other WebGL or WebGPU canvasContext type'3d' with glb and a default height of 1.7
2D canvas, pixel artimage-rendering, imageSmoothingEnabled, display scale × device pixel ratio, art-pixel size, tile size (8–32 px), exact palette'pixel': sprite sheet (rows down, left, right, up × 4 frames) at tile size in the world’s palette, url and draw(ctx, x, y, dir, frame)
2D canvas, smoothMain colours (k-means, 8) and whether a few flat inks cover the art'vector': SVG figure (flat and outlined when the art is flat), height, draw(ctx, x, y, h, face, t)
No canvas—'dom': SVG figure and element(), a name tag
Anything else, or any error—'core'

Every look carries identity (name, id, guest, colors, seed, stamps), glyph (an SVG badge) and detected. seed = 32-bit FNV-1a over the UTF-16 code units of lowercase(name) + body + trim (colours as lowercase #rrggbb), modulo 4096. It comes only from shared fields, so it adds no new way to link visitors. Optional overrides: { style, target, tile, scale, palette, height, wait }.

10. Ownership proof

Before review, a creator proves control of the world’s origin with a token from Aeonis (aeonis-verify-…) in one of two ways:

Aeonis fetches over https with the user agent AeonisWorldCheck/1.0, follows up to 3 redirects only within the same origin and waits up to 8 seconds. After the proof passes, a person reviews the world before it goes live. Approved worlds are checked about every 25 minutes and are hidden after 3 failed checks in a row until they answer again.

11. Versioning and deprecation

Changes. 1.0 (18 Oct 2026): first published version.

12. Test vectors

vectors.json holds 14 sample passes and one replay check, with the result each must give. They are signed with a test key published inside the file. It is not in the production key set and must never be trusted outside tests. Check them at the fixed time now in the file, against audience https://world.example.

import { verify } from './aeonis-passport-1.2.0.js';          // or your own verifier
const V = await (await fetch('https://search.aeonis.pro/creators/spec/vectors.json')).json();
for (const v of V.vectors) {
  const r = await verify(v.token, { audience: V.audience, keys: V.keys, now: V.now });
  console.log(v.name, r.ok === v.valid && (v.valid || r.reason === v.reason) ? 'pass' : 'FAIL');
}