hash-chained audit trail for session transitions
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
This commit is contained in:
parent
377b82999d
commit
ae9ef41bb1
3 changed files with 223 additions and 1 deletions
|
|
@ -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 |
|
| Lock screen power actions | Lock surface | ambient | Config.lock.security.requirePasswordToPower | enforced |
|
||||||
| Idle state transition | IdleCoordinator | ambient | nativeEnabled config | enforced |
|
| Idle state transition | IdleCoordinator | ambient | nativeEnabled config | enforced |
|
||||||
| Sleep/suspend transition | SessionEvents | ambient | delay inhibitor + WlSessionLock.secure | 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)
|
## 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 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 |
|
| 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) |
|
| Boot-time IPC audit | Shell startup | ambient | Done (log in Session.qml) |
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ services/WallpaperAssets.qml souveraine/services/WallpaperAssets.qml
|
||||||
services/ConflictKiller.qml souveraine/services/ConflictKiller.qml
|
services/ConflictKiller.qml souveraine/services/ConflictKiller.qml
|
||||||
services/SessionEvents.qml souveraine/services/SessionEvents.qml
|
services/SessionEvents.qml souveraine/services/SessionEvents.qml
|
||||||
services/StepUpAuth.qml souveraine/services/StepUpAuth.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/SidebarLeft.qml souveraine/modules/ii/sidebarLeft/SidebarLeft.qml
|
||||||
modules/ii/sidebarLeft/AiChat.qml souveraine/modules/ii/sidebarLeft/AiChat.qml
|
modules/ii/sidebarLeft/AiChat.qml souveraine/modules/ii/sidebarLeft/AiChat.qml
|
||||||
modules/ii/sidebarRight/SidebarRight.qml souveraine/modules/ii/sidebarRight/SidebarRight.qml
|
modules/ii/sidebarRight/SidebarRight.qml souveraine/modules/ii/sidebarRight/SidebarRight.qml
|
||||||
|
|
|
||||||
220
surfaces/quickshell/services/SessionAudit.qml
Normal file
220
surfaces/quickshell/services/SessionAudit.qml
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue