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

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