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:
parent
0b13e2d347
commit
b4b30b124d
10 changed files with 1284 additions and 65 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue