diff --git a/apps/machine/src/App.vue b/apps/machine/src/App.vue index a4e22a0..07cb912 100644 --- a/apps/machine/src/App.vue +++ b/apps/machine/src/App.vue @@ -3,6 +3,7 @@ import { onMounted, onUnmounted, ref, computed, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useAtmStore } from '@/stores/atm' import { useTheme } from '@/composables/useTheme' +import { useSessionSecurity } from '@/composables/useSessionSecurity' import { setBranding } from '@/composables/useBranding' import { classifyInitError } from '@/services/init-error' import { Badge } from '@/components/ui/badge' @@ -16,6 +17,11 @@ const route = useRoute() const router = useRouter() const { current: currentTheme, themes, colorMode } = useTheme() +// ADR-003 session security: idle-inactivity re-lock (resets on touch) + an +// absolute hard session cap. Enforced here at the always-mounted shell so it +// spans the whole unlocked session, not just the idle view. +useSessionSecurity() + // When the machine re-locks after a transaction (access gate enabled), the // router is still on /cash-in or /cash-out under the LockedView overlay. Reset // it to home so that when the gate reopens to `idle`, IdleView shows — not the diff --git a/apps/machine/src/composables/useSessionSecurity.ts b/apps/machine/src/composables/useSessionSecurity.ts new file mode 100644 index 0000000..6317474 --- /dev/null +++ b/apps/machine/src/composables/useSessionSecurity.ts @@ -0,0 +1,95 @@ +import { watch } from 'vue' +import { useEventListener, useIntervalFn } from '@vueuse/core' +import { useAtmStore } from '@/stores/atm' + +/** + * Session security timeouts for the ADR-003 access gate. + * + * Enforced at the DOM layer on purpose: the state machine can't observe raw + * pointer events, so an XState `after` delay can only count from state entry — + * it never resets on a screen touch and therefore can't measure *inactivity*. + * Two independent, fail-closed limits, both of which re-lock via the machine's + * root-level END_SESSION transition: + * + * - SOFT idle (SOFT_IDLE_MS): re-lock after this long with no trusted user + * input while on the idle menu. Resets on every genuine pointer/touch/key + * event. Scoped to `idle` so it never interrupts an in-flight cash-in/out + * (those carry their own, longer machine timeouts). + * - HARD cap (HARD_CAP_MS): re-lock this long after the session began, + * regardless of activity. Anchored to unlock time and never reset — an + * absolute ceiling a forgotten or relayed card can't hold open. + * + * Security properties: + * - Only `event.isTrusted` input resets the soft timer, so synthetic/scripted + * events in the renderer can't keep a session alive. + * - Limits are wall-clock deadline comparisons, not chained setTimeouts: a + * suspended/resumed renderer re-locks on the very next tick instead of + * silently extending the session past its deadline. + * - Both limits only ever *lock*. The machine's accessGateActive guard makes + * END_SESSION a no-op when the gate is off, so this is inert on a + * gate-disabled machine. + * - One-shot per session: after firing, it disarms until the next unlock, so + * a lock that (under dev bypass) doesn't take can't spin. + * + * Call once from the always-mounted App shell. + */ +const SOFT_IDLE_MS = 60_000 // 60s of no interaction on the idle menu +const HARD_CAP_MS = 600_000 // 10min absolute session ceiling + +export function useSessionSecurity() { + const atm = useAtmStore() + + // Wall-clock anchors. `null` sessionStartedAt == disarmed (no live session). + let sessionStartedAt: number | null = null + let lastActivityAt = 0 + + function arm() { + const now = Date.now() + sessionStartedAt = now + lastActivityAt = now + } + function disarm() { + sessionStartedAt = null + } + + // Arm on each locked → unlocked edge; disarm on lock. Anchored to the + // isLocked transition so the hard cap starts at unlock and does NOT restart + // when moving idle → cashIn → idle within a single session. + watch( + () => atm.isLocked, + (locked, wasLocked) => { + if (wasLocked && !locked && atm.accessControl.enabled) arm() + else if (locked) disarm() + } + ) + + // Only genuine hardware input counts as activity. Passive + capture so it + // observes every touch without interfering with handling. useEventListener + // auto-detaches on unmount. + const onActivity = (e: Event) => { + if (e.isTrusted) lastActivityAt = Date.now() + } + for (const type of ['pointerdown', 'touchstart', 'keydown', 'wheel'] as const) { + useEventListener(document, type, onActivity, { passive: true, capture: true }) + } + + // Single 1s evaluator — cheap, and coarse enough that timer drift/suspend + // can only ever make it fire late-then-immediately, never early. + useIntervalFn(() => { + if (sessionStartedAt === null || atm.isLocked || !atm.accessControl.enabled) return + const now = Date.now() + + // Hard cap first — absolute, activity-independent. + if (now - sessionStartedAt >= HARD_CAP_MS) { + disarm() // one-shot; re-arms on next unlock + atm.endSession('session-cap') + return + } + + // Soft inactivity — idle menu only, resets on trusted input. + if (atm.currentState === 'idle' && now - lastActivityAt >= SOFT_IDLE_MS) { + disarm() + atm.endSession('inactivity') + } + }, 1000) +} diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index f1764e0..6416828 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -1640,11 +1640,15 @@ export const useAtmStore = defineStore('atm', () => { } /** - * End the current tap-in session on demand and re-lock immediately (drops the - * loaded Bolt Card via `locked`'s entry), instead of waiting out the idle - * timeout. No-op unless the gate is active and we're on the idle menu. + * End the current tap-in session and re-lock immediately (drops the loaded + * Bolt Card via `locked`'s entry). Routed through the machine's root-level + * END_SESSION so it locks from any unlocked state. Callers: the "End session" + * button, the idle-inactivity timer, and the absolute session cap. `reason` + * is recorded for the access audit trail — never a raw credential. No-op + * unless the gate is active (guarded in the machine). */ - function endSession() { + function endSession(reason: 'user' | 'inactivity' | 'session-cap' = 'user') { + console.info(`[ATM] Ending session — reason=${reason}`) send({ type: 'END_SESSION' }) } diff --git a/packages/state-machine/src/__tests__/access-control.test.ts b/packages/state-machine/src/__tests__/access-control.test.ts index e023b4c..e3a6477 100644 --- a/packages/state-machine/src/__tests__/access-control.test.ts +++ b/packages/state-machine/src/__tests__/access-control.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from 'vitest' +import { describe, it, expect } from 'vitest' import { createActor } from 'xstate' import { createATMMachine } from '../machine.js' @@ -61,37 +61,13 @@ describe('ATM access control (ADR-003)', () => { }) }) - describe('idle inactivity re-lock', () => { - it('re-locks the idle menu after IDLE_LOCK_TIMEOUT when the gate is active', () => { - vi.useFakeTimers() - try { - const actor = createActor(createATMMachine({}, { accessControlEnabled: true })) - actor.start() - actor.send({ type: 'ACCESS_GRANTED', role: 'user', credentialIdHash: 'abc' }) - expect(actor.getSnapshot().value).toBe('idle') - vi.advanceTimersByTime(60000) - expect(actor.getSnapshot().value).toBe('locked') - } finally { - vi.useRealTimers() - } - }) - - it('does not re-lock idle when the gate is disabled', () => { - vi.useFakeTimers() - try { - const actor = createActor(createATMMachine()) // gate off → rests at idle - actor.start() - expect(actor.getSnapshot().value).toBe('idle') - vi.advanceTimersByTime(120000) - expect(actor.getSnapshot().value).toBe('idle') - } finally { - vi.useRealTimers() - } - }) - }) - - describe('user-initiated end of session', () => { - it('END_SESSION re-locks immediately when the gate is active', () => { + // NOTE: idle inactivity re-lock is no longer an XState `after` delay — an + // entry-anchored timer can't measure inactivity (it never resets on screen + // touches). It's enforced at the DOM layer (useSessionSecurity), which sends + // END_SESSION on true idleness / at the hard cap. The machine's contract is + // just: END_SESSION re-locks from any unlocked state when the gate is active. + describe('session end (button / inactivity / hard cap all route here)', () => { + it('END_SESSION re-locks immediately from idle when the gate is active', () => { const actor = createActor(createATMMachine({}, { accessControlEnabled: true })) actor.start() actor.send({ type: 'ACCESS_GRANTED', role: 'user', credentialIdHash: 'abc' }) @@ -100,6 +76,22 @@ describe('ATM access control (ADR-003)', () => { expect(actor.getSnapshot().value).toBe('locked') }) + it('END_SESSION re-locks from an in-flight cash-out (hard-cap path)', () => { + // The absolute session cap must be able to lock mid-transaction, so the + // transition lives at the machine root, not only on `idle`. + const rateServices = { + getExchangeRate: () => Promise.resolve(2500), + getAvailableBalance: () => Promise.resolve(1_000_000), + } + const actor = createActor(createATMMachine(rateServices, { accessControlEnabled: true })) + actor.start() + actor.send({ type: 'ACCESS_GRANTED', role: 'user', credentialIdHash: 'abc' }) + actor.send({ type: 'SELECT_CASH_OUT' }) + expect(actor.getSnapshot().value).not.toBe('locked') // now inside cashOut + actor.send({ type: 'END_SESSION' }) + expect(actor.getSnapshot().value).toBe('locked') + }) + it('END_SESSION is a no-op when the gate is disabled (stays at idle)', () => { const actor = createActor(createATMMachine()) // gate off → rests at idle actor.start() diff --git a/packages/state-machine/src/machine.ts b/packages/state-machine/src/machine.ts index 94353ee..2a11569 100644 --- a/packages/state-machine/src/machine.ts +++ b/packages/state-machine/src/machine.ts @@ -478,8 +478,9 @@ export function createATMMachine( COMPLETE_DELAY: 60000, DISPENSE_TIMEOUT: 120000, // 2 min max for hardware to respond DISPENSE_ERROR_TIMEOUT: 30000, // 30s like brain.js _timedState - // Auto re-lock the idle menu after this long unattended (gate active only). - IDLE_LOCK_TIMEOUT: 60000, // 60s + // NOTE: idle inactivity re-lock + hard session cap are enforced at the + // DOM layer (useSessionSecurity), not as XState `after` delays — see the + // idle state comment. No IDLE_LOCK_TIMEOUT delay here by design. }, }).createMachine({ id: 'atm', @@ -512,6 +513,14 @@ export function createATMMachine( cashOutFeeFraction: ({ event }) => event.cashOutFeeFraction, }), }, + // Root-level session kill switch (ADR-003). Any unlocked state re-locks + // on END_SESSION — the "End session" button, the idle-inactivity timer, + // and the absolute session cap all route here (see useSessionSecurity). + // Placed at the root so the hard cap can lock mid cash-in/out, not just + // from idle. Guarded to the active gate so a gate-disabled machine (which + // rests at idle) can't be knocked out of it. `locked`'s entry clears the + // access session + loaded card. Fail-closed: locking is always allowed. + END_SESSION: { guard: 'accessGateActive', target: '#atm.locked' }, }, states: { // === ACCESS GATE (ADR-003) === @@ -532,14 +541,13 @@ export function createATMMachine( idle: { entry: 'resetContext', - // When the gate is engaged, an unlocked-but-idle terminal auto re-locks - // after IDLE_LOCK_TIMEOUT so a session left unattended (card loaded, no - // transaction) returns to the lock screen. Guarded so a gate-disabled - // machine (which rests at idle) never re-locks. Selecting cash-in/out - // leaves idle and cancels this timer. - after: { - IDLE_LOCK_TIMEOUT: { guard: 'accessGateActive', target: 'locked' }, - }, + // Idle inactivity re-lock is NOT modeled here as an XState `after`: + // that timer is anchored to state ENTRY and never resets on screen + // touches (the machine can't see raw pointer events), so it would fire + // a fixed countdown regardless of activity. Inactivity is measured at + // the DOM layer (useSessionSecurity) and drives END_SESSION on true + // idleness. The hard session cap is handled the same way. Both re-lock + // via the root-level END_SESSION transition above. on: { SELECT_CASH_IN: { target: 'cashIn', @@ -549,11 +557,6 @@ export function createATMMachine( target: 'cashOut', actions: ['setStartTime', 'setCashOutFee'], }, - // User taps "End session": re-lock now rather than waiting out - // IDLE_LOCK_TIMEOUT. Guarded to the active gate so a gate-disabled - // machine (which rests at idle) never leaves it via this event. - // `locked`'s entry clears the access session + loaded card. - END_SESSION: { guard: 'accessGateActive', target: 'locked' }, }, },