fix(access): reset idle timer on activity + add hard session cap
The idle re-lock was an XState `after` on `idle`, which is anchored to state ENTRY and never reset on screen touches — so it fired a fixed 60s countdown regardless of interaction (reported: touching the screen didn't extend the session). The machine can't observe raw pointer events, so inactivity can't be measured there. Move session timeouts to the DOM layer (useSessionSecurity, mounted in the always-on App shell), enforcing two fail-closed limits that both re-lock via a new root-level END_SESSION transition: - SOFT idle (60s): re-lock after no *trusted* pointer/touch/key input while on the idle menu; resets on every genuine interaction. Scoped to idle so it never interrupts an in-flight cash-in/out. - HARD cap (10min): absolute ceiling from unlock time, never reset — a forgotten/relayed card can't hold a session open. Lives at the machine root so it can lock mid-transaction, not just from idle. Security posture: only event.isTrusted resets the soft timer (synthetic events can't keep a session alive); wall-clock deadline checks re-lock immediately after a suspend/resume rather than silently extending; one-shot disarm-on-fire prevents spin; END_SESSION is guarded to the active gate so it's inert when the gate is off. Machine no longer owns the idle timer; tests updated (END_SESSION re-locks from idle and from an in-flight cash-out; no-op when disabled). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ivBosaWmv8vwFE7ejrdHW
This commit is contained in:
parent
d1105bbb38
commit
984ae9d71e
5 changed files with 150 additions and 50 deletions
|
|
@ -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
|
||||
|
|
|
|||
95
apps/machine/src/composables/useSessionSecurity.ts
Normal file
95
apps/machine/src/composables/useSessionSecurity.ts
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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' })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,41 +61,33 @@ 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 {
|
||||
// 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' })
|
||||
expect(actor.getSnapshot().value).toBe('idle')
|
||||
vi.advanceTimersByTime(60000)
|
||||
actor.send({ type: 'END_SESSION' })
|
||||
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()
|
||||
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),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('user-initiated end of session', () => {
|
||||
it('END_SESSION re-locks immediately when the gate is active', () => {
|
||||
const actor = createActor(createATMMachine({}, { accessControlEnabled: true }))
|
||||
const actor = createActor(createATMMachine(rateServices, { accessControlEnabled: true }))
|
||||
actor.start()
|
||||
actor.send({ type: 'ACCESS_GRANTED', role: 'user', credentialIdHash: 'abc' })
|
||||
expect(actor.getSnapshot().value).toBe('idle')
|
||||
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')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue