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:
Padreug 2026-08-16 09:12:22 +02:00
commit 82fbf12950
5 changed files with 150 additions and 50 deletions

View file

@ -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

View 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)
}

View file

@ -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' })
}