Watch
1
0
Fork
You've already forked souveraine
0

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:
Fimeg 2026-07-14 21:31:23 -04:00
commit ae9ef41bb1
3 changed files with 223 additions and 1 deletions

View file

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

View file

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

View 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);
}
}