diff --git a/src/components/StateMachine.vue b/src/components/StateMachine.vue index 1abb9a6..52281a2 100644 --- a/src/components/StateMachine.vue +++ b/src/components/StateMachine.vue @@ -87,14 +87,24 @@ const KIND_LABELS: Record = { revoke: "revoke()", expire: "expired()", clear: "clear()", + submit: "signIn(c)", + lock: "lockOut()", + signout: "signOut()", + reset: "reset()", + unlock: "unlock()", } const KIND_ORDER: SmKind[] = [ + "submit", "succeed", "fail", "refresh", "loadMore", "navigate", "changeScope", + "lock", + "signout", + "reset", + "unlock", "persist", "store", "storeFail", @@ -103,13 +113,24 @@ const KIND_ORDER: SmKind[] = [ "clear", ] +// Lay the legend out left-to-right, wrapping to a new row when it would overflow +// the viewBox. Machines with few kinds (List, Grid, Calendar) stay on one row +// exactly as before; richer machines (PasswordAuth) wrap instead of clipping. const legendItems = computed(() => { const present = new Set(props.machine.edges.map((e) => e.kind)) + const maxX = 880 let lxp = 70 + let row = 0 return KIND_ORDER.filter((k) => present.has(k)).map((k) => { const t = KIND_LABELS[k] - const item = { t, color: SM_COLORS[k], x1: lxp, x2: lxp + 18, tx: lxp + 24, y: 8, ty: 12 } - lxp += 24 + t.length * 6.6 + 26 + const width = 24 + t.length * 6.6 + 26 + if (lxp > 70 && lxp + width > maxX) { + row += 1 + lxp = 70 + } + const y = 8 + row * 16 + const item = { t, color: SM_COLORS[k], x1: lxp, x2: lxp + 18, tx: lxp + 24, y, ty: y + 4 } + lxp += width return item }) }) diff --git a/src/data/blueprint.ts b/src/data/blueprint.ts index b10ec1b..8142f8e 100644 --- a/src/data/blueprint.ts +++ b/src/data/blueprint.ts @@ -72,6 +72,12 @@ export type SmKind = | "revoke" | "expire" | "clear" + // PasswordAuth session lifecycle + | "submit" + | "lock" + | "signout" + | "reset" + | "unlock" export interface SmEdge { from: string @@ -107,12 +113,26 @@ export const SM_COLORS: Record = { revoke: "#ff5c8a", expire: "#ffb84d", clear: "#b79cff", + // PasswordAuth: blue submit / magenta lockout / purple sign-out / teal recovery + submit: "#8fd0ff", + lock: "#ff5c8a", + signout: "#b79cff", + reset: "#67d0c0", + unlock: "#4fb3a6", init: "#4f7099", } // ── Blueprint ────────────────────────────────────────────────────────────── export type BlueprintRole = "feature" | "capability" +// A reference from one blueprint to another (host, base, sibling, extension). +// `name` is the referenced blueprint's display name; the Viewer resolves it to a +// live link when that blueprint is registered, otherwise renders it as plain text. +export interface RelatedRef { + name: string + relation: string +} + export interface Blueprint { slug: string name: string @@ -125,4 +145,5 @@ export interface Blueprint { coreInvariants: string[] composition?: Composition stateMachine?: StateMachine + related?: RelatedRef[] } diff --git a/src/data/blueprints.ts b/src/data/blueprints.ts index 014add3..11e6d71 100644 --- a/src/data/blueprints.ts +++ b/src/data/blueprints.ts @@ -8,10 +8,12 @@ import { list } from "./listBlueprint" import { grid } from "./gridBlueprint" import { calendar } from "./calendarBlueprint" import { rememberMe } from "./rememberMeBlueprint" +import { passwordAuth } from "./passwordAuthBlueprint" import ListSpecimen from "@/specimens/ListSpecimen.vue" import GridSpecimen from "@/specimens/GridSpecimen.vue" import CalendarSpecimen from "@/specimens/CalendarSpecimen.vue" import RememberMeSpecimen from "@/specimens/RememberMeSpecimen.vue" +import PasswordAuthSpecimen from "@/specimens/PasswordAuthSpecimen.vue" export interface RegistryEntry { blueprint: Blueprint @@ -22,6 +24,7 @@ export const REGISTRY: Record = { list: { blueprint: list, specimen: ListSpecimen }, grid: { blueprint: grid, specimen: GridSpecimen }, calendar: { blueprint: calendar, specimen: CalendarSpecimen }, + "password-auth": { blueprint: passwordAuth, specimen: PasswordAuthSpecimen }, "remember-me": { blueprint: rememberMe, specimen: RememberMeSpecimen }, } @@ -30,3 +33,15 @@ export const BLUEPRINTS: Blueprint[] = Object.values(REGISTRY).map((e) => e.blue export function entryFor(slug: string): RegistryEntry | undefined { return REGISTRY[slug] } + +// Resolve a blueprint's display name (as used in `extendsName` / `related`) to its +// slug, so cross-references between blueprints render as live links. Returns +// undefined when the referenced blueprint is not illustrated yet — the reference +// then renders as plain text. +const slugByName: Record = Object.fromEntries( + Object.entries(REGISTRY).map(([slug, e]) => [e.blueprint.name, slug]), +) + +export function slugForName(name: string | undefined): string | undefined { + return name ? slugByName[name] : undefined +} diff --git a/src/data/passwordAuthBlueprint.ts b/src/data/passwordAuthBlueprint.ts new file mode 100644 index 0000000..9a9e965 --- /dev/null +++ b/src/data/passwordAuthBlueprint.ts @@ -0,0 +1,348 @@ +// The PasswordAuth blueprint, as data. Transcribed by hand from the ontology +// `password-auth` README + password-auth.als. A *feature* that extends Form: the +// user submits a credential, the backend verifies it, and a bounded session is +// established — with uniform failures (no username enumeration), lockout, and +// silent refresh. It is the host that RememberMe / MFA / Captcha compose onto. + +import type { Blueprint, StateMachine } from "./blueprint" + +// The full AuthState lifecycle. Idle → Submitting fans out to Authenticated / +// Error / LockedOut; Authenticated ⇄ Refreshing; and reset() / unlock() / signOut() +// all return to Idle. Node coordinates are hand-placed for a clean layout. +const PASSWORD_AUTH_MACHINE: StateMachine = { + initId: "idle", + caption: "Session lifecycle", + field: "state : AuthState", + nodes: [ + { id: "idle", x: 30, y: 140, w: 90, h: 44, label: "Idle" }, + { id: "submitting", x: 190, y: 140, w: 120, h: 44, label: "Submitting" }, + { id: "authenticated", x: 400, y: 140, w: 140, h: 44, label: "Authenticated" }, + { id: "refreshing", x: 630, y: 44, w: 130, h: 44, label: "Refreshing" }, + { id: "lockedout", x: 175, y: 252, w: 120, h: 44, label: "LockedOut" }, + { id: "error", x: 395, y: 252, w: 100, h: 44, label: "Error" }, + ], + edges: [ + { from: "idle", to: "submitting", label: "signIn(c)", kind: "submit", off: -34 }, + { from: "submitting", to: "authenticated", label: "succeed(id)", kind: "succeed", off: -34 }, + { from: "submitting", to: "lockedout", label: "lockOut()", kind: "lock", off: -20 }, + { from: "submitting", to: "error", label: "fail()", kind: "fail", off: -24 }, + { from: "authenticated", to: "refreshing", label: "refresh()", kind: "refresh", off: -26 }, + { from: "refreshing", to: "authenticated", label: "refreshed(id)", kind: "succeed", off: -26 }, + { from: "refreshing", to: "error", label: "refreshFailed()", kind: "fail", off: -30 }, + { from: "authenticated", to: "idle", label: "signOut()", kind: "signout", off: 120 }, + { from: "error", to: "idle", label: "reset()", kind: "reset", off: -40 }, + { from: "lockedout", to: "idle", label: "unlock()", kind: "unlock", off: 30 }, + ], +} + +export const passwordAuth: Blueprint = { + slug: "password-auth", + name: "PasswordAuth", + tagline: + "Submit a credential, verify it against the backend, and hold a bounded session — with uniform failures, lockout, and silent refresh.", + role: "feature", + extendsName: "Form", + + sig: { + header: "PasswordAuth ◁ Form", + fields: [ + { name: "state", type: "AuthState", note: "lifecycle position" }, + { name: "identity", type: "lone Identity", note: "present iff Authenticated or Refreshing" }, + { + name: "pendingCredential", + type: "lone Credential", + note: "present only during Submitting", + }, + ], + types: [ + "AuthState = Idle | Submitting | Authenticated | Refreshing | LockedOut | Error", + "Identity { subject : Subject, realm : Realm } -- subject unique per realm", + "Credential { username : Username, secret : Secret } -- 8 ≤ len(secret) ≤ 128", + "identity ≠ ⊥ ⟺ state ∈ {Authenticated, Refreshing}", + "pendingCredential ≠ ⊥ ⟺ state = Submitting", + ], + }, + + functions: [ + { + id: "capture", + name: "Capture", + fig: "01", + caps: ["PasswordAuth", "Form"], + verb: "Collect username and secret from the form; mask the secret and mount both fields together so the OS credential manager can pair them.", + state: [ + ["username", "Username"], + ["secret", "Secret"], + ], + behaviors: [], + invariants: [ + "len(secret) ∈ [8, 128] — enforced before signIn(c) fires; the upper bound is surfaced to the user, never silently accepted (bcrypt truncates at 72 bytes)", + ], + failures: [ + "Form auto-submit — browser autofill triggers signIn without user intent; the unintended submission locks the user out faster on stale credentials", + ], + perf: [ + "Form responsiveness — input fields remain accessible during Submitting; the submit button is disabled to prevent double-fire", + ], + notes: [ + "Username and password are mounted together on the submitted form so the credential manager can pair them on save.", + ], + }, + { + id: "submit", + name: "Submit", + fig: "02", + caps: ["PasswordAuth"], + verb: "Transmit the credential over an authenticated channel (TLS) on a user gesture; never log or persist the secret.", + state: [ + ["state", "AuthState"], + ["pendingCredential", "lone Credential"], + ], + behaviors: [ + { + sig: "signIn(c)", + pre: "state = Idle", + eff: "state ← Submitting, pendingCredential ← c", + }, + ], + invariants: [ + "pendingCredential ≠ ⊥ ⟺ state = Submitting — the credential is held only across the verification call", + "the plaintext secret is never persisted, logged, or held past Submitting", + ], + failures: [ + "Plaintext leak — secret logged, persisted, or kept in app memory past Submitting; exposed to crash dumps, analytics, or an attacker with disk read", + "Form auto-submit — autofill fires signIn without a user gesture", + ], + perf: [ + "Verification — signIn to Authenticated or Error completes within 2 s on a stable network", + ], + notes: [ + "The signIn(c) submission must be triggered by a user gesture, never auto-fired on autofill completion.", + ], + }, + { + id: "verify", + name: "Verify", + fig: "03", + caps: ["PasswordAuth"], + verb: "Backend checks the credential against the credential store; returns an Identity or a uniform failure that never distinguishes unknown-user from wrong-password.", + state: [ + ["state", "AuthState"], + ["identity", "lone Identity"], + ], + behaviors: [ + { + sig: "succeed(id)", + pre: "state = Submitting, verification passed", + eff: "state ← Authenticated, identity ← id, pendingCredential ← ⊥", + }, + { + sig: "fail()", + pre: "state = Submitting, verification failed", + eff: "state ← Error, pendingCredential ← ⊥", + }, + ], + invariants: [ + "succeed(id) requires backend verification — the relying party never trusts client-side validation alone", + "fail() does not distinguish 'wrong password' from 'unknown username' — messages stay uniform", + ], + failures: [ + "Username enumeration — fail() reason distinguishes 'no such user' from 'wrong password'; an attacker maps valid usernames before a targeted attack", + "Timing attack — verification time differs for valid vs invalid usernames; the attacker infers validity from response latency", + "Credential reuse — the backend accepts a known-breached password, so a password compromised elsewhere grants access here", + ], + perf: [ + "Constant-time response — failure responses take the same time regardless of username validity", + "Failure visibility — Error surfaces with a uniform message, never revealing whether the username exists", + ], + notes: [], + }, + { + id: "limit", + name: "Limit", + fig: "04", + caps: ["PasswordAuth"], + verb: "Track failed attempts per user / per IP; transition to LockedOut past the threshold.", + state: [["state", "AuthState"]], + behaviors: [ + { + sig: "lockOut()", + pre: "state = Submitting, attempt threshold exceeded", + eff: "state ← LockedOut, pendingCredential ← ⊥", + }, + ], + invariants: [ + "state = LockedOut is reached only from Submitting via lockOut() — never user-triggerable directly", + ], + failures: [ + "Brute force — no attempt counting / no lockOut threshold; the attacker tries unbounded password guesses", + "Lockout bypass — unlock() callable without elapsed cooldown; the rate limit is defeated and brute force resumes", + ], + perf: [ + "Lockout feedback — LockedOut surfaces to the user within 1 s of trigger, with the cooldown duration shown", + ], + notes: [], + }, + { + id: "establish", + name: "Establish", + fig: "05", + caps: ["PasswordAuth", "Identity"], + verb: "Persist the session token; expose subject and realm to the consumer.", + state: [["identity", "lone Identity"]], + behaviors: [], + invariants: [ + "identity ≠ ⊥ ⟺ state ∈ {Authenticated, Refreshing} — no ghost identity outside an active session", + "the consumer sees subject and realm; the plaintext secret never crosses the client-persistence boundary", + ], + failures: [ + "Replay (token theft) — the session token leaks (XSS, network capture); the attacker impersonates the user until the token expires", + ], + perf: [], + notes: [ + "Client persistence may store Identity.subject / Identity.realm and the composed session credential — never the plaintext secret in any form.", + ], + }, + { + id: "refresh", + name: "Refresh", + fig: "06", + caps: ["PasswordAuth"], + verb: "Renew the session token silently before expiry; preserve subject and realm.", + state: [ + ["state", "AuthState"], + ["identity", "lone Identity"], + ], + behaviors: [ + { + sig: "refresh()", + pre: "state = Authenticated, session token refreshable", + eff: "state ← Refreshing", + }, + { + sig: "refreshed(id)", + pre: "state = Refreshing, id.subject = identity.subject, id.realm = identity.realm", + eff: "state ← Authenticated, identity ← id", + }, + { + sig: "refreshFailed()", + pre: "state = Refreshing", + eff: "state ← Error, identity ← ⊥", + }, + ], + invariants: [ + "refreshed(id) preserves both subject and realm — refresh never silently swaps user or tenant", + ], + failures: [ + "Subject swap on refresh — refreshed(id) accepts an id with a different subject or realm; the attacker's refresh response steals the session", + "Stale session — the session is used past expiry without refresh; API calls 401 and the user is surprise-logged-out", + ], + perf: [ + "Silent refresh — completes within 200 ms and before any in-flight API call observes a 401", + ], + notes: [], + }, + { + id: "recover", + name: "Recover", + fig: "07", + caps: ["PasswordAuth"], + verb: "Surface Error distinctly from LockedOut; expose reset() and the cooldown-driven unlock().", + state: [["state", "AuthState"]], + behaviors: [ + { sig: "reset()", pre: "state = Error", eff: "state ← Idle" }, + { + sig: "unlock()", + pre: "state = LockedOut, cooldown elapsed", + eff: "state ← Idle", + }, + ], + invariants: [ + "unlock() is reachable only from LockedOut and requires elapsed cooldown — not user-triggerable", + ], + failures: [ + "Forgotten password loop — no recovery path from LockedOut or an unknown username; the user is permanently locked out with no self-service unblock", + ], + perf: [], + notes: [ + "Recovery from a forgotten password is a sibling blueprint (PasswordReset), not part of this contract.", + ], + }, + { + id: "terminate", + name: "Terminate", + fig: "08", + caps: ["PasswordAuth"], + verb: "Clear the local session and invalidate the session token at the backend.", + state: [ + ["state", "AuthState"], + ["identity", "lone Identity"], + ], + behaviors: [ + { + sig: "signOut()", + pre: "state = Authenticated", + eff: "state ← Idle, identity ← ⊥", + }, + ], + invariants: ["signOut() clears identity — no active session remains locally"], + failures: [ + "Stale session — a logged-out session token still valid at the backend can be replayed", + ], + perf: [], + notes: [ + "A logout is not a credential operation — the secret never crosses the logout boundary; only the session credential is sent so the server can revoke.", + ], + }, + ], + + coreInvariants: [ + "identity ≠ ⊥ ⟺ state ∈ {Authenticated, Refreshing} — no ghost identity outside an active session", + "pendingCredential ≠ ⊥ ⟺ state = Submitting — credentials are held only across the verification call", + "the plaintext secret is never persisted, logged, or held past Submitting", + "refreshed(id) preserves both subject and realm — refresh never swaps user or tenant", + "succeed(id) requires backend verification — the relying party never trusts client-side validation alone", + "unlock() is reachable only from LockedOut and requires elapsed cooldown — not user-triggerable", + "len(secret) ∈ [8, 128] is enforced before signIn(c) fires", + ], + + composition: { + caps: [ + { id: "core", label: "PasswordAuth : Form", sub: "state, identity", core: true }, + { id: "form", label: "Form", sub: "fields, filled, invalid" }, + { id: "cred", label: "Credential", sub: "username, secret" }, + { id: "identity", label: "Identity", sub: "subject, realm" }, + ], + funcs: ["Capture", "Submit", "Verify", "Limit", "Establish", "Refresh", "Recover", "Terminate"], + links: [ + ["form", "Capture"], + ["cred", "Capture"], + ["cred", "Submit"], + ["core", "Submit"], + ["core", "Verify"], + ["identity", "Verify"], + ["core", "Limit"], + ["core", "Establish"], + ["identity", "Establish"], + ["core", "Refresh"], + ["identity", "Refresh"], + ["core", "Recover"], + ["form", "Recover"], + ["core", "Terminate"], + ["identity", "Terminate"], + ], + }, + + stateMachine: PASSWORD_AUTH_MACHINE, + + related: [ + { name: "Form", relation: "extends · base" }, + { name: "RememberMe", relation: "composable extension" }, + { name: "MFA", relation: "composable extension" }, + { name: "Captcha", relation: "composable extension" }, + { name: "StatelessSession", relation: "composable extension" }, + { name: "PasswordReset", relation: "sibling · recovery" }, + { name: "SSO", relation: "sibling · delegated" }, + { name: "Passkey", relation: "sibling · passwordless" }, + ], +} diff --git a/src/specimens/PasswordAuthSpecimen.vue b/src/specimens/PasswordAuthSpecimen.vue new file mode 100644 index 0000000..f3c10ed --- /dev/null +++ b/src/specimens/PasswordAuthSpecimen.vue @@ -0,0 +1,430 @@ + + + + +