Gratos_Credentials

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

Single-user credential lifecycle with ranked authentication methods (webauthn > devicekey > softkey): sessions carry the rank of the credential that minted them; deletion requires the session to outrank-or-equal the target and never removes the last credential. Checks never-orphaned, no privilege-escalation deletes, and that a phished low-rank key can never evict the strongest credential.

Raw .tla Raw .cfg

Gratos_Credentials.tla

MODULE Gratos_Credentials

Credential lifecycle of the account-key feature (gratos-multi src/keys.ts + src/sessions.ts), for a single user — credentials are the state, so one user suffices.

Credential kinds carry ranks (KIND_RANK in keys.ts / AMR_RANK in sessions.ts): webauthn=3, devicekey=2, softkey=1. Every session records the amr of the credential that minted it, so a session is fully characterized by its rank; live sessions are modeled as a set of ranks.

Actions: SignupWith(k) — register/verify with no session: first credential of any kind (passkey signup or account-key signup) plus a session of its rank. Attach(s, k) — register-while-authenticated: a live session adds a credential (device-key provisioning, recovery-key enrollment, extra passkeys); verify also mints a session of the new credential's rank. Total credentials are capped (MAX_CREDENTIALS_PER_USER, shrunk here to bound state). Login(k) — login/verify against an existing credential mints a session of rank(k). Delete(s, k) — DELETE /v1/credentials/:id, guarded by BOTH rules from the handler: the session outranks-or-equals the target credential, AND more than one credential exists (never orphan an account). DropSession(s) — sessions expire / are logged out freely.

Checked properties: TypeOK NeverOrphaned — once any credential has existed, at least one always exists: the API can never delete the last credential. NoPrivilegeEscalationDelete — every deletion ever performed was by a session whose rank >= the deleted credential's rank. PasskeySurvivesPhishedKey — the story that motivated the rank rule: a webauthn credential is only ever deleted by a webauthn-rank session, so a phished account key (rank-1 session) can never evict the user's passkey.

EXTENDS Naturals
CONSTANT MaxCreds cap on total credentials (MAX_CREDENTIALS_PER_USER, shrunk)
Kinds ≜ {"webauthn", "devicekey", "softkey"}
Ranks ≜ 1..3

KIND_RANK (keys.ts) = AMR_RANK (sessions.ts) via KIND_TO_AMR.

Rank(k) ≜ CASE k = "webauthn" → 3
k = "devicekey" → 2
k = "softkey" → 1
VARIABLES
creds, per-kind credential counts, [Kinds -> 0..MaxCreds]
sessions, ranks of live sessions (a session is just its amr rank)
everRegistered, history: some credential has existed at some point
deletions history: <<sessionRank, deletedCredRank>> pairs
vars ≜ ⟨creds, sessions, everRegistered, deletions
Total(c) ≜ c["webauthn"] + c["devicekey"] + c["softkey"]
Init
creds = [kKinds ↦ 0]
sessions = {}
everRegistered = FALSE
deletions = {}

POST /v1/(register|key/register)/verify with no session: fresh user, first credential of any kind, and a session of that credential's rank.

SignupWith(k) ≜
Total(creds) = 0
creds = [creds EXCEPT ![k] = @ + 1]
sessions = sessions ∪ {Rank(k)}
everRegistered = TRUE
UNCHANGED deletions

Register-while-authenticated: any live session attaches a credential of any kind to its user (no rank guard on attach in keys.ts), and verify mints a session of the NEW credential's rank. Capped at MaxCreds.

Attach(s, k) ≜
ssessions
Total(creds) < MaxCreds
creds = [creds EXCEPT ![k] = @ + 1]
sessions = sessions ∪ {Rank(k)}
UNCHANGEDeverRegistered, deletions

POST /v1/(login|key/login)/verify against an existing credential.

Login(k) ≜
creds[k] ≥ 1
sessions = sessions ∪ {Rank(k)}
UNCHANGEDcreds, everRegistered, deletions

DELETE /v1/credentials/:id — both guards from the handler: never remove the last credential (409), and a session may not remove a credential stronger than how it was authenticated (403).

Delete(s, k) ≜
ssessions
creds[k] ≥ 1
Total(creds) > 1 last-credential guard
sRank(k) rank guard: AMR_RANK[session.amr] >= KIND_RANK[kind]
creds = [creds EXCEPT ![k] = @ - 1]
deletions = deletions ∪ {⟨s, Rank(k)⟩}
UNCHANGEDsessions, everRegistered

Sessions expire (KV TTL) or are dropped by /v1/logout at any time.

DropSession(s) ≜
ssessions
sessions = sessions \ {s}
UNCHANGEDcreds, everRegistered, deletions
Next
∨ ∃ kKinds : SignupWith(k) ∨ Login(k)
∨ ∃ sRanks, kKinds : Attach(s, k) ∨ Delete(s, k)
∨ ∃ sRanks : DropSession(s)
SpecInit ∧ □[Next]vars
TypeOK
creds ∈ [Kinds → 0..MaxCreds]
Total(creds) ≤ MaxCreds
sessionsRanks
everRegisteredBOOLEAN
deletionsRanks × Ranks

Once registered, an account always has at least one credential.

NeverOrphanedeverRegisteredTotal(creds) ≥ 1

Every deletion ever performed satisfied the rank rule.

NoPrivilegeEscalationDelete ≜ ∀ ddeletions : d[1] ≥ d[2]

The phished-key story: a stolen account key yields only a rank-1 session, which by the rank rule can never delete the webauthn credential — only a webauthn-rank session ever deletes a passkey. (A corollary of NoPrivilegeEscalationDelete, stated explicitly as the security property that motivated the guard.)

PasskeySurvivesPhishedKey ≜ ∀ ddeletions : d[2] = 3 ⇒ d[1] = 3

Gratos_Credentials.cfg

SPECIFICATION Spec

Quiescent states are legal: an account sitting idle with credentials and no live sessions (or the pristine pre-signup state) is a valid resting point; a stuttering-only run is fine.

CHECK_DEADLOCK FALSE
CONSTANTS
MaxCreds = 3
INVARIANTS
TypeOK
NeverOrphaned
NoPrivilegeEscalationDelete
PasskeySurvivesPhishedKey

Generations

genchangesdistinct statesdepthpublishedraw
1 (latest) First publication to the hub; spec previously validated with the Java TLC toolchain. 6717 19 2026-07-20 16:39:22 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…