Watch
1
0
Fork
You've already forked souveraine
0

polkit: add temporary FPC confirmation

This commit is contained in:
Fimeg 2026-08-10 20:46:28 -04:00
commit 658217e72e
7 changed files with 374 additions and 110 deletions

View file

@ -63,6 +63,7 @@ modules/souveraine/windowSheet/PowerMenu.qml souveraine/modules/souveraine/windo
modules/souveraine/windowSheet/PowerOptionRow.qml souveraine/modules/souveraine/windowSheet/PowerOptionRow.qml
modules/souveraine/windowSheet/qmldir souveraine/modules/souveraine/windowSheet/qmldir
services/Face.qml souveraine/services/Face.qml
services/FingerprintPreview.qml souveraine/services/FingerprintPreview.qml
services/ViewtopControl.qml souveraine/services/ViewtopControl.qml
services/ZoneTransition.qml souveraine/services/ZoneTransition.qml
services/Hyprsunset.qml souveraine/services/Hyprsunset.qml
@ -114,6 +115,7 @@ modules/settings/KeyboardConfig.qml souveraine/modules/settings/KeyboardConfig.q
modules/settings/IdleConfig.qml souveraine/modules/settings/IdleConfig.qml
modules/settings/SpeechConfig.qml souveraine/modules/settings/SpeechConfig.qml
modules/ii/polkit/Polkit.qml souveraine/modules/ii/polkit/Polkit.qml
modules/ii/polkit/PolkitContent.qml souveraine/modules/ii/polkit/PolkitContent.qml
modules/ii/dock/Dock.qml souveraine/modules/ii/dock/Dock.qml
modules/ii/dock/DockManifest.qml souveraine/modules/ii/dock/DockManifest.qml
modules/ii/dock/DockApps.qml souveraine/modules/ii/dock/DockApps.qml

View file

@ -481,13 +481,20 @@ Singleton {
// (background.wallpaperPath); a path pins the lock's own.
property string wallpaperPath: ""
property bool showLockedText: true
// A visible wiring exercise for the FPC1020 path. It is not
// biometric authentication: it may acknowledge a sensor pulse
// and a local hold, but it never unlocks or mints a grant.
// A visible wiring exercise for the FPC1020 path. It never
// unlocks the session; the separate Polkit setting below is
// the temporary, post-login-only factor.
property JsonObject fingerprintPreview: JsonObject {
property bool enabled: false
property int holdMs: 3000
}
// The temporary FPC factor belongs only to polkit-1. It cannot
// satisfy first login, disk unlock, sudo, SSH, or lock-screen
// PAM. The real souveraine-fpd match result will replace this
// raw-reader bridge without changing the user-facing surface.
property JsonObject fingerprintPolkit: JsonObject {
property bool enabled: true
}
// Souveraine-owned lock cards. Transport controls are
// ambient; media metadata remains personal by default.
property JsonObject content: JsonObject {

View file

@ -1,4 +1,5 @@
import qs
import qs.services
import qs.modules.common
import QtQuick
import Quickshell
@ -23,24 +24,20 @@ Scope {
property var targetAction: LockContext.ActionEnum.Unlock
property bool alsoInhibitIdle: false
// This is deliberately an integration preview, not a biometric factor.
// The FPC1020's KEY_WAKEUP edge proves only that something asserted the
// line; it does not prove a matched enrolled fingerprint. Until the
// attested souveraine-fpd path exists, neither a pulse nor a touch hold
// may reach unlocked() or StepUpAuth.
// FingerprintPreview owns the FPC pulse and hold state for both lock and
// step-up. This context only exposes it to the lock surface; it never
// decides whether a hold unlocks the session.
readonly property bool provisionalFingerprintEnabled:
Config.options?.lock?.fingerprintPreview?.enabled ?? false
FingerprintPreview.previewEnabled
readonly property int provisionalFingerprintHoldMs:
Config.options?.lock?.fingerprintPreview?.holdMs ?? 3000
property bool provisionalFingerprintHolding: false
property bool provisionalFingerprintConfirmed: false
property bool provisionalFingerprintPulseSeen: false
property real provisionalFingerprintHoldProgress: 0
property double provisionalFingerprintHoldStartedAt: 0
property string provisionalFingerprintPulseToken: ""
signal provisionalFingerprintPulse()
signal provisionalFingerprintPreviewConfirmed()
FingerprintPreview.holdMs
readonly property bool provisionalFingerprintHolding:
FingerprintPreview.holding && FingerprintPreview.activePurpose === "lock"
readonly property bool provisionalFingerprintConfirmed:
FingerprintPreview.confirmed && FingerprintPreview.confirmedPurpose === "lock"
readonly property bool provisionalFingerprintPulseSeen: FingerprintPreview.pulseSeen
readonly property real provisionalFingerprintHoldProgress:
FingerprintPreview.activePurpose === "lock" ? FingerprintPreview.holdProgress : 0
function resetTargetAction() {
root.targetAction = LockContext.ActionEnum.Unlock;
@ -63,107 +60,25 @@ Scope {
}
function beginProvisionalFingerprintHold() {
if (!root.provisionalFingerprintEnabled) return;
root.provisionalFingerprintConfirmed = false;
root.provisionalFingerprintHolding = true;
root.provisionalFingerprintHoldStartedAt = Date.now();
root.provisionalFingerprintHoldProgress = 0;
provisionalFingerprintHoldTimer.start();
FingerprintPreview.beginHold("lock");
}
function cancelProvisionalFingerprintHold() {
if (!root.provisionalFingerprintHolding) return;
root.provisionalFingerprintHolding = false;
root.provisionalFingerprintHoldProgress = 0;
provisionalFingerprintHoldTimer.stop();
FingerprintPreview.cancelHold("lock");
}
function confirmProvisionalFingerprintHold() {
if (!root.provisionalFingerprintHolding) return;
root.provisionalFingerprintHolding = false;
root.provisionalFingerprintHoldProgress = 1;
provisionalFingerprintHoldTimer.stop();
root.provisionalFingerprintConfirmed = true;
root.provisionalFingerprintPreviewConfirmed();
provisionalFingerprintConfirmTimer.restart();
console.log("[fingerprint-preview] hold confirmed; no authentication granted");
FingerprintPreview.confirmHold("lock");
}
// Called by the narrow future producer seam (`fingerprint.signal`). This
// records only that an input path reached the surface. Any same-user IPC
// caller can cause this today, so it is intentionally visual-only.
// Called by the diagnostic `fingerprint.signal` IPC seam. It reaches the
// same one-owner state as the root-owned FPC producer record.
function noteProvisionalFingerprintPulse() {
if (!root.provisionalFingerprintEnabled)
return { ok: false, code: "not_enabled", reason: "fingerprint preview is disabled" };
root.provisionalFingerprintPulseSeen = true;
root.provisionalFingerprintPulse();
provisionalFingerprintPulseTimer.restart();
console.log("[fingerprint-preview] pulse observed; no authentication granted");
return { ok: true, status: "observed" };
return FingerprintPreview.notePulse();
}
function resetProvisionalFingerprint() {
root.provisionalFingerprintHolding = false;
root.provisionalFingerprintConfirmed = false;
root.provisionalFingerprintPulseSeen = false;
root.provisionalFingerprintHoldProgress = 0;
provisionalFingerprintHoldTimer.stop();
provisionalFingerprintConfirmTimer.stop();
provisionalFingerprintPulseTimer.stop();
}
// blueline-fingerprintd is the only source that may write this root-owned
// status file. Its record says an FPC IRQ reached the daemon; it is not a
// match, and this listener has no authority beyond the preview pulse.
FileView {
id: provisionalFingerprintPulseFile
path: "/run/blueline-fingerprintd/preview-pulse"
watchChanges: true
printErrors: false
onFileChanged: reload()
onLoaded: {
try {
const record = JSON.parse(provisionalFingerprintPulseFile.text());
const token = `${record.sequence}:${record.at_ms}`;
if (token === root.provisionalFingerprintPulseToken) return;
root.provisionalFingerprintPulseToken = token;
// Do not replay an old event merely because the lock surface
// appeared after it. A new reader assertion has a fresh wall
// timestamp and reaches the same visual-only method as the IPC
// diagnostic seam.
const ageMs = Date.now() - Number(record.at_ms);
if (Number(record.sequence) > 0 && ageMs >= 0 && ageMs < 5000)
root.noteProvisionalFingerprintPulse();
} catch (error) {
console.warn("[fingerprint-preview] invalid FPC pulse record:", error);
}
}
}
Timer {
id: provisionalFingerprintHoldTimer
interval: 50
repeat: true
onTriggered: {
const elapsed = Date.now() - root.provisionalFingerprintHoldStartedAt;
root.provisionalFingerprintHoldProgress = Math.min(1,
elapsed / Math.max(1, root.provisionalFingerprintHoldMs));
if (root.provisionalFingerprintHoldProgress >= 1)
root.confirmProvisionalFingerprintHold();
}
}
Timer {
id: provisionalFingerprintConfirmTimer
interval: 3500
onTriggered: root.provisionalFingerprintConfirmed = false
}
Timer {
id: provisionalFingerprintPulseTimer
interval: 3500
onTriggered: root.provisionalFingerprintPulseSeen = false
FingerprintPreview.reset("lock");
}
Timer {

View file

@ -0,0 +1,193 @@
// Phone Polkit content with a first-class FPC1020 temporary factor.
//
// The PAM module owns acceptance. This surface only recognizes its prompt,
// waits for a pulse that came from blueline-fingerprintd (not generic wake
// input), and submits the blank PAM response after the visible confirmation
// interval. The normal PIN/password conversation stays intact as fallback.
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Widgets
import qs.services
import qs.modules.common
import qs.modules.common.widgets
Item {
id: root
readonly property bool usePasswordChars: !PolkitService.flow?.responseVisible ?? true
readonly property bool fpcPrompt:
FingerprintPreview.polkitEnabled
&& PolkitService.cleanPrompt === "Touch and hold the fingerprint reader"
Keys.onPressed: event => {
if (event.key === Qt.Key_Escape)
PolkitService.cancel();
}
function submitPassword() {
PolkitService.submit(inputField.text);
}
function usePinInstead() {
// An empty response tells pam_souveraine_fpc to decline. PAM then
// reaches the ordinary system-auth conversation, where this same
// window shows the normal password field.
PolkitService.submit("");
}
Connections {
target: PolkitService
function onInteractionAvailableChanged() {
if (!PolkitService.interactionAvailable || root.fpcPrompt)
return;
inputField.text = "";
inputField.forceActiveFocus();
}
}
Connections {
target: FingerprintPreview
function onPulseObserved() {
if (PolkitService.active && PolkitService.interactionAvailable && root.fpcPrompt)
FingerprintPreview.beginHold("polkit");
}
function onHoldConfirmed(purpose) {
if (purpose === "polkit" && root.fpcPrompt && PolkitService.interactionAvailable)
PolkitService.submit("");
}
}
Rectangle {
anchors.fill: parent
color: Appearance.colors.colScrim
opacity: 0
Component.onCompleted: opacity = 1
Behavior on opacity {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
}
WindowDialog {
anchors.centerIn: parent
backgroundWidth: 450
show: false
Component.onCompleted: show = true
MaterialSymbol {
Layout.alignment: Qt.AlignHCenter
iconSize: 26
text: root.fpcPrompt ? "fingerprint" : "security"
color: Appearance.colors.colSecondary
}
WindowDialogTitle {
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
text: Translation.tr("Authentication")
}
WindowDialogParagraph {
Layout.fillWidth: true
horizontalAlignment: Text.AlignLeft
text: PolkitService.cleanMessage
}
Item {
Layout.fillWidth: true
visible: root.fpcPrompt
implicitHeight: visible ? 104 : 0
Rectangle {
anchors.fill: parent
radius: Appearance.rounding.normal
color: FingerprintPreview.confirmedPurpose === "polkit"
? Appearance.colors.colPrimary : "#2a000000"
border.width: FingerprintPreview.pulseSeen ? 2 : 1
border.color: FingerprintPreview.pulseSeen
? Appearance.colors.colPrimary : "#66ffffff"
}
Rectangle {
anchors.left: parent.left
anchors.bottom: parent.bottom
width: parent.width * (FingerprintPreview.activePurpose === "polkit"
? FingerprintPreview.holdProgress : 0)
height: 3
radius: 2
color: Appearance.colors.colPrimary
}
ColumnLayout {
anchors.centerIn: parent
spacing: 4
MaterialSymbol {
Layout.alignment: Qt.AlignHCenter
text: "fingerprint"
iconSize: 32
color: FingerprintPreview.confirmedPurpose === "polkit"
? Appearance.colors.colOnPrimary : Appearance.colors.colOnLayer1
}
StyledText {
Layout.alignment: Qt.AlignHCenter
text: FingerprintPreview.activePurpose === "polkit"
? Translation.tr("Reader contact received — confirming")
: Translation.tr("Touch and hold the fingerprint reader")
color: FingerprintPreview.confirmedPurpose === "polkit"
? Appearance.colors.colOnPrimary : Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
}
StyledText {
Layout.alignment: Qt.AlignHCenter
visible: FingerprintPreview.activePurpose === "polkit"
text: Translation.tr("%1 seconds").arg(Math.round(FingerprintPreview.holdMs / 1000))
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
}
}
}
MaterialTextField {
id: inputField
Layout.fillWidth: true
visible: !root.fpcPrompt
focus: visible
enabled: PolkitService.interactionAvailable
placeholderText: PolkitService.cleanPrompt
echoMode: root.usePasswordChars ? TextInput.Password : TextInput.Normal
onAccepted: root.submitPassword()
Keys.onPressed: event => {
if (event.key === Qt.Key_Escape)
PolkitService.cancel();
}
}
WindowDialogButtonRow {
Layout.bottomMargin: 10
Item { Layout.fillWidth: true }
DialogButton {
buttonText: Translation.tr("Cancel")
onClicked: PolkitService.cancel()
}
DialogButton {
visible: root.fpcPrompt
enabled: PolkitService.interactionAvailable
buttonText: Translation.tr("Use PIN instead")
onClicked: root.usePinInstead()
}
DialogButton {
visible: !root.fpcPrompt
enabled: PolkitService.interactionAvailable
buttonText: Translation.tr("OK")
onClicked: root.submitPassword()
}
}
}
onFpcPromptChanged: {
if (!fpcPrompt)
FingerprintPreview.reset("polkit");
}
}

View file

@ -289,7 +289,7 @@ ContentPage {
checked: Config.options.lock.fingerprintPreview.enabled
onCheckedChanged: Config.options.lock.fingerprintPreview.enabled = checked
StyledToolTip {
text: Translation.tr("Shows a three-second hold test on the lock screen. It never unlocks the device or grants step-up; it is only the UI and sensor-routing preview.")
text: Translation.tr("Shows a three-second hold test on the lock screen. It never unlocks the device; post-login Polkit is configured separately below.")
}
}
@ -303,7 +303,17 @@ ContentPage {
enabled: Config.options.lock.fingerprintPreview.enabled
onValueChanged: Config.options.lock.fingerprintPreview.holdMs = value * 1000
StyledToolTip {
text: Translation.tr("This controls only the visible hold exercise. A real fingerprint match must arrive through the attested FPC daemon before it can authenticate anything.")
text: Translation.tr("This controls the visible lock-screen exercise only. It does not change the temporary post-login Polkit factor.")
}
}
ConfigSwitch {
buttonIcon: "admin_panel_settings"
text: Translation.tr("Allow temporary FPC confirmation for Polkit")
checked: Config.options.lock.fingerprintPolkit.enabled
onCheckedChanged: Config.options.lock.fingerprintPolkit.enabled = checked
StyledToolTip {
text: Translation.tr("After PIN login, user-facing Polkit prompts may accept a fresh reader assertion after the visible confirmation interval. It never unlocks the session or replaces first-login PIN.")
}
}
}

View file

@ -0,0 +1,136 @@
// One owner for the temporary FPC1020 interaction path.
//
// The privileged reader daemon publishes only an IRQ pulse. Until the
// match-on-chip daemon exists, a user-visible hold is the configured temporary
// confirmation. The Polkit surface may submit the configured temporary PAM
// factor after a completed hold; it is never a lock-screen unlock path.
pragma Singleton
import QtQuick
import Quickshell.Io
import qs.modules.common
QtObject {
id: root
readonly property bool previewEnabled:
Config.options?.lock?.fingerprintPreview?.enabled ?? false
readonly property bool polkitEnabled:
Config.options?.lock?.fingerprintPolkit?.enabled ?? true
readonly property bool enabled: previewEnabled || polkitEnabled
readonly property int holdMs:
Config.options?.lock?.fingerprintPreview?.holdMs ?? 3000
property bool holding: false
property bool confirmed: false
property bool pulseSeen: false
property real holdProgress: 0
property double holdStartedAt: 0
property string activePurpose: ""
property string confirmedPurpose: ""
property string pulseToken: ""
signal pulseObserved()
signal holdConfirmed(string purpose)
function beginHold(purpose) {
if (!root.enabled || !purpose) return false;
root.confirmed = false;
root.confirmedPurpose = "";
root.activePurpose = purpose;
root.holding = true;
root.holdStartedAt = Date.now();
root.holdProgress = 0;
holdTimer.start();
return true;
}
function cancelHold(purpose = "") {
if (purpose && root.activePurpose !== purpose) return;
root.holding = false;
root.holdProgress = 0;
root.activePurpose = "";
holdTimer.stop();
}
function confirmHold(purpose = "") {
if (!root.holding || (purpose && root.activePurpose !== purpose)) return;
const confirmedPurpose = root.activePurpose;
root.holding = false;
root.holdProgress = 1;
root.activePurpose = "";
holdTimer.stop();
root.confirmed = true;
root.confirmedPurpose = confirmedPurpose;
root.holdConfirmed(confirmedPurpose);
confirmTimer.restart();
}
// The diagnostic IPC and the reader file meet here. Neither can unlock a
// session; Polkit alone may use the explicit temporary confirmation path.
function notePulse() {
if (!root.enabled)
return { ok: false, code: "not_enabled", reason: "fingerprint preview is disabled" };
root.pulseSeen = true;
root.pulseObserved();
pulseTimer.restart();
return { ok: true, status: "observed" };
}
function reset(purpose = "") {
if (purpose && root.activePurpose !== purpose && root.confirmedPurpose !== purpose)
return;
root.cancelHold();
root.confirmed = false;
root.confirmedPurpose = "";
root.pulseSeen = false;
confirmTimer.stop();
pulseTimer.stop();
}
FileView {
id: pulseFile
path: "/run/blueline-fingerprintd/preview-pulse"
watchChanges: true
printErrors: false
onFileChanged: reload()
onLoaded: {
try {
const record = JSON.parse(pulseFile.text());
const token = `${record.sequence}:${record.at_ms}`;
if (token === root.pulseToken) return;
root.pulseToken = token;
const ageMs = Date.now() - Number(record.at_ms);
if (Number(record.sequence) > 0 && ageMs >= 0 && ageMs < 5000)
root.notePulse();
} catch (error) {
console.warn("[fingerprint-preview] invalid FPC pulse record:", error);
}
}
}
Timer {
id: holdTimer
interval: 50
repeat: true
onTriggered: {
const elapsed = Date.now() - root.holdStartedAt;
root.holdProgress = Math.min(1, elapsed / Math.max(1, root.holdMs));
if (root.holdProgress >= 1) root.confirmHold();
}
}
Timer {
id: confirmTimer
interval: 3500
onTriggered: {
root.confirmed = false;
root.confirmedPurpose = "";
}
}
Timer {
id: pulseTimer
interval: 3500
onTriggered: root.pulseSeen = false
}
}

View file

@ -54,6 +54,7 @@ singleton SessiondBridge 1.0 SessiondBridge.qml
singleton SessiondPolicy 1.0 SessiondPolicy.qml
singleton SongRec 1.0 SongRec.qml
singleton Face 1.0 Face.qml
singleton FingerprintPreview 1.0 FingerprintPreview.qml
singleton Souveraine 1.0 Souveraine.qml
singleton Speech 1.0 Speech.qml
singleton StepUpAuth 1.0 StepUpAuth.qml