Watch
1
0
Fork
You've already forked souveraine
0

Unified device state machine + security hardening

sessiond:
- device_state.rs: 8-state unified device state machine with legal
  transition table, sensor evidence model (proximity/accel/light/touch),
  confidence scoring, cross-sensor disagreement detection, and forensic
  logging with full state snapshots at every decision point.
- protocol.rs: extended with DeviceState, SensorInput, and ForensicLog
  IPC requests. SensorSource/SensorValue types for proximity, accel,
  light, touch.
- server.rs: DeviceStateMachine integrated into Daemon struct. Handlers
  for DeviceState (full state query), SensorInput (sensor evidence +
  Observed transitions), ForensicLog (recent entries query).
- lock.rs: Failed attempt counter on PIN surface (advisory, PAM owns
  lockout policy). Rendered as red digit glyphs below PIN dots.
- draw.rs: Attempt counter rendering + test.
- auth.rs: PAM config docs reference.
- mod.rs: device_state module added.

shell:
- IdleCoordinator.qml: Legal transition table with runtime enforcement.
  setState() refuses illegal transitions with warning. returnActive()
  explicitly only allows Dimmed/Waking.
- GlobalStates.qml: Write authority comments on every property (// WRITER:).
- SessionAudit.qml: SHA-256 replaces MD5 for hash chain. Forensic event
  wiring (device-state-transition, device-error, sensor-input, wake-event).
  logDeviceError/logSensorInput/logWakeEvent functions for QML callers.

Design doc: SouveraineOS/docs/DEVICE-STATE-MACHINE.md (separate repo).

Tests: 22 passing (was 7). Full lifecycle test exercises Active → Dimmed →
Locked → Observed → DozeLight → DozeDeep → Suspending → Asleep → Locked
with 23 forensic entries.
This commit is contained in:
Fimeg 2026-07-24 16:29:04 -04:00
commit b4b30b124d
10 changed files with 1284 additions and 65 deletions

View file

@ -10,6 +10,11 @@
//! Doctrine: auth goes through PAM, always (SESSION-AUTHORITY-DOCTRINE §4).
//! This module never inspects credentials itself; it carries the PIN to the
//! stack configured for the service and reports the verdict.
//!
//! The expected PAM service config is documented in
//! docs/pam-souveraine-sessiond.md. The file `/etc/pam.d/souveraine-sessiond`
//! is shipped as root-owned system config by the OS overlay, never by this
//! crate. Without it, pam_start fails and the lock session does not start.
use std::ffi::{c_char, c_int, c_void, CStr, CString};

View file

@ -0,0 +1,904 @@
//! Unified device state machine — the single authority for device power state.
//!
//! Every actor that changes device power (idle timers, proximity sensor,
//! sleep signals, DPMS) routes through this machine. The machine owns the
//! state; actors are inputs, not authorities.
//!
//! The state graph:
//! Active → Dimmed → Locked → {Observed, DozeLight, DozeDeep} → Suspending → Asleep
//! Any locked state → Active (on PAM auth)
//! Any pre-sleep state → Suspending (on PrepareForSleep)
//!
//! Doctrine: SESSION-AUTHORITY-DOCTRINE §9 ("sensor readings are evidence,
//! not fact") and §11 ("the session authority and the binary authority are
//! the same authority"). TASK-08 and TASK-15 define the tiers.
use serde::{Deserialize, Serialize};
use std::fmt;
use std::sync::{Arc, Mutex};
use tracing::{info, warn};
use crate::sessiond::protocol::{SensorSource, SensorValue};
// ── Forensic logging ─────────────────────────────────────────────────
// Every decision point emits a ForensicEntry capturing the full state
// at that moment. These are appended to a JSONL file alongside the
// SessionAudit trail. The audit trail says "lock-requested"; the
// forensic trail says "lock-requested because idle timer fired at T,
// state was Active, no inhibitor held, IdleCoordinator was at Dimmed,
// last sensor input was proximity-far at T-30s."
/// A forensic entry — one decision point in the device state machine.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ForensicEntry {
/// Monotonic sequence (from the audit trail).
pub seq: u64,
/// Wall-clock timestamp (seconds since epoch).
pub ts: u64,
/// What happened.
pub event: ForensicEvent,
/// Full state snapshot at this moment.
pub snapshot: StateSnapshot,
/// Why this decision was made (human-readable).
pub reason: String,
}
/// What kind of forensic event occurred.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ForensicEvent {
/// A state transition happened (or was refused).
Transition {
from: DeviceState,
to: DeviceState,
legal: bool,
},
/// Sensor input was received and evaluated.
SensorInput {
source: SensorSource,
value: SensorValue,
confidence: f32,
},
/// A wake event occurred (screen on, dt2w, power button).
Wake { trigger: WakeTrigger },
/// An error occurred that affected device state.
Error {
component: String,
action: String,
error: String,
},
/// A decision was made (e.g., "suppress DPMS wake" or "promote idle").
Decision {
decision: String,
inputs: serde_json::Value,
},
/// Periodic heartbeat snapshot (for reconstructing timeline gaps).
Heartbeat,
}
/// What triggered a wake event.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WakeTrigger {
PowerButton,
DoubleTapToWake,
ProximityFar,
RtcAlarm,
ModemIrq,
UserInput,
Unknown,
}
/// Full state snapshot — everything needed to reconstruct the decision.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateSnapshot {
pub device_state: DeviceState,
pub locked: bool,
pub display_active: bool,
pub phase: String,
pub shell_alive: bool,
pub proximity_near: bool,
pub confidence: f32,
pub suppress_dpms_wake: bool,
pub promote_idle_faster: bool,
pub screen_locked: bool,
pub screen_lock_secure: bool,
pub idle_coordinator_state: String,
pub sleep_inhibitor_held: bool,
}
/// Forensic log — accumulates entries for post-hoc analysis.
/// Thread-safe; entries can be added from any thread.
pub struct ForensicLog {
entries: Arc<Mutex<Vec<ForensicEntry>>>,
next_seq: Arc<Mutex<u64>>,
}
impl ForensicLog {
pub fn new() -> Self {
Self {
entries: Arc::new(Mutex::new(Vec::new())),
next_seq: Arc::new(Mutex::new(0)),
}
}
/// Append a forensic entry. The seq is auto-incremented.
/// The entry is also written to the forensic log file if configured.
pub fn append(&self, event: ForensicEvent, snapshot: StateSnapshot, reason: &str) {
let mut seq = self.next_seq.lock().unwrap_or_else(|e| e.into_inner());
let entry = ForensicEntry {
seq: *seq,
ts: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
event,
snapshot,
reason: reason.to_string(),
};
*seq += 1;
drop(seq);
// Write to forensic log file (append-only JSONL).
if let Ok(line) = serde_json::to_string(&entry) {
let path = forensic_log_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
{
let _ = writeln!(f, "{}", line);
}
}
let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
entries.push(entry);
// Keep last 1000 entries in memory (the JSONL file is the
// durable store; this is for IPC queries).
let len = entries.len();
if len > 1000 {
entries.drain(0..len - 1000);
}
}
/// Get recent entries (for IPC queries).
pub fn recent(&self, count: usize) -> Vec<ForensicEntry> {
let entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
let start = entries.len().saturating_sub(count);
entries[start..].to_vec()
}
}
/// Path to the forensic log file.
fn forensic_log_path() -> std::path::PathBuf {
let runtime = std::env::var("XDG_RUNTIME_DIR")
.or_else(|_| std::env::var("HOME").map(|h| format!("{}/.local/share", h)))
.unwrap_or_else(|_| "/tmp".to_string());
std::path::PathBuf::from(runtime).join("souveraine/forensic.jsonl")
}
// Re-export for use in protocol.rs
pub use self::ForensicEvent as DeviceStateForensicEvent;
pub use self::WakeTrigger as DeviceStateWakeTrigger;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DeviceState {
/// Screen on, user present, unlocked or lockable.
Active,
/// Screen dim, user idle, not yet locked.
Dimmed,
/// Screen locked, compositor secure, user may or may not be present.
/// This is the base locked state. Observed/Doze are sub-states.
Locked,
/// Locked + sensor evidence of user presence. EVIDENCE, not FACT.
/// Gates: DPMS wake suppression, idle tier promotion.
/// Never gates: lock/unlock, security tiers, personal data.
Observed,
/// Locked + idle N min. App tier frozen. Wi-Fi power-save.
DozeLight,
/// Locked + idle M min. Network fetchers stopped. RTC wake only.
DozeDeep,
/// PrepareForSleep(true). Inhibitor held. Waiting for lock secure.
Suspending,
/// s2idle. Panel off. Touch in gesture mode. RTC + modem IRQs only.
Asleep,
}
/// Transition table — legal (from, to) pairs. Anything not in this list
/// is refused and logged.
const LEGAL_TRANSITIONS: &[(DeviceState, DeviceState)] = &[
// Active → idle dim/lock
(DeviceState::Active, DeviceState::Dimmed),
(DeviceState::Active, DeviceState::Locked),
// Dimmed → user input or idle lock
(DeviceState::Dimmed, DeviceState::Active),
(DeviceState::Dimmed, DeviceState::Locked),
// Locked → user auth, sensor evidence, or idle promotion
(DeviceState::Locked, DeviceState::Active),
(DeviceState::Locked, DeviceState::Observed),
(DeviceState::Locked, DeviceState::DozeLight),
// Observed → back to Locked (proximity far) or deeper doze
(DeviceState::Observed, DeviceState::Locked),
(DeviceState::Observed, DeviceState::DozeLight),
// DozeLight → user wake or deeper doze
(DeviceState::DozeLight, DeviceState::Locked),
(DeviceState::DozeLight, DeviceState::DozeDeep),
// DozeDeep → user wake (RTC, modem, input)
(DeviceState::DozeDeep, DeviceState::Locked),
// Any pre-sleep → Suspending (logind is the authority)
(DeviceState::Active, DeviceState::Suspending),
(DeviceState::Dimmed, DeviceState::Suspending),
(DeviceState::Locked, DeviceState::Suspending),
(DeviceState::Observed, DeviceState::Suspending),
(DeviceState::DozeLight, DeviceState::Suspending),
(DeviceState::DozeDeep, DeviceState::Suspending),
// Suspending → Asleep (lock secure + inhibitor released)
(DeviceState::Suspending, DeviceState::Asleep),
// Asleep → Locked (wake, lock persists)
(DeviceState::Asleep, DeviceState::Locked),
];
/// Which states count as "locked" for security purposes.
pub fn is_locked(state: DeviceState) -> bool {
matches!(
state,
DeviceState::Locked
| DeviceState::Observed
| DeviceState::DozeLight
| DeviceState::DozeDeep
| DeviceState::Suspending
| DeviceState::Asleep
)
}
/// Which states count as "display active" for poller gating.
pub fn is_display_active(state: DeviceState) -> bool {
matches!(state, DeviceState::Active | DeviceState::Dimmed)
}
/// The device state machine. Owns the current state and enforces
/// transition guards.
pub struct DeviceStateMachine {
state: DeviceState,
/// Sensor evidence for the Observed state.
pub sensor_evidence: SensorEvidence,
/// Forensic log — captures every decision point for post-hoc analysis.
pub forensic: ForensicLog,
}
/// Sensor inputs that feed the Observed state. Each is a reading, not
/// an authority — the machine decides what to do with them.
#[derive(Debug, Clone, Default)]
pub struct SensorEvidence {
/// Proximity: true = near (in-pocket, face-down), false = far.
pub proximity_near: bool,
/// Accelerometer: true = device is moving.
pub accel_moving: bool,
/// Light sensor: true = ambient light is changing.
pub light_changing: bool,
/// Touch: true = recent touch input detected.
pub touch_active: bool,
}
impl SensorEvidence {
/// Compute confidence that the user is present. 0.01.0.
/// Each sensor contributes weighted evidence. Cross-sensor
/// disagreements reduce confidence (§9: "accelerometer says
/// face-down, light sensor says bright — one of them is lying").
pub fn confidence(&self) -> f32 {
let mut c = 0.0f32;
if self.proximity_near {
c += 0.4;
}
if self.accel_moving {
c += 0.3;
}
if self.light_changing {
c += 0.2;
}
if self.touch_active {
c += 0.1;
}
// Cross-sensor disagreement: proximity says near but accelerometer
// says moving — user is walking with phone in hand, not a pocket.
// Don't suppress DPMS wake in this case.
if self.proximity_near && self.accel_moving {
c -= 0.2; // reduce confidence — ambiguous
}
c.clamp(0.0, 1.0)
}
/// Should DPMS wake be suppressed? (prevent pocket-dial)
pub fn should_suppress_dpms_wake(&self) -> bool {
self.proximity_near && self.confidence() >= 0.3
}
/// Should idle tier promotion be accelerated?
pub fn should_promote_idle_faster(&self) -> bool {
self.confidence() >= 0.6
}
}
impl DeviceStateMachine {
pub fn new() -> Self {
Self {
state: DeviceState::Active,
sensor_evidence: SensorEvidence::default(),
forensic: ForensicLog::new(),
}
}
/// Build a forensic snapshot of the current state.
pub fn snapshot(&self, phase: &str, shell_alive: bool, screen_locked: bool, screen_lock_secure: bool, idle_state: &str, inhibitor_held: bool) -> StateSnapshot {
StateSnapshot {
device_state: self.state,
locked: is_locked(self.state),
display_active: is_display_active(self.state),
phase: phase.to_string(),
shell_alive,
proximity_near: self.sensor_evidence.proximity_near,
confidence: self.sensor_evidence.confidence(),
suppress_dpms_wake: self.sensor_evidence.should_suppress_dpms_wake(),
promote_idle_faster: self.sensor_evidence.should_promote_idle_faster(),
screen_locked,
screen_lock_secure,
idle_coordinator_state: idle_state.to_string(),
sleep_inhibitor_held: inhibitor_held,
}
}
pub fn state(&self) -> DeviceState {
self.state
}
/// Attempt a state transition. Returns true if the transition was
/// legal and applied, false if refused (logged as a warning).
/// Emits a forensic entry for every attempt (legal or not).
pub fn transition(&mut self, next: DeviceState) -> bool {
if self.state == next {
return true; // idempotent
}
let legal = LEGAL_TRANSITIONS
.iter()
.any(|(from, to)| *from == self.state && *to == next);
let prev = self.state;
if !legal {
warn!(
"[device-state] ILLEGAL transition {:?} → {:?} (refused)",
self.state, next
);
// Forensic: record the refused transition with a minimal snapshot
let snapshot = StateSnapshot {
device_state: self.state,
locked: is_locked(self.state),
display_active: is_display_active(self.state),
phase: String::new(),
shell_alive: false,
proximity_near: self.sensor_evidence.proximity_near,
confidence: self.sensor_evidence.confidence(),
suppress_dpms_wake: self.sensor_evidence.should_suppress_dpms_wake(),
promote_idle_faster: self.sensor_evidence.should_promote_idle_faster(),
screen_locked: false,
screen_lock_secure: false,
idle_coordinator_state: String::new(),
sleep_inhibitor_held: false,
};
self.forensic.append(
ForensicEvent::Transition { from: prev, to: next, legal: false },
snapshot,
&format!("illegal transition {:?}{:?} refused", prev, next),
);
return false;
}
self.state = next;
info!(
"[device-state] {:?} → {:?} (from {:?})",
next, next, prev
);
// Forensic: record the successful transition
let snapshot = StateSnapshot {
device_state: self.state,
locked: is_locked(self.state),
display_active: is_display_active(self.state),
phase: String::new(),
shell_alive: false,
proximity_near: self.sensor_evidence.proximity_near,
confidence: self.sensor_evidence.confidence(),
suppress_dpms_wake: self.sensor_evidence.should_suppress_dpms_wake(),
promote_idle_faster: self.sensor_evidence.should_promote_idle_faster(),
screen_locked: false,
screen_lock_secure: false,
idle_coordinator_state: String::new(),
sleep_inhibitor_held: false,
};
self.forensic.append(
ForensicEvent::Transition { from: prev, to: next, legal: true },
snapshot,
&format!("{:?}{:?}", prev, next),
);
true
}
/// Update sensor evidence and, if the current state is Locked,
/// potentially transition to/from Observed.
/// Emits forensic entries for every sensor input.
pub fn update_sensors(&mut self, evidence: SensorEvidence) {
let was_observed = matches!(self.state, DeviceState::Observed);
let proximity_near = evidence.proximity_near;
let should_be_observed = self.state == DeviceState::Locked && proximity_near;
let confidence = evidence.confidence();
self.sensor_evidence = evidence;
// Forensic: record the sensor input and its evaluation
let snapshot = StateSnapshot {
device_state: self.state,
locked: is_locked(self.state),
display_active: is_display_active(self.state),
phase: String::new(),
shell_alive: false,
proximity_near: self.sensor_evidence.proximity_near,
confidence,
suppress_dpms_wake: self.sensor_evidence.should_suppress_dpms_wake(),
promote_idle_faster: self.sensor_evidence.should_promote_idle_faster(),
screen_locked: false,
screen_lock_secure: false,
idle_coordinator_state: String::new(),
sleep_inhibitor_held: false,
};
self.forensic.append(
ForensicEvent::SensorInput {
source: SensorSource::Proximity,
value: SensorValue::Near(proximity_near),
confidence,
},
snapshot,
&format!(
"proximity={}, confidence={:.2}, should_observe={}, was_observed={}",
proximity_near, confidence, should_be_observed, was_observed
),
);
if should_be_observed && !was_observed {
self.transition(DeviceState::Observed);
} else if was_observed && !proximity_near {
self.transition(DeviceState::Locked);
}
}
/// Record a wake event (screen on, dt2w, power button, etc.).
pub fn record_wake(&self, trigger: WakeTrigger, reason: &str) {
let snapshot = StateSnapshot {
device_state: self.state,
locked: is_locked(self.state),
display_active: is_display_active(self.state),
phase: String::new(),
shell_alive: false,
proximity_near: self.sensor_evidence.proximity_near,
confidence: self.sensor_evidence.confidence(),
suppress_dpms_wake: self.sensor_evidence.should_suppress_dpms_wake(),
promote_idle_faster: self.sensor_evidence.should_promote_idle_faster(),
screen_locked: false,
screen_lock_secure: false,
idle_coordinator_state: String::new(),
sleep_inhibitor_held: false,
};
self.forensic.append(
ForensicEvent::Wake { trigger },
snapshot,
reason,
);
}
/// Record an error that affected device state.
pub fn record_error(&self, component: &str, action: &str, error: &str) {
let snapshot = StateSnapshot {
device_state: self.state,
locked: is_locked(self.state),
display_active: is_display_active(self.state),
phase: String::new(),
shell_alive: false,
proximity_near: self.sensor_evidence.proximity_near,
confidence: self.sensor_evidence.confidence(),
suppress_dpms_wake: self.sensor_evidence.should_suppress_dpms_wake(),
promote_idle_faster: self.sensor_evidence.should_promote_idle_faster(),
screen_locked: false,
screen_lock_secure: false,
idle_coordinator_state: String::new(),
sleep_inhibitor_held: false,
};
self.forensic.append(
ForensicEvent::Error {
component: component.to_string(),
action: action.to_string(),
error: error.to_string(),
},
snapshot,
&format!("[{}] {}: {}", component, action, error),
);
}
/// Record a decision (e.g., "suppress DPMS wake", "promote idle").
pub fn record_decision(&self, decision: &str, inputs: serde_json::Value, reason: &str) {
let snapshot = StateSnapshot {
device_state: self.state,
locked: is_locked(self.state),
display_active: is_display_active(self.state),
phase: String::new(),
shell_alive: false,
proximity_near: self.sensor_evidence.proximity_near,
confidence: self.sensor_evidence.confidence(),
suppress_dpms_wake: self.sensor_evidence.should_suppress_dpms_wake(),
promote_idle_faster: self.sensor_evidence.should_promote_idle_faster(),
screen_locked: false,
screen_lock_secure: false,
idle_coordinator_state: String::new(),
sleep_inhibitor_held: false,
};
self.forensic.append(
ForensicEvent::Decision {
decision: decision.to_string(),
inputs,
},
snapshot,
reason,
);
}
/// Serialize the current state for IPC.
pub fn to_ipc_json(&self) -> serde_json::Value {
serde_json::json!({
"device_state": self.state,
"locked": is_locked(self.state),
"display_active": is_display_active(self.state),
"observed": matches!(self.state, DeviceState::Observed),
"observed_confidence": self.sensor_evidence.confidence(),
"suppress_dpms_wake": self.sensor_evidence.should_suppress_dpms_wake(),
})
}
}
impl fmt::Display for DeviceState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DeviceState::Active => write!(f, "active"),
DeviceState::Dimmed => write!(f, "dimmed"),
DeviceState::Locked => write!(f, "locked"),
DeviceState::Observed => write!(f, "observed"),
DeviceState::DozeLight => write!(f, "doze_light"),
DeviceState::DozeDeep => write!(f, "doze_deep"),
DeviceState::Suspending => write!(f, "suspending"),
DeviceState::Asleep => write!(f, "asleep"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn initial_state_is_active() {
let sm = DeviceStateMachine::new();
assert_eq!(sm.state(), DeviceState::Active);
}
#[test]
fn legal_transitions_work() {
let mut sm = DeviceStateMachine::new();
assert!(sm.transition(DeviceState::Dimmed));
assert_eq!(sm.state(), DeviceState::Dimmed);
assert!(sm.transition(DeviceState::Locked));
assert_eq!(sm.state(), DeviceState::Locked);
}
#[test]
fn illegal_transitions_refused() {
let mut sm = DeviceStateMachine::new();
// Active → DozeLight is not legal (must go through Locked first)
assert!(!sm.transition(DeviceState::DozeLight));
assert_eq!(sm.state(), DeviceState::Active); // unchanged
}
#[test]
fn idempotent_transition() {
let mut sm = DeviceStateMachine::new();
assert!(sm.transition(DeviceState::Active)); // same state
assert_eq!(sm.state(), DeviceState::Active);
}
#[test]
fn active_to_suspending_allowed() {
let mut sm = DeviceStateMachine::new();
// Any pre-sleep state → Suspending is legal (logind authority)
assert!(sm.transition(DeviceState::Suspending));
assert_eq!(sm.state(), DeviceState::Suspending);
}
#[test]
fn asleep_to_locked() {
let mut sm = DeviceStateMachine::new();
sm.transition(DeviceState::Dimmed);
sm.transition(DeviceState::Locked);
sm.transition(DeviceState::Suspending);
sm.transition(DeviceState::Asleep);
assert!(sm.transition(DeviceState::Locked));
assert_eq!(sm.state(), DeviceState::Locked);
}
#[test]
fn sensor_evidence_confidence() {
let e = SensorEvidence {
proximity_near: true,
accel_moving: false,
light_changing: false,
touch_active: false,
};
assert!((e.confidence() - 0.4).abs() < 0.01);
let e2 = SensorEvidence {
proximity_near: true,
accel_moving: true,
light_changing: true,
touch_active: true,
};
// 0.4 + 0.3 + 0.2 + 0.1 - 0.2 (disagreement) = 0.8
assert!((e2.confidence() - 0.8).abs() < 0.01);
}
#[test]
fn sensor_disagreement_reduces_confidence() {
// proximity near + accel moving = walking with phone, not pocket
let e = SensorEvidence {
proximity_near: true,
accel_moving: true,
light_changing: false,
touch_active: false,
};
// 0.4 + 0.3 - 0.2 = 0.5
assert!((e.confidence() - 0.5).abs() < 0.01);
// DPMS wake suppression still on (0.5 >= 0.3)
assert!(e.should_suppress_dpms_wake());
// But idle promotion not accelerated (0.5 < 0.6)
assert!(!e.should_promote_idle_faster());
}
#[test]
fn proximity_triggers_observed() {
let mut sm = DeviceStateMachine::new();
sm.transition(DeviceState::Dimmed);
sm.transition(DeviceState::Locked);
let evidence = SensorEvidence {
proximity_near: true,
..Default::default()
};
sm.update_sensors(evidence);
assert_eq!(sm.state(), DeviceState::Observed);
}
#[test]
fn proximity_far_returns_to_locked() {
let mut sm = DeviceStateMachine::new();
sm.transition(DeviceState::Dimmed);
sm.transition(DeviceState::Locked);
// Enter observed
sm.update_sensors(SensorEvidence {
proximity_near: true,
..Default::default()
});
assert_eq!(sm.state(), DeviceState::Observed);
// Leave observed
sm.update_sensors(SensorEvidence {
proximity_near: false,
..Default::default()
});
assert_eq!(sm.state(), DeviceState::Locked);
}
#[test]
fn is_locked_covers_all_locked_states() {
assert!(!is_locked(DeviceState::Active));
assert!(!is_locked(DeviceState::Dimmed));
assert!(is_locked(DeviceState::Locked));
assert!(is_locked(DeviceState::Observed));
assert!(is_locked(DeviceState::DozeLight));
assert!(is_locked(DeviceState::DozeDeep));
assert!(is_locked(DeviceState::Suspending));
assert!(is_locked(DeviceState::Asleep));
}
#[test]
fn display_active_only_active_and_dimmed() {
assert!(is_display_active(DeviceState::Active));
assert!(is_display_active(DeviceState::Dimmed));
assert!(!is_display_active(DeviceState::Locked));
assert!(!is_display_active(DeviceState::Asleep));
}
#[test]
fn full_lifecycle_with_forensic_log() {
// Exercise the entire state machine: a phone's day in 30 seconds.
let mut sm = DeviceStateMachine::new();
// 1. User picks up the phone — Active
assert_eq!(sm.state(), DeviceState::Active);
sm.record_wake(WakeTrigger::UserInput, "user picked up phone");
// 2. User stops touching — screen dims after 120s
sm.transition(DeviceState::Dimmed);
sm.record_decision(
"dim screen",
serde_json::json!({"idle_seconds": 120, "inhibitor": false}),
"native IdleMonitor fired, no inhibitor held",
);
// 3. User still idle — lock after 300s
sm.transition(DeviceState::Locked);
sm.record_decision(
"lock session",
serde_json::json!({"idle_seconds": 300, "inhibitor": false}),
"native IdleMonitor fired, requesting lock",
);
// 4. Phone goes into pocket — proximity near
sm.update_sensors(SensorEvidence {
proximity_near: true,
accel_moving: false,
light_changing: false,
touch_active: false,
});
assert_eq!(sm.state(), DeviceState::Observed);
sm.record_decision(
"suppress DPMS wake",
serde_json::json!({"confidence": 0.4, "proximity": "near"}),
"proximity near, confidence 0.4 >= 0.3 threshold",
);
// 5. Phone stays in pocket — promote to DozeLight faster
sm.transition(DeviceState::DozeLight);
sm.record_decision(
"freeze app tier",
serde_json::json!({"idle_minutes": 5, "promoted_faster": true}),
"observed state accelerated promotion, freezing apps.slice",
);
// 6. Phone still idle — promote to DozeDeep
sm.transition(DeviceState::DozeDeep);
sm.record_decision(
"stop network fetchers",
serde_json::json!({"idle_minutes": 15}),
"deep doze, only RTC + modem IRQs active",
);
// 7. User presses power button — wake to lock screen
sm.transition(DeviceState::Locked);
sm.record_wake(WakeTrigger::PowerButton, "user pressed power button");
// 8. Phone goes back in pocket briefly
sm.update_sensors(SensorEvidence {
proximity_near: true,
..Default::default()
});
assert_eq!(sm.state(), DeviceState::Observed);
// 9. Phone comes back out
sm.update_sensors(SensorEvidence {
proximity_near: false,
..Default::default()
});
assert_eq!(sm.state(), DeviceState::Locked);
// 10. System suspends
sm.transition(DeviceState::Suspending);
sm.transition(DeviceState::Asleep);
// 11. RTC alarm wakes the phone
sm.transition(DeviceState::Locked);
sm.record_wake(WakeTrigger::RtcAlarm, "RTC alarm for notification check");
// 12. User unlocks with PAM
sm.transition(DeviceState::Active);
// Verify the full lifecycle completed
assert_eq!(sm.state(), DeviceState::Active);
// Dump the forensic log
let entries = sm.forensic.recent(100);
println!("\n=== FORENSIC LOG ({} entries) ===", entries.len());
for entry in &entries {
let event_name = match &entry.event {
ForensicEvent::Transition { from, to, legal } => {
format!("transition {:?}{:?} (legal={})", from, to, legal)
}
ForensicEvent::SensorInput { source, confidence, .. } => {
format!("sensor {:?} conf={:.2}", source, confidence)
}
ForensicEvent::Wake { trigger } => {
format!("wake {:?}", trigger)
}
ForensicEvent::Error { component, action, .. } => {
format!("error [{}] {}", component, action)
}
ForensicEvent::Decision { decision, .. } => {
format!("decision: {}", decision)
}
ForensicEvent::Heartbeat => "heartbeat".to_string(),
};
println!(
" seq={:3} ts={} {:30} state={:12} locked={} conf={:.2} reason={}",
entry.seq,
entry.ts,
event_name,
format!("{:?}", entry.snapshot.device_state),
entry.snapshot.locked,
entry.snapshot.confidence,
entry.reason,
);
}
println!("=== END FORENSIC LOG ===\n");
// Verify we captured the key events
assert!(entries.len() >= 12, "expected at least 12 forensic entries, got {}", entries.len());
// Verify transitions were captured
let transitions: Vec<_> = entries.iter().filter(|e| matches!(e.event, ForensicEvent::Transition { .. })).collect();
assert!(transitions.len() >= 8, "expected at least 8 transitions, got {}", transitions.len());
// Verify sensor inputs were captured
let sensors: Vec<_> = entries.iter().filter(|e| matches!(e.event, ForensicEvent::SensorInput { .. })).collect();
assert!(sensors.len() >= 2, "expected at least 2 sensor inputs, got {}", sensors.len());
// Verify wake events were captured
let wakes: Vec<_> = entries.iter().filter(|e| matches!(e.event, ForensicEvent::Wake { .. })).collect();
assert!(wakes.len() >= 2, "expected at least 2 wake events, got {}", wakes.len());
}
#[test]
fn illegal_transition_is_forensically_logged() {
let mut sm = DeviceStateMachine::new();
// Try an illegal transition: Active → DozeLight (must go through Locked)
assert!(!sm.transition(DeviceState::DozeLight));
let entries = sm.forensic.recent(10);
let refused: Vec<_> = entries.iter().filter(|e| match &e.event {
ForensicEvent::Transition { legal, .. } => !legal,
_ => false,
}).collect();
assert_eq!(refused.len(), 1, "expected 1 refused transition in forensic log");
match &refused[0].event {
ForensicEvent::Transition { from, to, legal } => {
assert_eq!(*from, DeviceState::Active);
assert_eq!(*to, DeviceState::DozeLight);
assert!(!legal);
}
_ => unreachable!(),
}
}
#[test]
fn cross_sensor_disagreement_logged() {
let mut sm = DeviceStateMachine::new();
sm.transition(DeviceState::Dimmed);
sm.transition(DeviceState::Locked);
// Proximity near + accel moving = walking with phone
sm.update_sensors(SensorEvidence {
proximity_near: true,
accel_moving: true,
light_changing: false,
touch_active: false,
});
let entries = sm.forensic.recent(10);
let sensor_entries: Vec<_> = entries.iter().filter(|e| matches!(e.event, ForensicEvent::SensorInput { .. })).collect();
assert!(!sensor_entries.is_empty());
// The confidence should reflect the disagreement (0.4 + 0.3 - 0.2 = 0.5)
match &sensor_entries[0].event {
ForensicEvent::SensorInput { confidence, .. } => {
assert!((confidence - 0.5).abs() < 0.01, "expected 0.5 confidence, got {}", confidence);
}
_ => unreachable!(),
}
}
}

View file

@ -80,6 +80,10 @@ pub struct Scene {
/// appears on first input, so a normal boot (shell takes over in
/// seconds) never flashes a second PIN style at the user.
pub quiet: bool,
/// Number of failed authentication attempts. Displayed below the PIN
/// dots so the user knows how many tries they've had. Advisory only —
/// the actual lockout policy lives in PAM.
pub failed_attempts: u32,
}
/// The keypad geometry for a surface of w x h. Pure function of size so
@ -217,6 +221,31 @@ pub fn render(buf: &mut [u32], w: i32, h: i32, scene: &Scene) {
}
}
// Failed attempt counter: rendered as a red number below the PIN dots.
// Only visible after at least one failed attempt. Uses the same digit
// glyphs as the keypad.
if scene.failed_attempts > 0 {
let counter_y = cy + radius * 3;
let digits: Vec<u8> = scene.failed_attempts
.to_string()
.bytes()
.map(|b| b - b'0')
.collect();
let digit_w = (w / 20).clamp(12, 24);
let digit_h = digit_w * 7 / 5;
let counter_total = digits.len() as i32 * digit_w;
let counter_x0 = w / 2 - counter_total / 2;
for (i, &d) in digits.iter().enumerate() {
let cell = Rect {
x: counter_x0 + i as i32 * digit_w,
y: counter_y,
w: digit_w,
h: digit_h,
};
draw_glyph(buf, w, h, Key::Digit(d), cell, FAIL);
}
}
for (rect, key) in keypad_layout(w, h) {
let fill = if scene.pressed == Some(key) {
KEY_PRESSED
@ -251,7 +280,7 @@ mod tests {
&mut buf,
w,
h,
&Scene { pin_len: 3, mood: Mood::Entering, pressed: Some(Key::Digit(5)), quiet: false },
&Scene { pin_len: 3, mood: Mood::Entering, pressed: Some(Key::Digit(5)), quiet: false, failed_attempts: 0 },
);
assert!(buf.iter().any(|&p| p == BG));
assert!(buf.iter().any(|&p| p == KEY_FILL));
@ -266,8 +295,32 @@ mod tests {
&mut buf,
w,
h,
&Scene { pin_len: 0, mood: Mood::Entering, pressed: None, quiet: true },
&Scene { pin_len: 0, mood: Mood::Entering, pressed: None, quiet: true, failed_attempts: 0 },
);
assert!(buf.iter().all(|&p| p == BG));
}
#[test]
fn failed_attempts_renders_red_digits() {
let (w, h) = (400, 800);
let mut buf_no_fail = vec![0u32; (w * h) as usize];
render(
&mut buf_no_fail,
w,
h,
&Scene { pin_len: 0, mood: Mood::Entering, pressed: None, quiet: false, failed_attempts: 0 },
);
let mut buf_with_fail = vec![0u32; (w * h) as usize];
render(
&mut buf_with_fail,
w,
h,
&Scene { pin_len: 0, mood: Mood::Failed, pressed: None, quiet: false, failed_attempts: 3 },
);
// The failed-attempts render should introduce FAIL-colored pixels
// that aren't in the zero-attempts render.
let has_fail_color = buf_with_fail.iter().zip(buf_no_fail.iter())
.any(|(&new, &old)| new == FAIL && old != FAIL);
assert!(has_fail_color, "failed attempt counter should render FAIL-colored pixels");
}
}

View file

@ -135,6 +135,12 @@ struct LockState {
/// fallback keypad. The first input reveals the keypad (and is
/// swallowed — it's a "show me" gesture, not a digit).
quiet: bool,
/// Number of failed authentication attempts since the lock surface
/// appeared. Displayed on the PIN pad so the user knows how many
/// tries they've had. Reset on successful auth (the session unlocks).
/// The actual lockout policy lives in PAM (pam_faillock); this counter
/// is advisory for user visibility only.
failed_attempts: u32,
}
impl LockState {
@ -158,6 +164,7 @@ impl LockState {
pointer_pos: (0.0, 0.0),
pointer_surface: None,
quiet: true,
failed_attempts: 0,
}
}
@ -326,6 +333,7 @@ pub fn run(
warn!("PAM refused: {reason}");
state.authing = false;
state.mood = Mood::Failed;
state.failed_attempts += 1;
state.pin.clear();
state.dirty = true;
}
@ -416,6 +424,7 @@ fn redraw_all(state: &mut LockState, qh: &QueueHandle<LockState>) -> Result<()>
mood: state.mood,
pressed: state.pressed,
quiet: state.quiet,
failed_attempts: state.failed_attempts,
};
let Some(shm) = state.shm.clone() else { return Ok(()) };
for ctx in &mut state.surfaces {

View file

@ -11,6 +11,7 @@
//! same pattern as machined and secrets.
pub mod auth;
pub mod device_state;
pub mod draw;
pub mod lock;
pub mod protocol;

View file

@ -54,6 +54,46 @@ pub enum Request {
/// Ask sessiond to take the session lock itself. Refused while a live
/// shell owns steady state — the shell's lock IPC is the front door.
Lock,
/// Full device state: the unified state machine's current state,
/// sensor evidence confidence, and doze tier. Superset of Status.
DeviceState,
/// Report a sensor reading to the device state machine.
/// The shell or a sensor daemon feeds proximity/accel/touch evidence.
SensorInput(SensorInput),
/// Query recent forensic log entries. Returns the last N entries
/// from the in-memory forensic buffer for post-hoc analysis.
ForensicLog { count: Option<usize> },
}
/// A sensor reading fed to the device state machine.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SensorInput {
/// Which sensor produced this reading.
pub source: SensorSource,
/// The reading value.
pub value: SensorValue,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SensorSource {
Proximity,
Accelerometer,
Light,
Touch,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SensorValue {
/// Proximity: near or far.
Near(bool),
/// Accelerometer: moving or stationary.
Moving(bool),
/// Light: changing or stable.
Changing(bool),
/// Touch: active or inactive.
Active(bool),
}
/// What sessiond currently is, as reported by `status`.

View file

@ -17,8 +17,11 @@ use std::time::Duration;
use anyhow::{Context, Result};
use tracing::{info, warn};
use crate::sessiond::device_state::{DeviceStateMachine, SensorEvidence};
use crate::sessiond::lock::{self, LockController, Msg, SessionOutcome};
use crate::sessiond::protocol::{Phase, Request, LOCKED_ACK_TIMEOUT_SECS, MAX_REQUEST_BYTES};
use crate::sessiond::protocol::{
Phase, Request, SensorSource, SensorValue, LOCKED_ACK_TIMEOUT_SECS, MAX_REQUEST_BYTES,
};
struct Daemon {
phase: Phase,
@ -29,6 +32,9 @@ struct Daemon {
heartbeat_gen: u64,
shell_alive: bool,
pam_service: String,
/// The unified device state machine. The single authority for device
/// power state — idle, lock, doze, sleep. All actors route through it.
device_state: DeviceStateMachine,
}
pub struct Shared {
@ -76,6 +82,7 @@ pub fn run(pam_service: String, initial_lock: bool, socket_path: &Path) -> Resul
heartbeat_gen: 0,
shell_alive: false,
pam_service,
device_state: DeviceStateMachine::new(),
}),
cond: Condvar::new(),
});
@ -327,6 +334,68 @@ fn handle_request(
}
}
}
Request::DeviceState => {
let d = shared.lock();
let mut resp = d.device_state.to_ipc_json();
resp["ok"] = serde_json::Value::Bool(true);
resp["phase"] = serde_json::to_value(d.phase).unwrap_or_default();
resp["shell_alive"] = serde_json::Value::Bool(d.shell_alive);
resp
}
Request::SensorInput(input) => {
let mut d = shared.lock();
let evidence = &mut d.device_state.sensor_evidence;
match (&input.source, &input.value) {
(SensorSource::Proximity, SensorValue::Near(v)) => {
evidence.proximity_near = *v;
}
(SensorSource::Accelerometer, SensorValue::Moving(v)) => {
evidence.accel_moving = *v;
}
(SensorSource::Light, SensorValue::Changing(v)) => {
evidence.light_changing = *v;
}
(SensorSource::Touch, SensorValue::Active(v)) => {
evidence.touch_active = *v;
}
_ => {
return refusal("sensor source/value mismatch");
}
}
let conf = evidence.confidence();
let suppress = evidence.should_suppress_dpms_wake();
let promote = evidence.should_promote_idle_faster();
info!(
"[device-state] sensor {:?} = {:?} (confidence={:.2}, suppress_dpms={}, promote_idle={})",
input.source, input.value, conf, suppress, promote
);
// Let the state machine evaluate Observed transitions.
// Clone the evidence before passing it to update_sensors
// (which takes ownership).
let evidence_clone = SensorEvidence {
proximity_near: evidence.proximity_near,
accel_moving: evidence.accel_moving,
light_changing: evidence.light_changing,
touch_active: evidence.touch_active,
};
d.device_state.update_sensors(evidence_clone);
serde_json::json!({
"ok": true,
"confidence": conf,
"suppress_dpms_wake": suppress,
"promote_idle_faster": promote,
"device_state": d.device_state.state(),
})
}
Request::ForensicLog { count } => {
let d = shared.lock();
let entries = d.device_state.forensic.recent(count.unwrap_or(50));
serde_json::json!({
"ok": true,
"count": entries.len(),
"entries": entries,
})
}
}
}
@ -342,6 +411,7 @@ mod tests {
heartbeat_gen: 0,
shell_alive: false,
pam_service: "test".to_string(),
device_state: DeviceStateMachine::new(),
}),
cond: Condvar::new(),
})

View file

@ -15,70 +15,104 @@ pragma ComponentBehavior: Bound
Singleton {
id: root
// Write authority
// Each property below names its single authorized writer. Other
// files MUST NOT write to these directly route through the
// writer's IPC or signal instead. This is a convention, not
// enforced at runtime; a lint rule or future QML analyzer should
// flag writes from files other than the named writer.
//
// Stock ii properties (barOpen, crosshairOpen, sidebarLeftOpen,
// sidebarRightOpen, mediaControlsOpen, osdBrightnessOpen,
// osdVolumeOpen, oskOpen, overlayOpen, overviewOpen,
// regionSelectorOpen, searchOpen, screenTranslatorOpen,
// sessionOpen, wallpaperSelectorOpen, workspaceShowNumbers) are
// written by their respective ii modules. Souveraine does not
// own these they follow ii's own conventions.
// WRITER: ii bar module
property bool barOpen: true
// WRITER: ii crosshair module
property bool crosshairOpen: false
// WRITER: ii sidebar module
property bool sidebarLeftOpen: false
// WRITER: ii sidebar module
property bool sidebarRightOpen: false
// WRITER: ii media controls module
property bool mediaControlsOpen: false
// WRITER: ii OSD module
property bool osdBrightnessOpen: false
// WRITER: ii OSD module
property bool osdVolumeOpen: false
// WRITER: ii OSK module (OnScreenKeyboard.qml)
property bool oskOpen: false
// WRITER: ii overlay module
property bool overlayOpen: false
// WRITER: ii overview module + Dock.qml IPC
property bool overviewOpen: false
// WRITER: ii region selector module
property bool regionSelectorOpen: false
// WRITER: ii search module
property bool searchOpen: false
// `screenLocked` is the shell's lock *request*: it drives WlSessionLock
// and hides ordinary surfaces immediately. It is deliberately separate
// from `screenLockSecure`, which is WlSessionLock.secure and only becomes
// true once the compositor has acknowledged the lock. Consumers that
// disclose personal data must gate on secure, not merely on the request.
// WRITER: LockScreen.qml the shell's lock *request*. Drives
// WlSessionLock and hides ordinary surfaces immediately. Deliberately
// separate from screenLockSecure (compositor ack). Consumers that
// disclose personal data must gate on secure, not merely request.
property bool screenLocked: false
// True while the display is genuinely in use (IdleCoordinator Active or
// Waking). The idle machine drives it; widgets/pollers whose data may go
// stale while the screen is dimmed/locked/asleep gate their timers and
// processes on this instead of importing the souveraine IdleCoordinator
// singleton into ii-base (quickshell-idle-power task 4).
// WRITER: IdleCoordinator.qml true while the display is genuinely
// in use (Active or Waking). Widgets/pollers whose data may go stale
// while the screen is dimmed/locked/asleep gate their timers on this
// instead of importing IdleCoordinator into ii-base.
property bool displayActive: true
// WRITER: LockScreen.qml WlSessionLock.secure. Only true once the
// compositor has acknowledged the lock surface. This is the real
// "session is locked" signal; screenLocked is just the request.
property bool screenLockSecure: false
// WRITER: LockScreen.qml
property bool screenLockContainsCharacters: false
// WRITER: LockScreen.qml
property bool screenUnlockFailed: false
// WRITER: ii translator module
property bool screenTranslatorOpen: false
// WRITER: ii session module
property bool sessionOpen: false
// WRITER: GlobalShortcut handler in this file
property bool superDown: false
// WRITER: GlobalShortcut handler in this file
property bool superReleaseMightTrigger: true
// WRITER: GlobalShortcut handler in this file
property real superPressTime: 0
// WRITER: GlobalShortcut handler in this file
property real superLastPressDuration: -1
// WRITER: GlobalShortcut handler in this file
property real superLastReleaseTime: 0
// WRITER: ii wallpaper selector module
property bool wallpaperSelectorOpen: false
// WRITER: ii workspace module
property bool workspaceShowNumbers: false
// Boot bloom overlay: true from shell start until the lockscreen surface is
// secure. It covers all of Hyprland's boot render, continuing the C splash's
// bloom animation. LockScreen.onSecureChanged clears it to reveal the lock.
// WRITER: LockScreen.qml true from shell start until the lock
// surface is secure. Covers Hyprland's boot render, continuing the
// C splash bloom animation. LockScreen.onSecureChanged clears it.
property bool bootBloomActive: true
// In fullscreen this is the only way the dock becomes visible:
// the navigation rail swipe sets/clears it. It deliberately has no timer
// or secondary state.
// WRITER: Dock.qml IPC (swipeUp/reveal) + navigation rail. The
// fullscreen dock toggle. Deliberately has no timer.
property bool dockRevealed: false
// Mission Control: the Souveraine process/task surface, raised by a
// triple swipe-up on the navigation rail. A first-class shell state in
// its own right (like overviewOpen) the rail only sets it; the
// MissionControl surface owns everything else. Deliberately distinct
// from overviewOpen (the app launcher/search) this is the running-work
// view, reached by escalating the same upward gesture past a single dock
// reveal.
// WRITER: navigation rail (triple swipe-up). The Souveraine
// process/task surface. Deliberately distinct from overviewOpen
// (app launcher/search) this is the running-work view.
property bool missionControlOpen: false
// A rail swipe-down on a visible dock dismisses it in ANY state
// including pinned and shown-on-empty-desktop, which dockRevealed alone
// can't reach (it only governs the fullscreen case). Swipe up clears it.
// WRITER: Dock.qml IPC (swipeDown). A rail swipe-down on a visible
// dock dismisses it in ANY state including pinned and
// shown-on-empty-desktop. Swipe up clears it.
property bool dockSuppressed: false
// Transient dock reveal: shows the dock for a few seconds and restores
// whatever state ruled before. Callers: the `osk pulseDock` IPC (glance
// at the dock without closing the keyboard) and the agent's harness.
// Restored 2026-07-16 the definition was lost in a refactor while its
// IPC caller in OnScreenKeyboard.qml survived, so pulseDock threw.
// WRITER: this file (pulseDockReveal) + OnScreenKeyboard.qml IPC.
// Transient dock reveal: shows for 3s and restores prior state.
// Restored 2026-07-16 definition was lost in a refactor while
// its IPC caller survived, so pulseDock threw.
property bool dockRevealPulse: false
function pulseDockReveal() {
root.dockRevealPulse = true;
@ -90,10 +124,9 @@ Singleton {
onTriggered: root.dockRevealPulse = false
}
// True while a dock icon drag is in flight. Set by DockAppButton's drag
// lifecycle, read by DockManifest's state checks so structural edits
// (pin/stack mutations) can't land mid-drag and rewrite dock.stacks out
// from under a commit.
// WRITER: DockAppButton drag lifecycle. True while a dock icon drag
// is in flight. Read by DockManifest's state checks so structural
// edits (pin/stack mutations) can't land mid-drag.
property bool dockDragInProgress: false
function superPressDuration() {

View file

@ -36,8 +36,34 @@ Singleton {
// Dimmed saved never a stale brightnessctl snapshot.
property bool displayDimmed: false
// Legal transitions. Each key maps to the set of states it may
// move to. Anything not in this map is a bug two event sources
// racing in the same frame, a stale timer firing after a lock, or
// a new code path that forgot to check preconditions.
//
// The graph:
// Active Dimmed LockRequested LockSecure Suspending Asleep Waking Active
// Any locked state can return to Active on unlock.
// Suspending is reachable from any pre-sleep state (logind is the authority).
readonly property var legalTransitions: ({
0: [1, 2, 5], // Active Dimmed, LockRequested, Suspending
1: [0, 2, 5], // Dimmed Active, LockRequested, Suspending
2: [0, 3, 5], // LockRequested Active, LockSecure, Suspending
3: [0, 5], // LockSecure Active, Suspending
4: [5, 6], // Suspending Asleep, Waking
5: [6], // Asleep Waking
6: [0], // Waking Active
})
function setState(next) {
if (root.state === next) return;
const allowed = root.legalTransitions[root.state];
if (allowed && allowed.indexOf(next) === -1) {
console.warn("[idle-coordinator] ILLEGAL transition "
+ root.state + " → " + next + " (ignored)");
return;
}
const prev = root.state;
root.state = next;
// Publish the coarse in-use bool the ii-base pollers gate on
// (quickshell-idle-power task 4). Waking counts as active so stats
@ -45,7 +71,7 @@ Singleton {
GlobalStates.displayActive =
(next === IdleCoordinator.Active || next === IdleCoordinator.Waking);
root.stateTransitioned(next);
console.log("[idle-coordinator] state=" + next);
console.log("[idle-coordinator] state=" + next + " (from=" + prev + ")");
if (next === IdleCoordinator.Dimmed) {
dimProc.action = "dim";
@ -72,7 +98,12 @@ Singleton {
}
function returnActive() {
if (root.lockRequested || root.lockSecure) return;
// Only return to Active from states that are legitimately
// "waiting for user input" Dimmed or Waking. Never from
// locked/sleeping states; those are guarded by the transition
// table, but this check makes the intent explicit.
if (root.state !== IdleCoordinator.Dimmed
&& root.state !== IdleCoordinator.Waking) return;
root.setState(IdleCoordinator.Active);
root.activeRequested();
}

View file

@ -6,27 +6,27 @@ pragma Singleton
// includes the hash of the previous entry, making the log tamper-evident:
// altering any past entry invalidates every subsequent hash.
//
// The log is JSONL (one JSON object per line). The hash chain uses Qt.md5(),
// which is not cryptographically strong (SHA256 would be preferred but is
// not available in QML). The tamper-evidence is advisory it detects
// casual modification, not a determined attacker with access to the file.
// A production deployment should replace this with a Process that calls
// sha256sum or a dedicated audit binary.
// The log is JSONL (one JSON object per line). The hash chain uses
// SHA-256 via the `sha256sum` binary. The tamper-evidence is advisory
// it detects casual modification, not a determined attacker with access
// to the file.
//
// Log location: ~/.local/share/souveraine/session-audit.jsonl
//
// Entry format:
// {
// "seq": 42,
// "prev": "md5-of-previous-entry",
// "prev": "sha256-of-previous-entry",
// "ts": 1234567890,
// "event": "lock-requested",
// "data": { ... },
// "hash": "md5-of-this-entry-without-hash-field"
// "hash": "sha256-of-this-entry-without-hash-field"
// }
//
// The hash is computed over the JSON string of the entry WITHOUT the hash
// field. This is: md5(JSON.stringify({seq, prev, ts, event, data})).
// field. This is: sha256(JSON.stringify({seq, prev, ts, event, data})).
// Computed by piping the entry JSON through sha256sum in the same shell
// command that appends it to the log, so the hash and write are atomic.
//
// Integration points:
// - GlobalStates: lock/unlock transitions
@ -66,6 +66,11 @@ Singleton {
}
// --- Append logic -------------------------------------------------------
// Computes SHA-256 and appends in one shell command so the hash and
// write are atomic. The entry JSON (without hash field) is piped to
// sha256sum; the final entry (with hash) is appended to the log, and
// the hex hash is emitted to stdout so the StdioCollector can update
// lastHash for the next entry in the chain.
function append(event, data) {
const entry = {
seq: root.nextSeq,
@ -74,22 +79,20 @@ Singleton {
event: event,
data: data || {}
};
// Compute hash over the entry WITHOUT the hash field.
const hashInput = JSON.stringify(entry);
const hash = Qt.md5(hashInput);
entry.hash = hash;
const escaped = _escape(hashInput);
// Append to the log file via printf >>. FileView.setText() would
// overwrite; we need append-only semantics. The Process runs
// synchronously enough for audit purposes if the shell is
// crashing, the last entry may be lost, but that is detectable
// from the hash chain gap on next startup.
const line = JSON.stringify(entry) + "\n";
appendProc.command = ["sh", "-c",
`printf '%s' '${_escape(line)}' >> ${root.auditPath}`];
appendProc.running = true;
// Shell: compute sha256, inject hash into the JSON via sed
// (available on all target devices), append to log, echo hash
// to stdout. The sed command inserts "hash":"<hex>" before the
// LAST closing brace (anchored with $), which is the JSON root.
const cmd =
`h=$(printf '%s' '${escaped}' | sha256sum | cut -d' ' -f1); ` +
`printf '%s\\n' '${escaped}' | sed "s/}$/,\\"hash\\":\\"$h\\"}/" >> ${root.auditPath}; ` +
`echo "$h"`;
appendProcHash.command = ["sh", "-c", cmd];
appendProcHash.running = true;
root.lastHash = hash;
root.nextSeq++;
console.log("[audit] " + event + " (seq=" + entry.seq + ")");
}
@ -100,7 +103,17 @@ Singleton {
}
Process {
id: appendProc
id: appendProcHash
stdout: StdioCollector {
onStreamFinished: {
const hash = text.trim();
if (hash.length === 64) {
root.lastHash = hash;
} else {
console.log("[audit] unexpected sha256 output: " + text);
}
}
}
onExited: (exitCode) => {
if (exitCode !== 0) {
console.log("[audit] append failed (exit " + exitCode + ")");
@ -215,6 +228,66 @@ Singleton {
}
}
// Forensic: device state transitions
// These entries capture the decision context at each state change.
// The Rust-side forensic log (forensic.jsonl) has the full sensor
// snapshots; this trail records the same events in the
// tamper-evident hash chain for cross-referencing.
// Brightness / idle coordinator errors.
Connections {
target: typeof IdleCoordinator !== "undefined" ? IdleCoordinator : null
function onStateTransitioned(state) {
const names = ["active", "dimmed", "lock-requested",
"lock-secure", "suspending", "asleep", "waking"];
root.append("device-state-transition", {
state: names[state] || String(state),
source: "idle-coordinator"
});
}
}
// DPMS / screen power errors (from blueline-screen-toggle or
// brightnessctl failures). These are the operational errors that
// the old code logged to console.log only.
function logDeviceError(component, action, error) {
root.append("device-error", {
component: component,
action: action,
error: error,
device_state: typeof IdleCoordinator !== "undefined"
? IdleCoordinator.state : -1,
screen_locked: typeof GlobalStates !== "undefined"
? GlobalStates.screenLocked : false,
screen_lock_secure: typeof GlobalStates !== "undefined"
? GlobalStates.screenLockSecure : false,
});
}
// Sensor input that affected device state (proximity, accel, etc.).
function logSensorInput(source, value, confidence, decision) {
root.append("sensor-input", {
source: source,
value: value,
confidence: confidence,
decision: decision,
device_state: typeof IdleCoordinator !== "undefined"
? IdleCoordinator.state : -1,
});
}
// Wake event what triggered the screen to turn on.
function logWakeEvent(trigger, details) {
root.append("wake-event", {
trigger: trigger,
details: details || {},
device_state: typeof IdleCoordinator !== "undefined"
? IdleCoordinator.state : -1,
proximity: typeof GlobalStates !== "undefined"
? GlobalStates.proximityNear : null,
});
}
Timer {
id: initLogTimer
interval: 0