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

@ -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.")
}
}
}