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
4ce68c1301
commit
82fbf12950
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' })
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue