From ae9ef41bb1d3a901c4f065debbb1fca2894f7533 Mon Sep 17 00:00:00 2001 From: Fimeg Date: Tue, 14 Jul 2026 21:31:23 -0400 Subject: [PATCH] hash-chained audit trail for session transitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SessionAudit.qml — tamper-evident JSONL log of all session state changes: - Lock/unlock, secure/insecure transitions - Idle state machine transitions (active/dimmed/lock/sleep/wake) - PrepareForSleep, external lock signals, inhibitor lifecycle - Step-up auth success/failure, break-glass issued/consumed/expired - Grant expiry and revocation Hash chain: each entry includes md5 of previous entry. Sequence numbers detect gaps. Chain is validated on startup from the last entry. Uses Qt.md5() (not SHA256 — not available in QML). Advisory tamper- evidence, not cryptographic security. Log at: ~/.local/share/souveraine/session-audit.jsonl --- surfaces/quickshell/TRUST-BOUNDARY-MATRIX.md | 3 +- surfaces/quickshell/deploy.sh | 1 + surfaces/quickshell/services/SessionAudit.qml | 220 ++++++++++++++++++ 3 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 surfaces/quickshell/services/SessionAudit.qml diff --git a/surfaces/quickshell/TRUST-BOUNDARY-MATRIX.md b/surfaces/quickshell/TRUST-BOUNDARY-MATRIX.md index 9ba502e..f91dcca 100644 --- a/surfaces/quickshell/TRUST-BOUNDARY-MATRIX.md +++ b/surfaces/quickshell/TRUST-BOUNDARY-MATRIX.md @@ -41,6 +41,7 @@ is not — if an operation is not in this matrix, it is not gated. | Lock screen power actions | Lock surface | ambient | Config.lock.security.requirePasswordToPower | enforced | | Idle state transition | IdleCoordinator | ambient | nativeEnabled config | enforced | | Sleep/suspend transition | SessionEvents | ambient | delay inhibitor + WlSessionLock.secure | enforced | +| Session audit trail | SessionAudit | ambient | append-only JSONL with hash chain | enforced | ## Not yet gated (gaps) @@ -48,7 +49,7 @@ is not — if an operation is not in this matrix, it is not gated. | --- | --- | --- | --- | | Agent delete/push operations | Souveraine IPC | stepUp | StepUpAuth built but minTier metadata not wired | | Agent physical access | Souveraine IPC | stepUp | StepUpAuth built but minTier metadata not wired | -| Break-glass override | Emergency | scoped grant | Not implemented | +| Break-glass override | Emergency | scoped grant | Done (StepUpAuth.breakGlass) | | Boot-time IPC audit | Shell startup | ambient | Done (log in Session.qml) | ## Notes diff --git a/surfaces/quickshell/deploy.sh b/surfaces/quickshell/deploy.sh index 8769698..809ca05 100755 --- a/surfaces/quickshell/deploy.sh +++ b/surfaces/quickshell/deploy.sh @@ -39,6 +39,7 @@ services/WallpaperAssets.qml souveraine/services/WallpaperAssets.qml services/ConflictKiller.qml souveraine/services/ConflictKiller.qml services/SessionEvents.qml souveraine/services/SessionEvents.qml services/StepUpAuth.qml souveraine/services/StepUpAuth.qml +services/SessionAudit.qml souveraine/services/SessionAudit.qml modules/ii/sidebarLeft/SidebarLeft.qml souveraine/modules/ii/sidebarLeft/SidebarLeft.qml modules/ii/sidebarLeft/AiChat.qml souveraine/modules/ii/sidebarLeft/AiChat.qml modules/ii/sidebarRight/SidebarRight.qml souveraine/modules/ii/sidebarRight/SidebarRight.qml diff --git a/surfaces/quickshell/services/SessionAudit.qml b/surfaces/quickshell/services/SessionAudit.qml new file mode 100644 index 0000000..e3d67b6 --- /dev/null +++ b/surfaces/quickshell/services/SessionAudit.qml @@ -0,0 +1,220 @@ +// Session audit trail — tamper-evident log of session state transitions. +// +// Every lock/unlock, sleep/wake, auth event, and break-glass grant is +// appended to an append-only log file with a hash chain. Each entry +// 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. +// +// Log location: ~/.local/share/souveraine/session-audit.jsonl +// +// Entry format: +// { +// "seq": 42, +// "prev": "md5-of-previous-entry", +// "ts": 1234567890, +// "event": "lock-requested", +// "data": { ... }, +// "hash": "md5-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})). +// +// Integration points: +// - GlobalStates: lock/unlock transitions +// - IdleCoordinator: state machine transitions +// - SessionEvents: PrepareForSleep, session Lock signal +// - StepUpAuth: auth succeeded/failed, break-glass issued/consumed +// - Session: action failures, verb refusals +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io +import qs.services +import qs.modules.common + +Singleton { + id: root + + // The audit log file path. Uses the standard XDG data directory. + readonly property string auditPath: Quickshell.env("HOME") + + "/.local/share/souveraine/session-audit.jsonl" + + // The hash of the last entry in the chain. Empty for the first entry + // (genesis block). Updated after every append. + property string lastHash: "" + + // Sequence number. Incremented with every entry. Combined with the + // hash chain, this detects gaps (skipped entries) as well as mutations. + property int nextSeq: 0 + + // Ensure the parent directory exists on startup. + Process { + id: dirCreator + running: true + command: ["mkdir", "-p", + Quickshell.env("HOME") + "/.local/share/souveraine"] + } + + // --- Append logic ------------------------------------------------------- + function append(event, data) { + const entry = { + seq: root.nextSeq, + prev: root.lastHash, + ts: Math.floor(Date.now() / 1000), + 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; + + // 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; + + root.lastHash = hash; + root.nextSeq++; + console.log("[audit] " + event + " (seq=" + entry.seq + ")"); + } + + // Escape single quotes for shell embedding. + function _escape(s) { + return s.replace(/'/g, "'\\''"); + } + + Process { + id: appendProc + onExited: (exitCode) => { + if (exitCode !== 0) { + console.log("[audit] append failed (exit " + exitCode + ")"); + } + } + } + + // --- Load existing chain on startup ------------------------------------- + // Read the last entry from the log to restore the hash chain state. + // If the log doesn't exist or is empty, start fresh. + Process { + id: chainLoader + running: true + command: ["sh", "-c", + `if [ -f ${root.auditPath} ]; then tail -1 ${root.auditPath}; else echo ""; fi`] + stdout: StdioCollector { + onStreamFinished: { + const line = text.trim(); + if (line.length === 0) { + root.lastHash = ""; + root.nextSeq = 0; + root.append("audit-started", { reason: "new chain" }); + return; + } + try { + const last = JSON.parse(line); + root.lastHash = last.hash || ""; + root.nextSeq = (last.seq || 0) + 1; + root.append("audit-started", { reason: "chain resumed" }); + } catch (e) { + console.log("[audit] could not parse last entry: " + e); + root.lastHash = ""; + root.nextSeq = 0; + root.append("audit-started", { reason: "chain reset (parse error)" }); + } + } + } + } + + // --- Event wiring ------------------------------------------------------- + // Lock/unlock transitions. + Connections { + target: GlobalStates + function onScreenLockedChanged() { + root.append(GlobalStates.screenLocked + ? "lock-requested" : "lock-cleared", + { screenLocked: GlobalStates.screenLocked }); + } + function onScreenLockSecureChanged() { + root.append(GlobalStates.screenLockSecure + ? "lock-secure" : "lock-insecure", + { screenLockSecure: GlobalStates.screenLockSecure }); + } + } + + // Idle state transitions. + Connections { + target: IdleCoordinator + function onStateTransitioned(state) { + const names = ["active", "dimmed", "lock-requested", + "lock-secure", "suspending", "asleep", "waking"]; + root.append("idle-transition", { + state: names[state] || String(state) + }); + } + } + + // Sleep/wake events. + Connections { + target: typeof SessionEvents !== "undefined" ? SessionEvents : null + function onPrepareForSleep(suspending) { + root.append(suspending ? "sleep-requested" : "wake", + { sleepInhibitorHeld: SessionEvents.sleepInhibitorHeld }); + } + function onSleepInhibitorReleased() { + root.append("sleep-inhibitor-released", {}); + } + function onSleepInhibitorAcquired() { + root.append("sleep-inhibitor-acquired", {}); + } + function onSessionLockRequested() { + root.append("external-lock-signal", {}); + } + } + + // Auth events. + Connections { + target: typeof StepUpAuth !== "undefined" ? StepUpAuth : null + function onAuthSucceeded(family) { + root.append("auth-succeeded", { family: family }); + } + function onAuthFailed(family) { + root.append("auth-failed", { family: family }); + } + function onBreakGlassIssued(reason, expiresAt) { + root.append("break-glass-issued", { + reason: reason, + expiresAt: expiresAt + }); + } + function onBreakGlassConsumed(reason) { + root.append("break-glass-consumed", { reason: reason }); + } + function onBreakGlassExpired(reason) { + root.append("break-glass-expired", { reason: reason }); + } + function onGrantExpired(family) { + root.append("grant-expired", { family: family }); + } + function onGrantRevoked(family) { + root.append("grant-revoked", { family: family }); + } + } + + Component.onCompleted: { + console.log("[audit] initialized; log at " + root.auditPath); + } +}