OpenMonkeyAuth

by 623f9b12 · generation 1 · every generation passed the checker when published.

Browser-driven auth ceremony against an external auth service: the client fetches a challenge bound to a principal, a verify step consumes the challenge (single-use, bounded TTL) and creates a session, logout destroys it. Checks that every session is backed by a same-owner consumed challenge, challenges back at most one session, verification happens within TTL, used challenges are never replayed, and challenge bindings are immutable.

Raw .tla Raw .cfg

OpenMonkeyAuth.tla

MODULE OpenMonkeyAuth

First-party auth ceremony for OpenMonkey.

Login/register UI is served first-party (openmonkey.proc.io) and calls the auth service's raw API from the browser. All ceremony kinds (WebAuthn passkey create/get, HKDF account-key signature, silent device-key login) share one shape, modeled as a single abstract ceremony:

Issue - GET /v1/{register,login}/options hands the browser a fresh challenge bound to the requesting principal. Verify - POST /v1/{register,login}/verify (or /v1/key/...): the server accepts a challenge only if it is fresh (never used) and no older than TTL, consumes it, and creates a session. The session cookie replaces any prior one for that user. Logout - POST /v1/logout destroys the session.

Sessions and the trust model are unchanged from the hosted-pages design: the auth service issues and validates sessions; the app API forwards the first-party session cookie to /v1/whoami, an access gate that is exactly "session active", so it needs no extra state here. CORS/cross-subdomain transport is not state and is out of scope.

Safety-only, finite model. The real 5-minute challenge TTL is abstracted to TTL clock ticks against a bounded clock. Challenge identities are ordered slots allocated lowest-free-slot first (symmetry breaking). NoChal (= 0) marks "no session".

EXTENDS Naturals, FiniteSets
CONSTANTS
Users, set of principals (model values)
NumChallenges, number of challenge identity slots
MaxClock, clock bound
TTL, challenge lifetime in ticks (abstracts 5 minutes)
NULL model value: "no owner"
ASSUME NumChallengesNatNumChallenges ≥ 1
ASSUME MaxClockNatTTLNatTTL < MaxClock
Chals ≜ 1..NumChallenges
NoChal ≜ 0
VARIABLES
clock, 0..MaxClock
chalOwner, [Chals -> Users \cup {NULL}] principal it was issued to
chalIssued, [Chals -> 0..MaxClock] issue time
chalVerifiedAt, [Chals -> 0..MaxClock] consume time (used only)
chalState, [Chals -> {"none","fresh","used"}]
sessionChal [Users -> Chals \cup {NoChal}] challenge backing the

user's active session; NoChal = logged out

vars ≜ ⟨clock, chalOwner, chalIssued, chalVerifiedAt, chalState, sessionChal
Init
clock = 0
chalOwner = [cChalsNULL]
chalIssued = [cChals ↦ 0]
chalVerifiedAt = [cChals ↦ 0]
chalState = [cChals"none"]
sessionChal = [uUsersNoChal]
Tick
clock < MaxClock
clock = clock + 1
UNCHANGEDchalOwner, chalIssued, chalVerifiedAt, chalState, sessionChal

Browser fetches ceremony options; server mints a challenge bound to u. Lowest-free-slot allocation breaks challenge-labeling symmetry.

Issue(u, c) ≜
chalState[c] = "none"
∧ ∀ x ∈ 1..(c - 1) : chalState[x] ≠ "none"
chalOwner = [chalOwner EXCEPT ![c] = u]
chalIssued = [chalIssued EXCEPT ![c] = clock]
chalState = [chalState EXCEPT ![c] = "fresh"]
UNCHANGEDclock, chalVerifiedAt, sessionChal

Server-side verify: accepts only a fresh, same-owner, unexpired challenge; consumes it (single use) and creates the session. A new login replaces any existing session cookie for that user.

Verify(u, c) ≜
chalState[c] = "fresh"
chalOwner[c] = u
clock - chalIssued[c] ≤ TTL
chalState = [chalState EXCEPT ![c] = "used"]
chalVerifiedAt = [chalVerifiedAt EXCEPT ![c] = clock]
sessionChal = [sessionChal EXCEPT ![u] = c]
UNCHANGEDclock, chalOwner, chalIssued

POST /v1/logout destroys the session server-side.

Logout(u) ≜
sessionChal[u] ≠ NoChal
sessionChal = [sessionChal EXCEPT ![u] = NoChal]
UNCHANGEDclock, chalOwner, chalIssued, chalVerifiedAt, chalState
Next
Tick
∨ ∃ uUsers :
Logout(u)
∨ ∃ cChals : Issue(u, c) ∨ Verify(u, c)
SpecInit ∧ □[Next]vars

Invariants

TypeOK
clock ∈ 0..MaxClock
chalOwner ∈ [ChalsUsers ∪ {NULL}]
chalIssued ∈ [Chals → 0..MaxClock]
chalVerifiedAt ∈ [Chals → 0..MaxClock]
chalState ∈ [Chals → {"none", "fresh", "used"}]
sessionChal ∈ [UsersChals ∪ {NoChal}]

1. Every active session is backed by a consumed challenge that was issued to exactly that principal. No session without a completed ceremony, and never on someone else's challenge.

SessionBackedByOwnChallenge
uUsers : sessionChal[u] ≠ NoChal
chalState[sessionChal[u]] = "used"
chalOwner[sessionChal[u]] = u

2. A challenge backs at most one session (single use across users; within a user a re-login replaces the session).

ChallengeSingleUse
u1Users, u2Users :
(u1u2sessionChal[u1] ≠ NoChal) ⇒ sessionChal[u1] ≠ sessionChal[u2]

3. Every consumed challenge was verified within TTL of issuance: expired challenges never create sessions.

VerifiedWithinTTL
cChals : chalState[c] = "used"
chalVerifiedAt[c] ≥ chalIssued[c]
chalVerifiedAt[c] - chalIssued[c] ≤ TTL

Action properties

A consumed challenge is consumed forever: no replay of used challenges.

NoChallengeReplay
□[∀ cChals : chalState[c] = "used"chalState[c] = "used"]vars

Challenge bindings are immutable once issued.

ChallengeBindingImmutable
□[∀ cChals : chalState[c] ≠ "none"
chalOwner[c] = chalOwner[c]
chalIssued[c] = chalIssued[c]]vars

OpenMonkeyAuth.cfg

SPECIFICATION Spec
CONSTANTS
Users = {u1, u2}
NumChallenges = 2
MaxClock = 3
TTL = 1
NULL = NULL
INVARIANTS
TypeOK
SessionBackedByOwnChallenge
ChallengeSingleUse
VerifiedWithinTTL
PROPERTIES
NoChallengeReplay
ChallengeBindingImmutable
CHECK_DEADLOCK FALSE

Generations

genchangesdistinct statesdepthpublishedraw
1 (latest) New companion module: the login UI moved from hosted auth pages to first-party pages calling the auth API directly, so the challenge/verify ceremony the client now drives is modeled explicitly (issuance, single-use TTL-bounded verify creating a session, logout). The registry module is unchanged; sessions and trust model are unchanged. 1412 10 2026-07-22 14:06:43 UTC .tla .cfg

Defend this spec

Ask an AI role-playing the spec's author to defend the design, dissertation-style. This site holds no AI keys: you grant a small revocable budget from your own tokenpony.dev balance (or any TPX provider you choose) and your browser talks to the model directly.

Loading…