Give identity its due: native enrollment, honest deletion, and sign-in that stays home

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 18:55:38 -04:00
parent 5a0643357f
commit 4ec8bd94d9
25 changed files with 4713 additions and 407 deletions
+1 -1
View File
@@ -151,7 +151,7 @@ docs/ Settings reference, and the design specs behind the work
## Tests ## Tests
171 of them, under `tests/`. Run the lot, or a subset by pattern: 172 of them, under `tests/`. Run the lot, or a subset by pattern:
```sh ```sh
panama test # everything panama test # everything
@@ -0,0 +1,126 @@
// Guided enrollment, in place, for as long as it takes.
//
// fprintd wants the same finger pressed several times from slightly different
// angles, and it says how many it still needs. That count is the whole content
// of this panel: a person mid-enrollment is looking at the reader, not the
// screen, and the one thing they come back to the screen for is whether it
// worked and how much is left.
//
// Nothing here loops. The bar advances when a touch is accepted and then holds,
// so a panel left open on a desktop that never got touched costs no frames --
// see the note about repainting animations in the Appearance page.
import QtQuick
import qs.config
Rectangle {
id: root
// Human label for the finger being enrolled, already resolved by the
// service so nothing here has to know fprintd's vocabulary.
property string finger: ""
property int stage: 0
property int total: 0
// Whatever the last stream line said, when it said anything worth showing --
// "enroll-retry-scan", a failure, a completion.
property string message: ""
signal cancelled
width: parent ? parent.width : 620
implicitHeight: body.implicitHeight + 28
radius: Theme.cardRadius
color: Theme.alpha(Theme.bgDark, 0.55)
border.width: 1
border.color: Theme.alpha(Theme.accent, 0.3)
Column {
id: body
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 14
spacing: 7
Text {
anchors.horizontalCenter: parent.horizontalCenter
// md-fingerprint
text: "\u{F0306}"
color: Theme.accent
font.family: Theme.fontMono
font.pixelSize: 38
}
Text {
width: parent.width
horizontalAlignment: Text.AlignHCenter
text: root.finger === ""
? "Touch the reader"
: "Touch the reader with your " + root.finger.toLowerCase()
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
wrapMode: Text.WordWrap
}
Text {
width: parent.width
horizontalAlignment: Text.AlignHCenter
text: root.total > 0
? "Lift and press again — " + root.stage + " of " + root.total + " touches"
: "Lift and press again"
color: Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
Item { width: 1; height: 3 }
Rectangle {
width: parent.width
height: 5
radius: 3
color: Theme.alpha(Theme.fg, 0.1)
border.width: 0
Rectangle {
width: root.total > 0
? parent.width * Math.max(0, Math.min(1, root.stage / root.total))
: 0
height: parent.height
radius: parent.radius
color: Theme.accent
border.width: 0
// One step per accepted touch, then still.
Behavior on width {
NumberAnimation { duration: Theme.durFast; easing.type: Easing.OutCubic }
}
}
}
Text {
width: parent.width
visible: root.message !== ""
horizontalAlignment: Text.AlignHCenter
text: root.message
color: Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
topPadding: 4
}
Item { width: 1; height: 3 }
SettingsButton {
anchors.horizontalCenter: parent.horizontalCenter
text: "Cancel"
onClicked: root.cancelled()
}
}
}
@@ -0,0 +1,107 @@
// A row whose trailing control is a free-text field that reports every
// keystroke.
//
// LiveFieldRow {
// label: "Username"
// detail: root.userNameProblem !== "" ? root.userNameProblem : "Lowercase…"
// invalid: root.userNameProblem !== ""
// onEdited: value => root.newUserName = value
// }
//
// TextFieldRow commits on Enter or blur, which is right when every commit is a
// privileged write that prompts. It is wrong for a form that has to say whether
// what is being typed is acceptable *while* it is being typed: the add-user
// form used TextFieldRow and its Create button stayed dark until the field lost
// focus, which reads as a broken button rather than as a field awaiting blur.
//
// So this reports twice. `edited` fires per keystroke, for validation and for
// state a page holds until a submit button is pressed. `accepted` fires on
// Enter or when focus leaves, for the values that go straight to a privileged
// write and must not prompt once per character.
import QtQuick
import qs.config
SettingRow {
id: root
property string text: ""
property string placeholder: ""
property bool enabled: true
// Draws the field as rejected. The page decides what invalid means; this
// only shows it, next to the detail line that says why.
property bool invalid: false
property int maximumLength: 0
signal edited(value: string)
signal accepted(value: string)
controlWidth: 220
function clear(): void {
input.text = "";
}
// An external change replaces what is shown, unless it would yank the field
// out from under someone mid-edit.
onTextChanged: if (!input.activeFocus && input.text !== root.text) input.text = root.text
Rectangle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: root.controlWidth
height: 32
radius: 9
color: Theme.alpha(Theme.fg, root.enabled ? 0.06 : 0.03)
border.width: input.activeFocus ? 2 : 1
border.color: root.invalid
? Theme.alpha(Theme.danger, 0.6)
: (input.activeFocus ? Theme.alpha(Theme.accent, 0.55) : Theme.alpha(Theme.fg, 0.1))
opacity: root.enabled ? 1 : 0.5
TextInput {
id: input
anchors.fill: parent
anchors.leftMargin: 11
anchors.rightMargin: 11
enabled: root.enabled
activeFocusOnTab: true
text: root.text
color: root.invalid ? Theme.danger : Theme.fg
selectByMouse: true
selectionColor: Theme.alpha(Theme.accent, 0.5)
selectedTextColor: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
verticalAlignment: TextInput.AlignVCenter
maximumLength: root.maximumLength > 0 ? root.maximumLength : 32767
clip: true
onTextEdited: root.edited(input.text)
onAccepted: if (input.text !== root.text) root.accepted(input.text)
// Unchanged text is not re-committed: a page whose `accepted`
// handler writes through polkit would otherwise prompt every time
// the field was tabbed past.
onActiveFocusChanged: {
if (input.activeFocus)
return;
if (input.text !== root.text)
root.accepted(input.text);
else
input.text = root.text;
}
Text {
anchors.fill: parent
visible: input.text === ""
text: root.placeholder
color: Theme.fgMuted
font: input.font
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
}
}
@@ -0,0 +1,247 @@
// One GNOME Online Accounts account: a line you can read, unfolding into the
// services it lends to the rest of the desktop.
//
// Built like InstalledAppRow rather than out of SettingRow, because the head
// carries the provider's own icon on the left and SettingRow's only icon slot
// is a mono glyph. What unfolds is small -- a toggle per service and a removal
// -- but it is per-account state, and four accounts each showing four toggles
// was most of a screen of switches with no way to tell which belonged to whom.
//
// Nothing here writes. Every control leaves through a signal so the page owns
// the service calls, which keeps the removal confirmation one piece of page
// state instead of something each row remembers for itself.
import QtQuick
import Quickshell
import qs.config
import qs.widgets
Column {
id: root
// { path, provider, providerName, providerIcons, identity, needsAttention,
// services: [{ key, label, enabled }] }
required property var account
property bool expanded: false
property bool busy: false
property bool confirmingRemoval: false
property bool divider: true
// The words on the re-authorisation button come from the page, because the
// page is what that button leads to: it is the one hand-off to GNOME's own
// dialog, and the sentence and the call belong together rather than one of
// them living out here. Left empty the button does not appear at all.
property string signInAction: ""
signal activated
signal serviceToggled(key: string, enabled: bool)
signal removeArmed
signal removeCancelled
signal removeConfirmed
signal signInAgainRequested
readonly property bool needsAttention: root.account?.needsAttention === true
readonly property string providerName: String(root.account?.providerName ?? "")
readonly property string identity: String(root.account?.identity ?? "")
readonly property var services: root.account?.services ?? []
// GOA hands back a preference-ordered chain; this walks it and takes the
// first name the active icon theme actually has. Taking the first blindly,
// or the last as a fallback, both looked right and were not: on Adwaita the
// tail of these chains ("mail", "goa-symbolic") does not exist, so a miss
// would have rendered nothing at all.
readonly property string iconName: {
const chain = root.account?.providerIcons ?? [];
for (const name of chain) {
if (Quickshell.iconPath(String(name), true) !== "")
return String(name);
}
return "";
}
width: parent ? parent.width : 620
spacing: 0
Item {
id: head
width: parent.width
implicitHeight: Math.max(56, copy.implicitHeight + 20)
Rectangle {
anchors.fill: parent
anchors.bottomMargin: 1
radius: 9
z: -1
visible: headHover.hovered
color: Theme.alpha(Theme.fg, 0.05)
border.width: 0
}
Rectangle {
id: tile
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: 32
height: 32
radius: 9
color: Theme.alpha(Theme.fg, 0.07)
border.width: 0
ThemedIcon {
anchors.centerIn: parent
icon: root.iconName
iconFallback: "avatar-default-symbolic"
tint: root.needsAttention ? Theme.warn : Theme.fg
size: 19
}
}
Column {
id: copy
anchors.left: tile.right
anchors.leftMargin: 12
anchors.right: trailing.left
anchors.rightMargin: 16
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: root.identity !== "" ? root.identity : root.providerName
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
elide: Text.ElideRight
}
Text {
width: parent.width
text: root.needsAttention
? root.providerName + " · sign-in expired, so this account has stopped syncing"
: (root.services.length === 0
? root.providerName
: root.providerName + " · "
+ root.services.filter(service => service.enabled)
.map(service => String(service.label)).join(", "))
color: root.needsAttention ? Theme.warn : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
Row {
id: trailing
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 9
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
visible: root.needsAttention && root.signInAction !== ""
text: root.signInAction
enabled: !root.busy
onClicked: root.signInAgainRequested()
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.expanded ? "▴" : "▾"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
Rectangle {
anchors.left: copy.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 1
visible: root.divider && !root.expanded
color: Theme.alpha(Theme.fg, 0.065)
}
HoverHandler {
id: headHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: root.activated()
}
}
Column {
id: expansion
width: parent.width
leftPadding: 44
visible: root.expanded
Repeater {
model: root.services
delegate: SettingRow {
id: serviceRow
required property var modelData
width: expansion.width - expansion.leftPadding
label: String(serviceRow.modelData.label ?? "")
detail: serviceRow.modelData.enabled
? "Applications using " + String(serviceRow.modelData.label ?? "").toLowerCase()
+ " can see this account"
: "Hidden from applications"
controlWidth: 54
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: serviceRow.modelData.enabled === true
// A toggle that moves while GOA is still answering the last
// one springs back, which reads as a broken switch.
enabled: !root.busy
onToggled: value => root.serviceToggled(
String(serviceRow.modelData.key ?? ""), value)
}
}
}
SettingRow {
width: expansion.width - expansion.leftPadding
label: root.confirmingRemoval ? "Remove this account?" : "Remove this account"
detail: root.confirmingRemoval
? "It signs out here and disappears from every application that was using it. Nothing on the provider's side is touched."
: "Signs out and removes it from every application that was using it"
controlWidth: 230
divider: false
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: root.confirmingRemoval ? "Keep it" : "Remove…"
enabled: !root.busy
onClicked: root.confirmingRemoval ? root.removeCancelled() : root.removeArmed()
}
SettingsButton {
visible: root.confirmingRemoval
text: "Remove"
tone: "danger"
enabled: !root.busy
onClicked: root.removeConfirmed()
}
}
}
}
}
@@ -2,17 +2,22 @@
// //
// GNOME's panel of the same name, except for one step. The accounts are GNOME // GNOME's panel of the same name, except for one step. The accounts are GNOME
// Online Accounts objects on D-Bus, the daemon already runs here, and listing, // Online Accounts objects on D-Bus, the daemon already runs here, and listing,
// per-service toggles and removal are all done natively on this page. // per-service toggles, removal and password-provider sign-in are all done
// natively on this page.
// //
// Signing in to an OAuth provider is handed to GNOME's panel, because the // The one hand-off left is OAuth. Google's and Microsoft's tokens are obtained
// credentials are OAuth tokens and the code that obtains them ships without a // by code inside libgoa-backend, which Fedora ships without a GIR binding, so
// scriptable binding. That is stated on the page rather than hidden behind a // no amount of D-Bus gets at it. That is stated on the page rather than hidden
// button that looks native, because a hand-off the user does not expect reads // behind a button that looks native, because a hand-off nobody expects reads as
// as a bug. // a bug -- and it goes through openProviderDialog() below, which is the only
// place in this file that names a GNOME panel.
// //
// Accounts needing attention are surfaced first. GOA knows when a token has // What Panama itself signs in to leads, because those two integrations are the
// expired and nothing outside its own panel says so, which is how an account // ones this desktop actually uses. GOA's accounts are for the applications.
// quietly stops syncing for weeks. //
// A failed write is a row. The page used to replace itself with "Accounts
// unavailable" whenever `lastError` was set, so one refused toggle hid every
// account on the machine.
import QtQuick import QtQuick
import Quickshell import Quickshell
@@ -22,118 +27,452 @@ import qs.services
SettingsPage { SettingsPage {
id: root id: root
objectName: "accounts"
title: "Online Accounts" title: "Online Accounts"
lede: OnlineAccounts.attentionCount > 0 lede: OnlineAccounts.attentionCount > 0
? OnlineAccounts.attentionCount + (OnlineAccounts.attentionCount === 1 ? OnlineAccounts.attentionCount + (OnlineAccounts.attentionCount === 1
? " account needs you to sign in again." ? " account needs you to sign in again."
: " accounts need you to sign in again.") : " accounts need you to sign in again.")
: "Accounts your mail, calendar, contacts, and files come from." : "What this desktop signs in to — Panama's own integrations first."
Component.onCompleted: if (!OnlineAccounts.scanned) OnlineAccounts.refresh() // "" | "nextcloud" | "imap"
property string openForm: ""
property string expandedAccount: ""
property string confirmingRemoval: ""
SettingsCard { property string ncServer: ""
visible: !OnlineAccounts.available property string ncUser: ""
title: "Accounts unavailable" property string ncPassword: ""
subtitle: OnlineAccounts.lastError
}
SettingsCard { property string imapEmail: ""
visible: OnlineAccounts.scanned && OnlineAccounts.available && OnlineAccounts.accounts.length === 0 property string imapHost: ""
title: "No accounts yet" property string smtpHost: ""
subtitle: "Adding one lets mail, calendar, contacts, and file managers share a single sign-in." property string imapUser: ""
property string imapPassword: ""
ActionRow { readonly property bool nextcloudReady: /^https?:\/\/.+/.test(root.ncServer.trim())
label: "Add an account" && root.ncUser.trim() !== "" && root.ncPassword !== ""
detail: "Google, Nextcloud, Microsoft Exchange, IMAP, WebDAV, and Kerberos"
action: "Add account" readonly property bool imapReady: /^[^@\s]+@[^@\s]+$/.test(root.imapEmail.trim())
divider: false && root.imapHost.trim() !== "" && root.smtpHost.trim() !== ""
onTriggered: SystemSettings.openGnomePanel("online-accounts") && root.imapUser.trim() !== "" && root.imapPassword !== ""
readonly property string homeSummary: {
if (!HomeAssistantConfig.configured)
return "Not set up";
if (HomeAssistant.phase === "ready") {
const named = (HomeAssistant.rooms ?? []).filter(room => String(room.name ?? "") !== "").length;
const host = root.hostOf(HomeAssistantConfig.url);
return named > 0
? host + " · " + named + " room" + (named === 1 ? "" : "s")
: host + " · " + HomeAssistant.discoveredCount + " lights";
} }
if (HomeAssistant.phase === "degraded")
return root.hostOf(HomeAssistantConfig.url) + " · answering slowly";
if (HomeAssistant.phase === "loading")
return "Asking " + root.hostOf(HomeAssistantConfig.url) + "…";
return root.hostOf(HomeAssistantConfig.url) + " · not answering";
} }
Repeater { readonly property string phoneName: KdeConnect.preferredPhone
model: OnlineAccounts.accounts ? String(KdeConnect.preferredPhone.name)
: "Phone"
SettingsCard { readonly property string phoneSummary: {
id: accountCard if (!KdeConnect.available)
required property var modelData return "Not set up";
if (!KdeConnect.preferredPhone)
return "KDE Connect · no phone paired yet";
const battery = KdeConnect.phoneBattery;
const where = KdeConnect.phoneReachable ? "nearby on Wi-Fi" : "not nearby";
return battery
? "KDE Connect · " + where + " · battery " + battery.charge + "%"
: "KDE Connect · " + where;
}
// GOA hands back a preference-ordered chain; this walks it and takes // Just the host, because a full URL in a subtitle is mostly scheme and
// the first name the active icon theme actually has. Taking the // trailing slash.
// first name blindly, or the last as a fallback, both looked right function hostOf(url: string): string {
// and were not: on Adwaita the tail of these chains ("mail", const text = String(url ?? "").trim();
// "goa-symbolic") does not exist, so a miss would have rendered if (text === "")
// nothing at all. return "Home Assistant";
icon: { return text.replace(/^https?:\/\//, "").replace(/\/.*$/, "");
const chain = accountCard.modelData.providerIcons ?? []; }
for (const name of chain) {
if (Quickshell.iconPath(String(name), true) !== "")
return String(name);
}
return "";
}
iconFallback: "avatar-default-symbolic"
title: accountCard.modelData.identity || accountCard.modelData.providerName // The single hand-off. Both the OAuth add row and a needs-attention row
subtitle: accountCard.modelData.needsAttention // arrive here, so this file names a GNOME panel exactly once -- see
? accountCard.modelData.providerName + " · sign-in expired, so this account has stopped syncing" // gnome-handoff-contract, which allows this one and nothing else.
: accountCard.modelData.providerName function openProviderDialog(): void {
SystemSettings.openGnomePanel("online-accounts");
}
// Only when it is true, because it is the one thing on this page function closeForms(): void {
// that needs acting on. root.openForm = "";
ActionRow { root.ncServer = "";
visible: accountCard.modelData.needsAttention root.ncUser = "";
label: "Sign in again" root.ncPassword = "";
detail: "Re-authorising uses the provider's own sign-in page, which GNOME's panel hosts" nextcloudPassword.clear();
action: "Sign in" root.imapEmail = "";
onTriggered: SystemSettings.openGnomePanel("online-accounts") root.imapHost = "";
} root.smtpHost = "";
root.imapUser = "";
root.imapPassword = "";
imapPasswordField.clear();
}
Repeater { // Every open, not only the first: accounts are added and removed by people
model: accountCard.modelData.services // -- sometimes in the very dialog this page hands them to -- so coming back
// to this page is exactly when the list is most likely to be stale.
Component.onCompleted: {
OnlineAccounts.refresh();
// The phone row reads a service that is only refreshed when the
// Control Center's phone shelf opens, which this page cannot assume
// has happened. Home Assistant refreshes itself.
KdeConnect.refresh();
}
SettingRow { // Two errors, kept apart because they mean different things. A refused
id: serviceRow // write is about the one thing somebody just did; a listing that failed is
required property var modelData // why the card below might be empty. Neither takes the page away.
required property int index TextRow {
visible: OnlineAccounts.writeError !== ""
label: "That did not go through"
detail: OnlineAccounts.writeError
value: ""
divider: false
}
label: serviceRow.modelData.label TextRow {
detail: serviceRow.modelData.enabled visible: OnlineAccounts.snapshotError !== ""
? "Applications using " + serviceRow.modelData.label.toLowerCase() + " can see this account" label: "The account list could not be read"
: "Hidden from applications" detail: OnlineAccounts.snapshotError
controlWidth: 48 value: ""
divider: false
}
SettingsToggle { // ── What Panama itself uses ──────────────────────────────────────────────
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter SettingsCard {
checked: serviceRow.modelData.enabled title: "This desktop"
onToggled: value => OnlineAccounts.setService( subtitle: "The two services Panama signs in to on its own behalf."
accountCard.modelData.path, serviceRow.modelData.key, value)
SettingRow {
// The same glyph the My Home page and its Control Center tile use.
icon: "\u{F0335}"
label: "Home Assistant"
detail: root.homeSummary
controlWidth: 210
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
Rectangle {
anchors.verticalCenter: parent.verticalCenter
visible: HomeAssistant.phase === "ready"
width: homeBadge.implicitWidth + 14
height: 17
radius: Theme.pillRadius
color: Theme.alpha(Theme.ok, 0.1)
border.width: 1
border.color: Theme.alpha(Theme.ok, 0.3)
Text {
id: homeBadge
anchors.centerIn: parent
text: "CONNECTED"
color: Theme.ok
font.family: Theme.fontFamily
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 2)
font.weight: Font.DemiBold
font.letterSpacing: 0.5
} }
} }
}
ActionRow { SettingsButton {
label: "Remove this account" text: HomeAssistantConfig.configured ? "Open Home" : "Set up"
detail: "Signs out and removes it from every application that was using it" onClicked: ShellState.openSettings("my-home")
action: "Remove" }
divider: false }
onTriggered: OnlineAccounts.remove(accountCard.modelData.path) }
SettingRow {
// md-cellphone
icon: "\u{F011C}"
label: root.phoneName
detail: root.phoneSummary
controlWidth: 210
divider: false
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
Rectangle {
anchors.verticalCenter: parent.verticalCenter
visible: KdeConnect.pairedCount > 0
width: phoneBadge.implicitWidth + 14
height: 17
radius: Theme.pillRadius
color: Theme.alpha(Theme.ok, 0.1)
border.width: 1
border.color: Theme.alpha(Theme.ok, 0.3)
Text {
id: phoneBadge
anchors.centerIn: parent
text: "PAIRED"
color: Theme.ok
font.family: Theme.fontFamily
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 2)
font.weight: Font.DemiBold
font.letterSpacing: 0.5
}
}
SettingsButton {
text: KdeConnect.pairedCount > 0 ? "Open Phone" : "Set up"
onClicked: ShellState.openSettings("phone")
}
} }
} }
} }
// ── Everything the applications use ──────────────────────────────────────
SettingsCard { SettingsCard {
visible: OnlineAccounts.available && OnlineAccounts.accounts.length > 0 title: "Accounts"
title: "Add another account" subtitle: "Signed in through GNOME Online Accounts, which is what mail, calendar, contacts and file managers read."
subtitle: "Signing in happens on the provider's own page. GNOME's panel hosts that step; everything after it is managed here."
TextRow {
visible: !OnlineAccounts.scanned
label: "Reading the account list…"
detail: ""
value: ""
divider: false
}
TextRow {
visible: OnlineAccounts.scanned && OnlineAccounts.accounts.length === 0
label: "No accounts yet"
detail: "Adding one lets mail, calendar, contacts and file managers share a single sign-in."
value: ""
divider: false
}
Repeater {
model: OnlineAccounts.accounts
delegate: OnlineAccountRow {
id: accountRow
required property var modelData
required property int index
account: accountRow.modelData
busy: OnlineAccounts.busy
// Said here rather than in the row, next to the hand-off it
// runs: GOA knows the token expired and nothing outside its own
// panel says so, which is how an account quietly stops syncing
// for weeks.
signInAction: "Sign in again"
expanded: root.expandedAccount === String(accountRow.modelData.path ?? "")
confirmingRemoval: root.confirmingRemoval === String(accountRow.modelData.path ?? "")
divider: accountRow.index < OnlineAccounts.accounts.length - 1
onActivated: {
const path = String(accountRow.modelData.path ?? "");
root.expandedAccount = accountRow.expanded ? "" : path;
root.confirmingRemoval = "";
}
onServiceToggled: (key, enabled) => OnlineAccounts.setService(
String(accountRow.modelData.path ?? ""), key, enabled)
onRemoveArmed: root.confirmingRemoval = String(accountRow.modelData.path ?? "")
onRemoveCancelled: root.confirmingRemoval = ""
onRemoveConfirmed: {
root.confirmingRemoval = "";
root.expandedAccount = "";
OnlineAccounts.remove(String(accountRow.modelData.path ?? ""));
}
onSignInAgainRequested: root.openProviderDialog()
}
}
ActionRow { ActionRow {
label: "Add an account" label: "Check again"
detail: "Google, Nextcloud, Microsoft Exchange, IMAP, WebDAV, and Kerberos" detail: "Reads the list back from GOA, for when an account changed somewhere else"
action: "Add account" action: OnlineAccounts.busy ? "Checking…" : "Refresh"
enabled: !OnlineAccounts.busy
divider: false divider: false
onTriggered: SystemSettings.openGnomePanel("online-accounts") onTriggered: OnlineAccounts.refresh()
}
}
// ── Adding one ───────────────────────────────────────────────────────────
SettingsCard {
title: "Add an account"
ActionRow {
label: "Nextcloud"
detail: "Server, username, password — signed in right here"
action: root.openForm === "nextcloud" ? "Cancel" : "Add…"
enabled: !OnlineAccounts.busy
divider: root.openForm === "nextcloud"
onTriggered: {
const wasOpen = root.openForm === "nextcloud";
root.closeForms();
if (!wasOpen)
root.openForm = "nextcloud";
}
}
Column {
width: parent.width
visible: root.openForm === "nextcloud"
LiveFieldRow {
width: parent.width
label: "Server"
detail: "The address you open Nextcloud at, scheme and all"
placeholder: "https://cloud.example.org"
text: root.ncServer
enabled: !OnlineAccounts.busy
onEdited: value => root.ncServer = value
}
LiveFieldRow {
width: parent.width
label: "Username"
placeholder: "you"
text: root.ncUser
enabled: !OnlineAccounts.busy
onEdited: value => root.ncUser = value
}
SecretFieldRow {
id: nextcloudPassword
width: parent.width
label: "Password"
detail: "Your password, or an app password — GOA signs in with Basic auth, so either works, and Nextcloud's Security settings issue an app password per device. It is handed over on stdin and never becomes a command-line argument."
placeholder: "Password or app password"
enabled: !OnlineAccounts.busy
onChanged: value => root.ncPassword = value
}
ActionRow {
width: parent.width
label: "Sign in to Nextcloud"
detail: root.nextcloudReady
? "Files, calendar and contacts arrive with the account"
: "A server address, a username and an app password are needed first"
action: OnlineAccounts.busy ? "Signing in…" : "Sign in"
enabled: root.nextcloudReady && !OnlineAccounts.busy
divider: false
onTriggered: {
OnlineAccounts.addNextcloud(root.ncServer.trim(), root.ncUser.trim(),
root.ncPassword);
root.closeForms();
}
}
}
ActionRow {
label: "Mail (IMAP & SMTP)"
detail: "Server details and password — native form"
action: root.openForm === "imap" ? "Cancel" : "Add…"
enabled: !OnlineAccounts.busy
divider: root.openForm === "imap"
onTriggered: {
const wasOpen = root.openForm === "imap";
root.closeForms();
if (!wasOpen)
root.openForm = "imap";
}
}
Column {
width: parent.width
visible: root.openForm === "imap"
LiveFieldRow {
width: parent.width
label: "Email address"
placeholder: "[email protected]"
text: root.imapEmail
enabled: !OnlineAccounts.busy
onEdited: value => root.imapEmail = value
}
LiveFieldRow {
width: parent.width
label: "Incoming server"
detail: "IMAP"
placeholder: "imap.example.com"
text: root.imapHost
enabled: !OnlineAccounts.busy
onEdited: value => root.imapHost = value
}
LiveFieldRow {
width: parent.width
label: "Outgoing server"
detail: "SMTP"
placeholder: "smtp.example.com"
text: root.smtpHost
enabled: !OnlineAccounts.busy
onEdited: value => root.smtpHost = value
}
LiveFieldRow {
width: parent.width
label: "Username"
detail: "Often the address again, sometimes not"
placeholder: "[email protected]"
text: root.imapUser
enabled: !OnlineAccounts.busy
onEdited: value => root.imapUser = value
}
SecretFieldRow {
id: imapPasswordField
width: parent.width
label: "Password"
detail: "Handed over on stdin, never as a command-line argument"
enabled: !OnlineAccounts.busy
onChanged: value => root.imapPassword = value
}
ActionRow {
width: parent.width
label: "Add this mail account"
detail: root.imapReady
? "GOA stores it and mail clients pick it up from there"
: "An address, both servers, a username and a password are needed first"
action: OnlineAccounts.busy ? "Adding…" : "Add account"
enabled: root.imapReady && !OnlineAccounts.busy
divider: false
onTriggered: {
OnlineAccounts.addImap(root.imapEmail.trim(), root.imapHost.trim(),
root.smtpHost.trim(), root.imapUser.trim(),
root.imapPassword);
root.closeForms();
}
}
}
ActionRow {
label: "Google or Microsoft"
detail: "OAuth needs the provider's own browser sign-in, which only GOA's dialog can run — the one thing this page hands off"
action: "Open GNOME dialog…"
divider: false
onTriggered: root.openProviderDialog()
} }
} }
} }
@@ -0,0 +1,77 @@
// Four segments saying how much a password has going for it.
//
// The score is computed here, from length and which character classes appear,
// and from nothing else. There is no dictionary, no breach list and no network
// call, so the row claims exactly what it can back up: it is headed "Strength"
// and says in its own detail line that it only measures shape. A meter that
// says "Strong" about a leaked password is worse than no meter, so this one
// never uses that word.
import QtQuick
import qs.config
SettingRow {
id: root
property string password: ""
readonly property int score: {
const value = root.password;
if (value === "")
return 0;
let classes = 0;
if (/[a-z]/.test(value))
classes += 1;
if (/[A-Z]/.test(value))
classes += 1;
if (/[0-9]/.test(value))
classes += 1;
if (/[^A-Za-z0-9]/.test(value))
classes += 1;
let points = Math.max(0, classes - 1);
if (value.length >= 8)
points += 1;
if (value.length >= 12)
points += 1;
if (value.length >= 16)
points += 1;
if (value.length < 8)
return 1;
return Math.max(1, Math.min(4, points));
}
readonly property color tone: {
if (root.score <= 1)
return Theme.danger;
if (root.score === 2)
return Theme.warn;
return Theme.ok;
}
label: "Strength"
detail: "Length and variety of characters, measured here — nothing is sent anywhere, and nothing is checked against known passwords"
controlWidth: 150
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 5
Repeater {
model: 4
delegate: Rectangle {
required property int index
width: 32
height: 5
radius: 2.5
color: index < root.score ? root.tone : Theme.alpha(Theme.fg, 0.12)
border.width: 0
}
}
}
}
@@ -0,0 +1,42 @@
// A row whose trailing control is a masked field that can be emptied on demand.
//
// PasswordRow already reports every keystroke, which is what comparing two
// entries as they are typed needs. What it cannot do is forget: its field is
// private, so a form that closed left the typed password sitting in a hidden
// TextInput for the rest of the session.
//
// This exposes `clear()` so the page that owns a form can empty it when the
// form succeeds or closes, which is the only difference between the two.
import QtQuick
import qs.config
SettingRow {
id: root
property string placeholder: "Password"
property bool enabled: true
signal changed(value: string)
signal accepted
controlWidth: 220
function clear(): void {
field.clear();
root.changed("");
}
PasswordField {
id: field
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: root.controlWidth
enabled: root.enabled
opacity: root.enabled ? 1 : 0.5
placeholder: root.placeholder
onTextChanged: root.changed(field.text)
onAccepted: root.accepted()
}
}
@@ -0,0 +1,116 @@
// The pictures already on the machine, offered before the file chooser.
//
// /usr/share/pixmaps/faces is what GNOME's Users panel shows first, and it is
// the answer for most people: a picture that is already the right shape, needs
// no cropping, and costs one click. The file chooser stays right beside it for
// everyone else, so choosing a photo is not a second-class path -- it is simply
// the one that needs a crop step afterwards.
//
// An empty gallery is a normal state on a machine with no faces package
// installed, and says so rather than rendering an empty strip.
import QtQuick
import Quickshell.Widgets
import qs.config
Column {
id: root
// [{ name, path }] -- whatever the helper found, in the order it found it.
property var avatars: []
property bool enabled: true
signal picked(path: string)
signal fileRequested
width: parent ? parent.width : 620
spacing: 10
bottomPadding: 6
Flow {
width: parent.width
spacing: 8
Repeater {
model: root.avatars
delegate: Item {
id: tile
required property var modelData
readonly property string path: String(tile.modelData?.path ?? "")
width: 46
height: 46
ClippingRectangle {
anchors.fill: parent
anchors.margins: 2
radius: width / 2
color: Theme.alpha(Theme.fg, 0.08)
Image {
anchors.fill: parent
source: tile.path === "" ? "" : "file://" + tile.path
fillMode: Image.PreserveAspectCrop
asynchronous: true
sourceSize.width: 96
sourceSize.height: 96
}
}
// The ring is its own item rather than a border on the clipping
// rectangle: that one clips its children and does not carry a
// border group of its own.
Rectangle {
anchors.fill: parent
radius: width / 2
color: "transparent"
border.width: tileHover.hovered ? 2 : 1
border.color: tileHover.hovered
? Theme.alpha(Theme.accent, 0.7)
: Theme.alpha(Theme.fg, 0.1)
}
HoverHandler {
id: tileHover
enabled: root.enabled
cursorShape: Qt.PointingHandCursor
}
TapHandler {
enabled: root.enabled && tile.path !== ""
onTapped: root.picked(tile.path)
}
}
}
// Wrapped so it sits on the tiles' centre line rather than on their top
// edge: Flow lays children out by their own height, and the button is
// fifteen pixels shorter than a picture.
Item {
width: chooseFile.implicitWidth
height: 46
SettingsButton {
id: chooseFile
anchors.verticalCenter: parent.verticalCenter
text: "Choose a file…"
enabled: root.enabled
onClicked: root.fileRequested()
}
}
}
Text {
width: parent.width
visible: root.avatars.length === 0
text: "No pictures are installed on this machine, so a file of your own is the only choice here."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
@@ -7,6 +7,10 @@
// //
// Everything privileged here prompts through polkit. A prompt that is dismissed // Everything privileged here prompts through polkit. A prompt that is dismissed
// is a normal outcome and says so plainly rather than reporting a failure. // is a normal outcome and says so plainly rather than reporting a failure.
//
// This page hands nothing to GNOME. Fingerprint enrollment was the last thing
// that did, and Panama drives fprintd's EnrollStart itself now -- see
// fingerprint-contract, which pins the absence.
import Quickshell import Quickshell
import Quickshell.Widgets import Quickshell.Widgets
@@ -19,20 +23,74 @@ SettingsPage {
objectName: "users" objectName: "users"
title: "Users" title: "Users"
lede: "Your account, and anyone else who signs in to this machine." lede: "Who this machine belongs to, and how it knows it's you."
// One panel open at a time: changing a password and adding an account are // One panel open at a time: changing a password and adding an account are
// both multi-field, and two open at once reads as a form with no shape. // both multi-field, and two open at once reads as a form with no shape.
property string openPanel: "" property string openPanel: ""
property bool pictureOpen: false
property string newPassword: "" property string newPassword: ""
property string confirmPassword: "" property string confirmPassword: ""
property string newUserName: "" property string newUserName: ""
property string newRealName: "" property string newRealName: ""
property bool newUserIsAdministrator: false property bool newUserIsAdministrator: false
// Which other account is unfolded, which one is one press from deletion,
// and what that deletion would do to their files. All three are page state
// rather than row state, so opening a second row puts the first one's armed
// Delete button away instead of leaving two of them armed at once.
property string expandedUser: ""
property string confirmingRemoval: "" property string confirmingRemoval: ""
// "keep" | "remove"
property string removalDisposition: "keep"
// Which enrolled finger is one press from being forgotten, and which one
// the next enrollment would take. The finger being enrolled right now is
// the service's business, not this page's.
property string confirmingFinger: ""
property string chosenFinger: "right-index-finger"
readonly property var me: UserAccounts.me readonly property var me: UserAccounts.me
// The service owns fprintd's vocabulary and subtracts what is already
// enrolled; this only pairs each one with its label for the picker.
readonly property var fingerOptions: Fingerprint.availableFingers.map(
finger => ({ value: finger, label: Fingerprint.fingerLabel(finger) }))
// Keeps the picker pointing at something that can still be enrolled: the
// default finger stops being offered the moment it is enrolled, and a
// picker showing a blank current value reads as broken.
onFingerOptionsChanged: {
if (root.fingerOptions.length === 0)
return;
if (!root.fingerOptions.some(option => option.value === root.chosenFinger))
root.chosenFinger = String(root.fingerOptions[0].value);
}
readonly property bool fingerprintStuck: Fingerprint.unlockFeatureEnabled
&& !Fingerprint.readerPresent
// What the last touch did, in words. fprintd's result strings are for
// programs; this is the one line someone looking up from the reader reads.
readonly property string enrollHint: {
if (Fingerprint.enrollPhase === "claiming")
return "Waking the reader…";
if (Fingerprint.enrollPhase === "failed")
return "The reader gave up on that one — start again when you are ready.";
const result = String(Fingerprint.enrollResult);
if (result === "" || result === "enroll-stage-passed")
return "";
if (/too-short|swipe-too-short/.test(result))
return "That touch was too brief — hold it there a moment longer.";
if (/remove-and-retry|retry-scan/.test(result))
return "Lift your finger all the way off, then press again.";
if (/centered|too-fast|duplicate/.test(result))
return "Land more of the pad on the reader and hold still.";
return "That touch did not take. Try again.";
}
readonly property string passwordProblem: { readonly property string passwordProblem: {
if (root.newPassword === "") if (root.newPassword === "")
return ""; return "";
@@ -46,15 +104,56 @@ SettingsPage {
readonly property bool passwordReady: root.newPassword.length >= 6 readonly property bool passwordReady: root.newPassword.length >= 6
&& root.newPassword === root.confirmPassword && root.newPassword === root.confirmPassword
// The helper's own rule, character for character -- panama-users compiles
// exactly this one, and a page that tests something slightly different
// accepts a name accountsservice then refuses, with nothing on screen to
// say why. The length cap is part of the expression rather than a separate
// check beside it, for the same reason.
//
// Read while the name is being typed, by both the sentence under the field
// and the Create button, so the two can never disagree.
readonly property bool newUserNameValid: /^[a-z_][a-z0-9_-]{0,31}$/.test(root.newUserName)
readonly property string userNameProblem: {
const value = root.newUserName;
if (value === "")
return "";
if (!root.newUserNameValid)
return "Start with a lowercase letter or _, then lowercase letters, digits, - and _ — up to 31 more.";
if (UserAccounts.users.some(user => String(user.userName ?? "") === value))
return "An account with that username already exists.";
return "";
}
function closePanels(): void { function closePanels(): void {
root.openPanel = ""; root.openPanel = "";
root.newPassword = ""; root.newPassword = "";
root.confirmPassword = ""; root.confirmPassword = "";
newPasswordField.clear();
confirmPasswordField.clear();
root.newUserName = ""; root.newUserName = "";
root.newRealName = ""; root.newRealName = "";
root.newUserIsAdministrator = false; root.newUserIsAdministrator = false;
} }
// accountsservice reports the last session start in seconds; a helper that
// hands back milliseconds is taken at its word rather than read as a date
// fifty thousand years from now.
function signInSummary(user: var): string {
const raw = Number(user?.loginTime ?? 0);
if (!Number.isFinite(raw) || raw <= 0)
return "has not signed in yet";
const when = new Date((raw > 1e12 ? raw / 1000 : raw) * 1000);
const days = Math.floor((Date.now() - when.getTime()) / 86400000);
if (days <= 0)
return "last signed in today";
if (days === 1)
return "last signed in yesterday";
if (days < 30)
return "last signed in " + days + " days ago";
return "last signed in " + when.toLocaleDateString(Qt.locale(), Locale.ShortFormat);
}
Component.onCompleted: { Component.onCompleted: {
UserAccounts.refresh(); UserAccounts.refresh();
// Probing fprintd bus-activates it, so it waits for the page rather // Probing fprintd bus-activates it, so it waits for the page rather
@@ -62,9 +161,11 @@ SettingsPage {
Fingerprint.refresh(); Fingerprint.refresh();
} }
// A write that failed is a row, not a page. Everything below stays usable,
// because the thing that refused is almost never the thing being read.
TextRow { TextRow {
visible: UserAccounts.lastError !== "" visible: UserAccounts.lastError !== ""
label: "Accounts need attention" label: "That change did not go through"
detail: UserAccounts.lastError detail: UserAccounts.lastError
value: "" value: ""
divider: false divider: false
@@ -160,25 +261,64 @@ SettingsPage {
Item { width: 1; height: 6 } Item { width: 1; height: 6 }
SettingsButton { Row {
text: "Change picture…" spacing: 8
enabled: !UserAccounts.busy
onClicked: avatarPicker.open() SettingsButton {
text: root.pictureOpen ? "Done" : "Change picture…"
enabled: !UserAccounts.busy
onClicked: {
// The gallery is a directory listing, read the
// first time someone asks to see it.
if (!root.pictureOpen)
UserAccounts.loadStockAvatars();
root.pictureOpen = !root.pictureOpen;
}
}
SettingsButton {
visible: UserAccounts.avatarUrl !== ""
text: "Remove"
enabled: !UserAccounts.busy
onClicked: {
root.pictureOpen = false;
UserAccounts.removeIcon();
}
}
} }
} }
} }
StockAvatarPicker {
width: parent.width
visible: root.pictureOpen
avatars: UserAccounts.stockAvatars
enabled: !UserAccounts.busy
onPicked: path => {
root.pictureOpen = false;
UserAccounts.setIcon(String(root.me?.userName ?? ""), path);
}
// A photo is almost never square, so it goes through the cropper
// rather than being squeezed into a circle by the image loader.
onFileRequested: {
root.pictureOpen = false;
avatarPicker.open();
}
}
} }
SettingsCard { SettingsCard {
title: "Account" title: "Account"
visible: root.me !== null visible: root.me !== null && root.pendingPicture === ""
TextFieldRow { LiveFieldRow {
label: "Full name" label: "Full name"
detail: "Shown on the lock screen and in the Control Center" detail: "Shown on the lock screen and in the Control Center"
text: String(root.me?.realName ?? "") text: String(root.me?.realName ?? "")
placeholder: "Your name" placeholder: "Your name"
enabled: !UserAccounts.busy enabled: !UserAccounts.busy
// Committed on Enter or when the field is left, never per
// keystroke: this one goes through polkit.
onAccepted: value => UserAccounts.setRealName(String(root.me?.userName ?? ""), value) onAccepted: value => UserAccounts.setRealName(String(root.me?.userName ?? ""), value)
} }
@@ -191,7 +331,7 @@ SettingsPage {
SegmentRow { SegmentRow {
label: "Account type" label: "Account type"
detail: root.me?.administrator && UserAccounts.administratorCount <= 1 detail: root.me?.administrator && UserAccounts.administratorCount <= 1
? "This is the only administrator, so it cannot be changed" ? "The last administrator cannot step down"
: "Administrators can install software and manage other accounts" : "Administrators can install software and manage other accounts"
options: [ options: [
{ value: "standard", label: "Standard" }, { value: "standard", label: "Standard" },
@@ -212,12 +352,10 @@ SettingsPage {
enabled: !UserAccounts.busy enabled: !UserAccounts.busy
divider: root.openPanel === "password" divider: root.openPanel === "password"
onTriggered: { onTriggered: {
if (root.openPanel === "password") const wasOpen = root.openPanel === "password";
root.closePanels(); root.closePanels();
else { if (!wasOpen)
root.closePanels();
root.openPanel = "password"; root.openPanel = "password";
}
} }
} }
@@ -225,19 +363,30 @@ SettingsPage {
width: parent.width width: parent.width
visible: root.openPanel === "password" visible: root.openPanel === "password"
PasswordRow { SecretFieldRow {
id: newPasswordField
width: parent.width width: parent.width
label: "New password" label: "New password"
detail: "At least six characters" detail: "At least six characters"
enabled: !UserAccounts.busy
onChanged: value => root.newPassword = value onChanged: value => root.newPassword = value
} }
PasswordRow { PasswordStrengthRow {
width: parent.width
password: root.newPassword
}
SecretFieldRow {
id: confirmPasswordField
width: parent.width width: parent.width
label: "Confirm" label: "Confirm"
detail: root.passwordProblem !== "" detail: root.passwordProblem !== ""
? root.passwordProblem ? root.passwordProblem
: "Type it a second time" : "Type it a second time"
enabled: !UserAccounts.busy
onChanged: value => root.confirmPassword = value onChanged: value => root.confirmPassword = value
} }
@@ -257,7 +406,7 @@ SettingsPage {
SwitchRow { SwitchRow {
label: "Automatic login" label: "Automatic login"
detail: "Sign in without typing a password. The login keyring stays locked when this is on, so stored passwords are unavailable until something asks for them." detail: "Anyone at the keyboard becomes you — the disk stays encrypted, the session does not. The login keyring stays locked too, so stored passwords wait until something asks for them."
checked: root.me?.automaticLogin === true checked: root.me?.automaticLogin === true
enabled: !UserAccounts.busy enabled: !UserAccounts.busy
divider: false divider: false
@@ -267,50 +416,195 @@ SettingsPage {
// ── Fingerprint ────────────────────────────────────────────────────────── // ── Fingerprint ──────────────────────────────────────────────────────────
// //
// Hidden in full on a machine with no reader. Two systems make this work // Two systems make a working fingerprint login and the card keeps them
// and the card keeps them honest with each other: fprintd holds the // honest with each other: fprintd holds the enrolled prints, and authselect
// enrolled prints (GNOME's Users panel owns that dialog, so enrollment // decides whether PAM asks the reader at all. A print enrolled while
// hands off the same way password-adjacent panels do), and authselect // authselect is off does nothing, and authselect left on with no reader
// decides whether PAM asks the reader at all -- a print enrolled while // attached asks PAM for a finger nothing can read -- which is why the card
// that is off does nothing, which reads as "fingerprint is broken". // appears for either fact, not only when a reader is plugged in.
SettingsCard { SettingsCard {
visible: Fingerprint.readerPresent // `cardVisible` is the service's `readerPresent || unlockFeatureEnabled`
// -- one expression, so the card and the state it describes cannot
// disagree about when the stuck case exists.
visible: Fingerprint.cardVisible
title: "Fingerprint" title: "Fingerprint"
subtitle: Fingerprint.readerName !== "" subtitle: Fingerprint.readerPresent && Fingerprint.readerName !== ""
? Fingerprint.readerName ? Fingerprint.readerName
: "A fingerprint reader is present." : ""
SwitchRow { // The stuck state: the feature is on, and there is nothing to read a
label: "Unlock with a fingerprint" // finger with. Reachable here because it is unreachable anywhere else --
detail: { // the card used to hide itself on exactly the machine that needed it.
if (Fingerprint.enrolled.length === 0) SettingRow {
return "Enroll a finger below first; until then the password is the only way in"; visible: root.fingerprintStuck
return Fingerprint.pamEnabled label: "Fingerprint unlock is on, but no reader is connected"
? "The lock screen and sudo accept an enrolled finger, with the password as fallback" detail: "authselect still asks PAM for a finger that can't be read — turn it off here, or plug the reader back in"
: "Enrolled fingers are ignored until this is on"; controlWidth: 190
divider: false
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: noReader.implicitWidth + 14
height: 17
radius: Theme.pillRadius
color: Theme.alpha(Theme.warn, 0.1)
border.width: 1
border.color: Theme.alpha(Theme.warn, 0.3)
Text {
id: noReader
anchors.centerIn: parent
text: "NO READER"
color: Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 2)
font.weight: Font.DemiBold
font.letterSpacing: 0.5
}
}
SettingsButton {
text: "Turn off"
enabled: !Fingerprint.busy
onClicked: Fingerprint.setUnlockEnabled(false)
}
} }
checked: Fingerprint.pamEnabled
enabled: !Fingerprint.busy
onToggled: value => Fingerprint.setUnlockEnabled(value)
} }
ActionRow { Column {
label: "Enrolled fingers" width: parent.width
detail: Fingerprint.enrolled.length === 0 visible: Fingerprint.readerPresent
? "None yet"
: Fingerprint.enrolled.map(finger => Fingerprint.fingerLabel(finger)).join(", ") SwitchRow {
action: "Manage…" width: parent.width
divider: Fingerprint.lastError !== "" label: "Unlock with a fingerprint"
// GNOME's Users panel owns the enrollment dialog; growing our own detail: {
// means reimplementing a guided capture flow fprintd already has if (Fingerprint.fingers.length === 0)
// a good one of. return "Enroll a finger below first; until then the password is the only way in";
onTriggered: SystemSettings.openGnomePanel("system", "users") return "The lock screen and sudo — system-wide, via authselect, with the password as fallback";
}
checked: Fingerprint.unlockFeatureEnabled
enabled: !Fingerprint.busy && !Fingerprint.enrolling
onToggled: value => Fingerprint.setUnlockEnabled(value)
}
Text {
width: parent.width
text: "ENROLLED FINGERS"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
font.weight: Font.DemiBold
font.letterSpacing: 0.8
topPadding: 12
bottomPadding: 4
}
TextRow {
width: parent.width
visible: Fingerprint.fingers.length === 0
label: "None yet"
detail: "A finger has to be enrolled before the reader can let anyone in"
value: ""
divider: false
}
Repeater {
model: Fingerprint.fingers
delegate: SettingRow {
id: fingerRow
required property var modelData
readonly property string finger: String(fingerRow.modelData)
readonly property bool confirming: root.confirmingFinger === fingerRow.finger
width: parent.width
// md-fingerprint
icon: "\u{F0306}"
label: Fingerprint.fingerLabel(fingerRow.finger)
detail: fingerRow.confirming
? "Forgetting it takes effect at once; it can be enrolled again afterwards"
: ""
controlWidth: 190
divider: false
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: fingerRow.confirming ? "Keep" : "Remove…"
enabled: !Fingerprint.busy && !Fingerprint.enrolling
onClicked: root.confirmingFinger = fingerRow.confirming
? "" : fingerRow.finger
}
SettingsButton {
visible: fingerRow.confirming
text: "Forget it"
tone: "danger"
enabled: !Fingerprint.busy
onClicked: {
root.confirmingFinger = "";
Fingerprint.removeFinger(fingerRow.finger);
}
}
}
}
}
OptionPickerRow {
width: parent.width
visible: !Fingerprint.enrolling && root.fingerOptions.length > 1
label: "Finger"
detail: "Which one the reader is about to learn"
options: root.fingerOptions
current: root.chosenFinger
onPicked: value => root.chosenFinger = String(value)
}
ActionRow {
width: parent.width
visible: !Fingerprint.enrolling
label: "Add a fingerprint"
detail: root.fingerOptions.length === 0
? "Every finger the reader knows about is already enrolled"
: "Around ten touches, guided here — the reader does the work"
action: "Enroll…"
enabled: !Fingerprint.busy && root.fingerOptions.length !== 0
divider: false
onTriggered: {
root.confirmingFinger = "";
Fingerprint.startEnroll(root.chosenFinger);
}
}
FingerprintEnrollPanel {
width: parent.width
visible: Fingerprint.enrolling
// The finger the service is actually working on, not the one
// the picker happens to be showing.
finger: Fingerprint.fingerLabel(Fingerprint.enrollFinger)
stage: Fingerprint.enrollStage
total: Fingerprint.enrollTotal
message: root.enrollHint
onCancelled: Fingerprint.cancelEnroll()
}
} }
TextRow { TextRow {
visible: Fingerprint.lastError !== "" visible: Fingerprint.lastError !== ""
label: "Fingerprint needs attention" label: "The reader had something to say"
detail: Fingerprint.lastError detail: Fingerprint.lastError
value: "" value: ""
divider: false divider: false
@@ -337,48 +631,149 @@ SettingsPage {
width: parent.width width: parent.width
readonly property string userName: String(otherBlock.modelData.userName ?? "") readonly property string userName: String(otherBlock.modelData.userName ?? "")
readonly property bool expanded: root.expandedUser === otherBlock.userName
readonly property bool confirming: root.confirmingRemoval === otherBlock.userName readonly property bool confirming: root.confirmingRemoval === otherBlock.userName
readonly property bool locked: otherBlock.modelData.locked === true
readonly property bool lastAdministrator:
otherBlock.modelData.administrator && UserAccounts.administratorCount <= 1
SettingRow { SettingRow {
width: otherBlock.width width: otherBlock.width
label: UserAccounts.displayName(otherBlock.modelData) label: UserAccounts.displayName(otherBlock.modelData)
detail: otherBlock.userName + " · " detail: otherBlock.userName + " · "
+ (otherBlock.modelData.administrator ? "Administrator" : "Standard account") + (otherBlock.modelData.administrator ? "Administrator" : "Standard")
controlWidth: 210 + " · " + (otherBlock.locked
divider: false ? "locked"
: root.signInSummary(otherBlock.modelData))
controlWidth: 26
divider: !otherBlock.expanded
activatable: true
onActivated: {
root.expandedUser = otherBlock.expanded ? "" : otherBlock.userName;
root.confirmingRemoval = "";
root.removalDisposition = "keep";
}
Row { Text {
anchors.right: parent.right anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
spacing: 8 text: otherBlock.expanded ? "▴" : "▾"
color: Theme.fgMuted
SettingsButton { font.family: Theme.fontFamily
text: otherBlock.confirming ? "Keep" : "Remove…" font.pixelSize: Theme.fontSizeSmall
enabled: !UserAccounts.busy
onClicked: root.confirmingRemoval = otherBlock.confirming
? "" : otherBlock.userName
}
SettingsButton {
visible: otherBlock.confirming
text: "Delete account and files"
tone: "danger"
enabled: !UserAccounts.busy
onClicked: {
root.confirmingRemoval = "";
UserAccounts.deleteUser(otherBlock.userName, true);
}
}
} }
} }
TextRow { Column {
width: otherBlock.width width: otherBlock.width
visible: otherBlock.confirming leftPadding: 14
label: "This cannot be undone" visible: otherBlock.expanded
detail: "Their home directory and everything in it is deleted."
value: "" SegmentRow {
divider: false width: otherBlock.width - 14
label: "Account type"
detail: otherBlock.lastAdministrator
? "The last administrator cannot step down"
: "Administrators can install software and manage other accounts"
options: [
{ value: "standard", label: "Standard" },
{ value: "administrator", label: "Administrator" }
]
value: otherBlock.modelData.administrator ? "administrator" : "standard"
enabled: !UserAccounts.busy && !otherBlock.lastAdministrator
onSelected: value => UserAccounts.setAccountTypeFor(otherBlock.userName, value)
}
ActionRow {
width: otherBlock.width - 14
visible: otherBlock.locked
label: "This account is locked"
detail: "Nothing signs in to it until it is unlocked again"
action: "Unlock"
enabled: !UserAccounts.busy
onTriggered: UserAccounts.setLocked(otherBlock.userName, false)
}
ActionRow {
width: otherBlock.width - 14
label: "Reset password"
detail: "They set a new one at next sign-in. Nothing is typed here, and no password is chosen for them."
action: "Reset…"
enabled: !UserAccounts.busy
onTriggered: UserAccounts.resetPassword(otherBlock.userName)
}
OptionPickerRow {
width: otherBlock.width - 14
label: "Their files"
detail: "What deleting the account does to /home/" + otherBlock.userName
options: [
{
value: "keep",
label: "Keep the files",
detail: "/home/" + otherBlock.userName
+ " stays exactly where it is, and you can hand it to someone later"
},
{
value: "remove",
label: "Remove everything",
detail: "The home directory and everything inside it is deleted with the account"
}
]
current: root.removalDisposition
enabled: !UserAccounts.busy
onPicked: value => root.removalDisposition = String(value)
}
SettingRow {
width: otherBlock.width - 14
label: otherBlock.confirming
? "Delete " + UserAccounts.displayName(otherBlock.modelData) + "?"
: "Delete this account"
detail: {
if (!otherBlock.confirming)
return "The account stops existing. What happens to their files is the choice above.";
if (root.removalDisposition === "remove")
return "This cannot be undone — /home/" + otherBlock.userName
+ " and everything in it is deleted along with the account.";
return "Their files stay in /home/" + otherBlock.userName
+ ", so only the account itself goes.";
}
controlWidth: 260
divider: false
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: otherBlock.confirming ? "Keep the account" : "Delete…"
enabled: !UserAccounts.busy
onClicked: root.confirmingRemoval = otherBlock.confirming
? "" : otherBlock.userName
}
SettingsButton {
visible: otherBlock.confirming
text: root.removalDisposition === "remove"
? "Delete account and files"
: "Delete the account"
tone: "danger"
enabled: !UserAccounts.busy
onClicked: {
// The helper's second argument is
// keep-files, not remove-files: the two
// read alike at a glance and mean opposite
// things to someone's home directory.
const keepFiles = root.removalDisposition === "keep";
root.confirmingRemoval = "";
root.expandedUser = "";
UserAccounts.deleteUser(otherBlock.userName, keepFiles);
}
}
}
}
} }
Item { width: 1; height: 6 } Item { width: 1; height: 6 }
@@ -392,35 +787,43 @@ SettingsPage {
enabled: !UserAccounts.busy enabled: !UserAccounts.busy
divider: root.openPanel === "newUser" divider: root.openPanel === "newUser"
onTriggered: { onTriggered: {
if (root.openPanel === "newUser") const wasOpen = root.openPanel === "newUser";
root.closePanels(); root.closePanels();
else { if (!wasOpen)
root.closePanels();
root.openPanel = "newUser"; root.openPanel = "newUser";
}
} }
} }
// Live, on purpose. These fields report every keystroke -- they are
// LiveFieldRow rather than TextFieldRow -- because the Create button
// has to darken and light as the username is typed. With blur-committed
// fields it stayed dark until focus left, which reads as a dead button.
Column { Column {
width: parent.width width: parent.width
visible: root.openPanel === "newUser" visible: root.openPanel === "newUser"
TextFieldRow { LiveFieldRow {
width: parent.width width: parent.width
label: "Full name" label: "Full name"
placeholder: "Their name" placeholder: "Their name"
detail: "Shown on the login screen" detail: "Shown on the login screen"
text: root.newRealName text: root.newRealName
onAccepted: value => root.newRealName = value enabled: !UserAccounts.busy
onEdited: value => root.newRealName = value
} }
TextFieldRow { LiveFieldRow {
width: parent.width width: parent.width
label: "Username" label: "Username"
placeholder: "lowercase, no spaces" placeholder: "riley"
detail: "Their home directory is named after this and cannot be changed later" detail: root.userNameProblem !== ""
? root.userNameProblem
: "A lowercase letter or _, then lowercase letters, digits, - and _ · up to 31 more. Their home directory is named after it and cannot be changed later."
invalid: root.userNameProblem !== ""
maximumLength: 32
text: root.newUserName text: root.newUserName
onAccepted: value => root.newUserName = value enabled: !UserAccounts.busy
onEdited: value => root.newUserName = value
} }
SegmentRow { SegmentRow {
@@ -432,6 +835,7 @@ SettingsPage {
{ value: "administrator", label: "Administrator" } { value: "administrator", label: "Administrator" }
] ]
value: root.newUserIsAdministrator ? "administrator" : "standard" value: root.newUserIsAdministrator ? "administrator" : "standard"
enabled: !UserAccounts.busy
onSelected: value => root.newUserIsAdministrator = value === "administrator" onSelected: value => root.newUserIsAdministrator = value === "administrator"
} }
@@ -440,7 +844,11 @@ SettingsPage {
label: "Create the account" label: "Create the account"
detail: "They set their own password the first time they sign in" detail: "They set their own password the first time they sign in"
action: "Create" action: "Create"
enabled: !UserAccounts.busy && /^[a-z_][a-z0-9_-]*$/.test(root.newUserName) // The same rule the sentence under the field states, read from
// the one place it is written down.
enabled: !UserAccounts.busy
&& root.newUserNameValid
&& root.userNameProblem === ""
divider: false divider: false
onTriggered: { onTriggered: {
UserAccounts.createUser(root.newUserName, root.newRealName, UserAccounts.createUser(root.newUserName, root.newRealName,
@@ -124,3 +124,9 @@ ThemeEditorWells 1.0 ThemeEditorWells.qml
ThemeSaturationRow 1.0 ThemeSaturationRow.qml ThemeSaturationRow 1.0 ThemeSaturationRow.qml
ThemeStartChips 1.0 ThemeStartChips.qml ThemeStartChips 1.0 ThemeStartChips.qml
ThemeSaveRow 1.0 ThemeSaveRow.qml ThemeSaveRow 1.0 ThemeSaveRow.qml
LiveFieldRow 1.0 LiveFieldRow.qml
SecretFieldRow 1.0 SecretFieldRow.qml
PasswordStrengthRow 1.0 PasswordStrengthRow.qml
StockAvatarPicker 1.0 StockAvatarPicker.qml
FingerprintEnrollPanel 1.0 FingerprintEnrollPanel.qml
OnlineAccountRow 1.0 OnlineAccountRow.qml
+348 -11
View File
@@ -12,22 +12,45 @@ turning individual services on and off, and removing an account. That is the
whole Online Accounts panel apart from one OAuth handshake. whole Online Accounts panel apart from one OAuth handshake.
Adding an account is the part that splits. The daemon's AddAccount takes Adding an account is the part that splits. The daemon's AddAccount takes
credentials as an ARGUMENT -- it stores them, it does not obtain them. For credentials as an ARGUMENT -- it stores them, it does not obtain them, and every
password-based providers (Nextcloud, IMAP, WebDAV) that is a username and a part of that storing happens inside goa-daemon, which does have the backend.
password, which a settings app can reasonably collect. For Google it is an OAuth For password-based providers (Nextcloud, IMAP) that argument is a username and a
token, and the code that runs that exchange lives in libgoa-backend, which password, which a settings app can reasonably collect, so those are added here.
Fedora ships without a GIR binding -- reachable from C only. Reimplementing it For Google it is an OAuth token, and the code that runs that exchange lives in
would mean our own Google client credentials. So Google sign-in is handed to libgoa-backend, which Fedora ships without a GIR binding -- reachable from C
GNOME's panel, and only the sign-in. only. Reimplementing it would mean our own Google client credentials. So OAuth
sign-in is handed to GNOME's panel, and only the sign-in.
A password reaches this script on stdin and nowhere else. argv is world-readable
through /proc, so a password passed as an argument is published to every process
on the machine.
Usage: Usage:
panama-accounts list panama-accounts list | snapshot
panama-accounts set <object-path> <service> <true|false> panama-accounts set <object-path> <service> <true|false>
panama-accounts remove <object-path> panama-accounts remove <object-path>
panama-accounts add-nextcloud SERVER USERNAME (password on stdin)
panama-accounts add-imap EMAIL IMAP_HOST SMTP_HOST USERNAME (password on stdin)
""" """
import json import json
import os
import re
import sys import sys
import urllib.parse
class BoundaryError(RuntimeError):
"""A user-visible validation or sign-in failure."""
# A canned GOA, for contract runs: a JSON file in the shape `list` prints. With
# it set, nothing here imports gi or touches the session bus, and every change a
# verb would have made is appended to PANAMA_ACCOUNTS_LOG instead -- with the
# password replaced by its length, so a contract can prove it was read from
# stdin without a secret ever reaching a file.
FIXTURE_ENV = "PANAMA_ACCOUNTS_FIXTURE"
LOG_ENV = "PANAMA_ACCOUNTS_LOG"
# Every service GOA models. The account object carries one interface per service # Every service GOA models. The account object carries one interface per service
# it supports, so presence of the interface is what "this account can do mail" # it supports, so presence of the interface is what "this account can do mail"
@@ -111,8 +134,319 @@ def find(client, path):
return None return None
# ── Adding a password account ────────────────────────────────────────────────
#
# The keys below are not invented. They are the keys goa-daemon writes into
# ~/.config/goa-1.0/accounts.conf, which is to say the ones each provider's
# build_object reads back -- taken from GOA 3.58's goaowncloudprovider.c and
# goaimapsmtpprovider.c, and confirmed against the accounts already on this
# machine. A key GOA does not recognize is silently ignored, so getting one
# wrong produces an account that exists and does nothing.
def read_password() -> str:
"""The password, from stdin. Never an argument, at any point in the chain."""
secret = sys.stdin.buffer.read()
# A trailing newline from a pipe is not part of the password.
if secret.endswith(b"\n"):
secret = secret[:-1]
if not secret:
raise BoundaryError("No password was provided.")
return secret.decode("utf-8", "surrogateescape")
# RFC 1123 hostname (or a dotted IPv4). These strings end up in accounts.conf
# and in URIs other processes fetch, so a shell metacharacter or a space is a
# malformed address whichever way it got here -- refuse it at the boundary.
HOSTNAME = re.compile(
r"^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?"
r"(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$")
def valid_host(text: str) -> bool:
return bool(text) and len(text) <= 253 and bool(HOSTNAME.match(text))
def nextcloud_uris(server: str) -> tuple[str, str, str]:
"""(WebDAV URI, DAV URI, host) for a Nextcloud address.
`remote.php/webdav` and `remote.php/dav` are Nextcloud's fixed layout, and
are exactly what GOA's own provider derives from the server it was given.
"""
text = (server or "").strip()
if not text:
raise BoundaryError("Enter the address of your Nextcloud server.")
if "://" not in text:
text = "https://" + text
parsed = urllib.parse.urlsplit(text)
if parsed.scheme not in ("http", "https"):
raise BoundaryError("A server address starts with https://.")
if not parsed.hostname or not valid_host(parsed.hostname):
raise BoundaryError("That is not a server address.")
# urlsplit is permissive about what follows the host; a path is fine, but
# spaces or shell punctuation anywhere in the address are not an URL that
# a Nextcloud server has.
if re.search(r"[\s;|&`$<>\"']", text):
raise BoundaryError("That is not a server address.")
base = f"{parsed.scheme}://{parsed.netloc}{parsed.path.rstrip('/')}"
return base + "/remote.php/webdav/", base + "/remote.php/dav/", parsed.hostname
def nextcloud_account(server: str, username: str) -> tuple[str, str, dict]:
"""(identity, presentation identity, details). Validation and nothing else,
so the same refusals run whether or not there is a GOA to talk to."""
identity = (username or "").strip()
if not identity:
raise BoundaryError("Enter the user name for that server.")
webdav, dav, host = nextcloud_uris(server)
return (
identity,
# What the account is shown as. A bare user name says nothing about
# which server it is on, and people keep more than one.
identity if "@" in identity else f"{identity}@{host}",
{
"Uri": webdav,
"FilesEnabled": "true",
"CalendarEnabled": "true",
"CalDavUri": dav,
"ContactsEnabled": "true",
"CardDavUri": dav,
"AcceptSslErrors": "false",
},
)
def add_nextcloud(server: str, username: str, password: str) -> str:
from gi.repository import GLib
identity, presentation, details = nextcloud_account(server, username)
return add_account("owncloud", identity, presentation,
{"password": GLib.Variant("s", password)},
details, "Nextcloud")
def imap_account(email: str, imap_host: str, smtp_host: str,
username: str) -> tuple[str, str, dict]:
address = (email or "").strip()
if "@" not in address or address.startswith("@") or address.endswith("@"):
raise BoundaryError("Enter the email address for this account.")
imap = (imap_host or "").strip()
smtp = (smtp_host or "").strip()
if not imap or not smtp:
raise BoundaryError("Enter both the incoming and outgoing server.")
if not valid_host(imap) or not valid_host(smtp):
raise BoundaryError("Mail servers are host names, like imap.example.org.")
identity = (username or "").strip() or address
# The modern defaults, and the ones every mail provider this is likely to
# meet uses: IMAP over TLS on 993, submission with STARTTLS on 587. GOA
# offers a dropdown for the other combinations; a settings page that asked
# four encryption questions to add a mailbox would be worse than one that
# gets it right and lets GNOME's panel handle the unusual server.
return (
address, address,
{
"Enabled": "true",
"EmailAddress": address,
"Name": address,
"ImapHost": imap,
"ImapUserName": identity,
"ImapUseSsl": "true",
"ImapUseTls": "false",
"ImapAcceptSslErrors": "false",
"SmtpHost": smtp,
"SmtpUseAuth": "true",
"SmtpUserName": identity,
"SmtpAuthLogin": "false",
"SmtpAuthPlain": "true",
"SmtpUseSsl": "false",
"SmtpUseTls": "true",
"SmtpAcceptSslErrors": "false",
},
)
def add_imap(email: str, imap_host: str, smtp_host: str, username: str,
password: str) -> str:
from gi.repository import GLib
identity, presentation, details = imap_account(email, imap_host, smtp_host, username)
# One password for both servers: submission almost always takes the same
# credentials as the mailbox, and asking twice for the same secret is how a
# form gets abandoned. GOA stores them under separate keys regardless.
return add_account("imap_smtp", identity, presentation,
{"imap-password": GLib.Variant("s", password),
"smtp-password": GLib.Variant("s", password)},
details, "mail")
def add_account(provider: str, identity: str, presentation: str,
credentials: dict, details: dict, label: str) -> str:
"""Hand GOA a new account, then make it prove the credentials work.
The daemon does not check what it is given -- it writes the account out and
stores the password. Without the second step a mistyped password produces an
account that exists, looks fine, and never syncs, which is the failure this
page is supposed to be able to explain.
"""
from gi.repository import GLib
client = load_client()
manager = client.get_manager()
if manager is None:
raise BoundaryError("GNOME Online Accounts is not answering.")
try:
if not manager.call_is_supported_provider_sync(provider, None):
raise BoundaryError(f"This system cannot add {label} accounts.")
path = manager.call_add_account_sync(
provider, identity, presentation,
GLib.Variant("a{sv}", credentials),
GLib.Variant("a{ss}", details), None)
except GLib.Error as failure:
raise BoundaryError(clean(failure.message)) from failure
verify(path, label)
return path
def verify(path: str, label: str) -> None:
"""Sign in once, and undo the account if that fails."""
from gi.repository import GLib
# A fresh client: the one that made the call has not necessarily seen the
# object appear yet, and this is the cheapest way to wait for it properly.
obj = find(load_client(), path)
if obj is None:
# It was added; this process simply cannot see it yet. Reporting a
# failure here would be a lie, and the account list will show it.
return
account = obj.get_account()
try:
account.set_default_timeout(60000)
account.call_ensure_credentials_sync(None)
except GLib.Error as failure:
try:
account.call_remove_sync(None)
except GLib.Error:
pass
raise BoundaryError(
f"Those {label} details were not accepted: {clean(failure.message)}"
) from failure
def clean(message: str) -> str:
"""The useful sentence of a GOA error, without the D-Bus type prefix."""
trimmed = re.sub(r"^GDBus\.Error:[^:]+:\s*", "", str(message or "")).strip()
return trimmed.splitlines()[0][:200] if trimmed else "That did not work."
# ── The canned GOA ───────────────────────────────────────────────────────────
def fixture() -> dict | None:
path = os.environ.get(FIXTURE_ENV)
if not path:
return None
try:
with open(path, encoding="utf-8") as handle:
listing = json.load(handle)
except (OSError, ValueError) as failure:
return {"accounts": [], "error": f"The accounts fixture could not be read: {failure}"}
listing.setdefault("accounts", [])
listing.setdefault("error", "")
return listing
def record(verb: str, arguments: list, password: str | None = None) -> None:
path = os.environ.get(LOG_ENV) or (os.environ.get(FIXTURE_ENV, "") + ".log")
if not path:
return
entry = {"verb": verb, "arguments": arguments}
if password is not None:
# Its length, never the thing itself: the point of the log is to prove
# the password came in on stdin, not to keep a copy of it.
entry["passwordBytes"] = len(password)
try:
with open(path, "a", encoding="utf-8") as handle:
handle.write(json.dumps(entry, separators=(",", ":")) + "\n")
except OSError:
pass
def added(operation) -> int:
"""Run an add, and answer with the fresh account list either way.
Errors come back inside that list rather than on stderr, so the page has
both facts -- what went wrong and what is there now -- from one read.
"""
error = ""
try:
operation()
except BoundaryError as failure:
error = str(failure)
try:
accounts = [describe(obj) for obj in load_client().get_accounts()]
except Exception: # noqa: BLE001 - the error above is the one worth saying
accounts = []
print(json.dumps({"accounts": accounts, "error": error}))
return 0
def replay(action: str, arguments: list, canned: dict) -> int:
"""Every verb, against the canned GOA. No bus, no gi, no account changed."""
error = ""
try:
if action in ("list", "snapshot"):
pass
elif action in ("set", "remove"):
record(action, arguments[1:])
elif action == "add-nextcloud":
if len(arguments) != 3:
raise BoundaryError("Usage: add-nextcloud SERVER USERNAME")
password = read_password()
nextcloud_account(arguments[1], arguments[2])
record(action, arguments[1:], password)
elif action == "add-imap":
if len(arguments) != 5:
raise BoundaryError("Usage: add-imap EMAIL IMAP_HOST SMTP_HOST USERNAME")
password = read_password()
imap_account(arguments[1], arguments[2], arguments[3], arguments[4])
record(action, arguments[1:], password)
else:
raise BoundaryError(f"Unknown command {action!r}.")
except BoundaryError as failure:
error = str(failure)
print(json.dumps({**canned, "error": error or canned.get("error", "")}))
return 0
def main(): def main():
action = sys.argv[1] if len(sys.argv) > 1 else "list" arguments = sys.argv[1:]
action = arguments[0] if arguments else "list"
canned = fixture()
if canned is not None:
return replay(action, arguments, canned)
if action == "add-nextcloud":
if len(arguments) != 3:
print("usage: panama-accounts add-nextcloud SERVER USERNAME", file=sys.stderr)
return 2
return added(lambda: add_nextcloud(arguments[1], arguments[2], read_password()))
if action == "add-imap":
if len(arguments) != 5:
print("usage: panama-accounts add-imap EMAIL IMAP_HOST SMTP_HOST USERNAME",
file=sys.stderr)
return 2
return added(lambda: add_imap(arguments[1], arguments[2], arguments[3],
arguments[4], read_password()))
try: try:
client = load_client() client = load_client()
@@ -124,7 +458,9 @@ def main():
})) }))
return 0 return 0
if action == "list": # "snapshot" is what every other Panama helper calls this, and what the
# service asks for; "list" is what this one has always been called.
if action in ("list", "snapshot"):
print(json.dumps({ print(json.dumps({
"accounts": [describe(obj) for obj in client.get_accounts()], "accounts": [describe(obj) for obj in client.get_accounts()],
"error": "", "error": "",
@@ -162,7 +498,8 @@ def main():
obj.get_account().call_remove_sync(None) obj.get_account().call_remove_sync(None)
return 0 return 0
print("usage: panama-accounts [list|set|remove]", file=sys.stderr) print("usage: panama-accounts [list|snapshot|set|remove|add-nextcloud|add-imap]",
file=sys.stderr)
return 2 return 2
+478 -77
View File
@@ -1,89 +1,490 @@
#!/usr/bin/env bash #!/usr/bin/env python3
# Fingerprint state and the one privileged switch, for the Users page. """Fingerprint login: the enrolled prints, and the one privileged switch.
#
# Two independent facts make a working fingerprint login, and conflating them
# is how the feature usually confuses people: fprintd must hold at least one
# enrolled print (GNOME's Users panel owns that dialog, and Panama hands off
# to it), and PAM must be told to ask the reader at all, which on Fedora is
# authselect's `with-fingerprint` feature. This helper reports both and can
# flip the second.
#
# Usage:
# panama-fingerprint status -> {"reader":bool,"readerName":"","enrolled":[],"pamEnabled":bool,"error":""}
# panama-fingerprint set-unlock on|off (prompts through panama-sudo/polkit)
#
# authselect is baseline Fedora (it manages PAM for the whole install), and
# fprintd ships with Workstation; a machine with neither simply reports no
# reader, which hides the card.
set -uo pipefail Two independent facts make a working fingerprint login, and conflating them is
how the feature usually confuses people. fprintd must hold at least one enrolled
print, and PAM must be told to ask the reader at all, which on Fedora is
authselect's `with-fingerprint` feature. This helper reports both, enrolls and
removes prints, and can flip the second.
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}" Both facts are reported unconditionally, including on a machine with no reader.
The state that used to be invisible -- the feature switched on with nothing
enrolled and no reader attached -- is exactly the state someone needs to see and
turn off, and reporting `pamEnabled: false` because there was no reader to ask
made it unreachable.
emit() { Enrollment talks to fprintd over D-Bus (net.reactivated.Fprint) rather than
jq -cn \ handing the person to GNOME's Users panel: Claim, EnrollStart, then one
--argjson reader "$1" \ `EnrollStatus` signal per touch until the device says it is done. Progress is
--arg readerName "$2" \ printed as one JSON object per line, the same streaming shape panama-dictate's
--argjson enrolled "$3" \ setup uses, so the page can count touches while they happen. That signal loop is
--argjson pamEnabled "$4" \ why this is Python and no longer bash -- `status` and `set-unlock` still shell
--arg error "$5" \ out to exactly the same tools, and answer in exactly the same shapes.
'{reader: $reader, readerName: $readerName, enrolled: $enrolled,
pamEnabled: $pamEnabled, error: $error}'
}
cmd_status() { panama-fingerprint status
command -v fprintd-list >/dev/null 2>&1 || { emit false "" '[]' false ""; return; } panama-fingerprint set-unlock on|off (prompts through panama-sudo/polkit)
panama-fingerprint enroll FINGER (streams {stage,done,total,result})
panama-fingerprint remove FINGER
panama-fingerprint remove-all
# fprintd-list both answers "is there a reader" (fprintd is bus-activated, authselect is baseline Fedora (it manages PAM for the whole install) and fprintd
# so this also copes with the daemon not running yet) and names the ships with Workstation; a machine with neither reports no reader and no feature.
# enrolled fingers in one call. """
local listing
# LC_ALL=C: the "no devices" match below reads fprintd's message, and a from __future__ import annotations
# translated daemon would turn every readerless non-English machine into
# a permanent error card. import json
if ! listing="$(LC_ALL=C timeout 10 fprintd-list "$USER" 2>&1)"; then import os
# "No devices available" is the normal no-reader machine; anything import re
# else is a real problem worth surfacing. import shutil
if grep -qi 'no devices' <<<"$listing"; then import subprocess
emit false "" '[]' false "" import sys
else import time
emit false "" '[]' false "fprintd did not answer: $(head -1 <<<"$listing")"
fi FPRINT = "net.reactivated.Fprint"
MANAGER_PATH = "/net/reactivated/Fprint/Manager"
MANAGER_INTERFACE = "net.reactivated.Fprint.Manager"
DEVICE_INTERFACE = "net.reactivated.Fprint.Device"
# The authselect feature that decides whether PAM asks the reader at unlock.
FEATURE = "with-fingerprint"
# How long a reader may sit waiting for a finger before enrollment is given up
# on. The page has a Cancel button; this is only for a session left open.
IDLE_TIMEOUT_SECONDS = 90
# A canned fprintd, for contract runs. Points at a JSON file; see replay() for
# the shape. Every call that would have gone to the bus is appended to
# PANAMA_FINGERPRINT_LOG instead, so a test can pin the order of
# Claim / EnrollStart / EnrollStop / Release without a reader in the room.
FIXTURE_ENV = "PANAMA_FINGERPRINT_FIXTURE"
LOG_ENV = "PANAMA_FINGERPRINT_LOG"
PANAMA_PATH = os.environ.get("PANAMA_PATH") or os.path.expanduser("~/.local/share/Panama")
# fprintd's own vocabulary, only far enough to reject nonsense before it becomes
# a D-Bus error. What a finger is CALLED is presented by services/Fingerprint.qml,
# which is the single place that decides how these read.
FINGER = re.compile(r"^(left|right)-(thumb|(index|middle|ring|little)-finger)$")
class BoundaryError(RuntimeError):
"""A user-visible failure: no reader, a refused claim, a bad finger name."""
# ── status ───────────────────────────────────────────────────────────────────
def unlock_feature_enabled() -> bool:
"""Whether PAM has been told to ask the reader.
Asked unconditionally. This is a property of the PAM configuration and has
nothing to do with whether a reader is plugged in, which is the whole point:
the feature left on with no reader is a state someone has to be able to see.
"""
if not shutil.which("authselect"):
return False
try:
current = subprocess.run(["authselect", "current"], capture_output=True,
text=True, timeout=10, check=False)
except (OSError, subprocess.SubprocessError):
return False
return FEATURE in (current.stdout or "")
def status() -> dict:
reader, name, enrolled, error = False, "", [], ""
if shutil.which("fprintd-list"):
# fprintd-list answers "is there a reader" (fprintd is bus-activated, so
# this copes with the daemon not running yet) and names the enrolled
# fingers in one call.
#
# LC_ALL=C: the "no devices" match below reads fprintd's message, and a
# translated daemon would turn every readerless non-English machine into
# a permanent error card.
environment = dict(os.environ, LC_ALL="C")
try:
listing = subprocess.run(["fprintd-list", os.environ.get("USER", "")],
capture_output=True, text=True, timeout=10,
check=False, env=environment)
output = (listing.stdout or "") + (listing.stderr or "")
except (OSError, subprocess.SubprocessError) as failure:
listing, output = None, str(failure)
if listing is None or listing.returncode != 0:
# "No devices available" is the normal no-reader machine; anything
# else is a real problem worth surfacing.
if "no devices" not in output.lower():
first = next((line for line in output.splitlines() if line.strip()), "")
error = f"fprintd did not answer: {first}"
else:
reader = True
# "Fingerprints for user gib on FocalTech ... (press):" carries the
# reader product name; " - #0: right-index-finger" the enrollment.
match = re.search(r"^Fingerprints for user \S+ on (.*) \(\w*\):$",
output, re.MULTILINE)
name = match.group(1) if match else ""
enrolled = re.findall(r"^ *- #\d+: (.+)$", output, re.MULTILINE)
feature = unlock_feature_enabled()
return {
"reader": reader,
"readerName": name,
"enrolled": enrolled,
# Two names for one fact, on purpose: `pamEnabled` is what this helper
# has always called it, `unlockFeatureEnabled` is what it is.
"pamEnabled": feature,
"unlockFeatureEnabled": feature,
"error": error,
}
# ── the privileged switch ────────────────────────────────────────────────────
def set_unlock(state: str) -> int:
if state == "on":
verb = "enable-feature"
reason = ("Turning on fingerprint login: telling PAM (via authselect) to "
"ask the fingerprint reader when unlocking")
elif state == "off":
verb = "disable-feature"
reason = ("Turning off fingerprint login: telling PAM (via authselect) to "
"stop asking the fingerprint reader")
else:
print("panama-fingerprint set-unlock takes on|off", file=sys.stderr)
return 1
escalate = os.path.join(PANAMA_PATH, "bin", "panama-sudo")
prefix = ([escalate, "--reason", reason, "--"]
if os.access(escalate, os.X_OK) else ["sudo"])
return subprocess.run(prefix + ["authselect", verb, FEATURE], check=False).returncode
# ── talking to fprintd ───────────────────────────────────────────────────────
def emit(**fields) -> None:
"""One JSON object, one line, flushed. The page reads these as they arrive."""
print(json.dumps(fields, separators=(",", ":")), flush=True)
def log_call(method: str, *arguments) -> None:
"""Record a call the fixture stood in for, so a contract can pin the order."""
path = os.environ.get(LOG_ENV) or (os.environ.get(FIXTURE_ENV, "") + ".log")
if not path:
return return
fi try:
with open(path, "a", encoding="utf-8") as handle:
handle.write(json.dumps({"method": method, "arguments": list(arguments)},
separators=(",", ":")) + "\n")
except OSError:
pass
# "Fingerprints for user gib on FocalTech ... (press):" carries the reader
# product name; " - #0: right-index-finger" lines carry the enrollment.
local name enrolled pam
name="$(sed -n 's/^Fingerprints for user [^ ]* on \(.*\) (\w*):$/\1/p' <<<"$listing" | head -1)"
enrolled="$(sed -n 's/^ *- #[0-9]*: //p' <<<"$listing" | jq -Rn '[inputs]')"
pam=false
authselect current 2>/dev/null | grep -q 'with-fingerprint' && pam=true
emit true "$name" "$enrolled" "$pam" "" def fixture() -> dict | None:
} """The canned fprintd, or None when there is a real bus to talk to.
cmd_set_unlock() { {"enrollStages": 5,
local verb reason "results": ["enroll-stage-passed", "enroll-retry-scan-too-short", ...],
case "$1" in "error": ""} <- non-empty stands in for a refused claim
on) verb=enable-feature """
reason="Turning on fingerprint login: telling PAM (via authselect) to ask the fingerprint reader when unlocking" ;; path = os.environ.get(FIXTURE_ENV)
off) verb=disable-feature if not path:
reason="Turning off fingerprint login: telling PAM (via authselect) to stop asking the fingerprint reader" ;; return None
*) echo 'panama-fingerprint set-unlock takes on|off' >&2; exit 1 ;; try:
esac with open(path, encoding="utf-8") as handle:
return json.load(handle)
except (OSError, ValueError) as failure:
raise BoundaryError(f"The fingerprint fixture could not be read: {failure}")
local sudo_cmd=(sudo)
[[ -x "$PANAMA_PATH/bin/panama-sudo" ]] && sudo_cmd=(
"$PANAMA_PATH/bin/panama-sudo" --reason "$reason" --
)
"${sudo_cmd[@]}" authselect "$verb" with-fingerprint
}
case "${1:-}" in def bus():
status) cmd_status ;; try:
set-unlock) [[ -n "${2:-}" ]] || { echo 'panama-fingerprint set-unlock takes on|off' >&2; exit 1; } import gi
cmd_set_unlock "$2" ;;
*) echo 'usage: panama-fingerprint status | set-unlock on|off' >&2; exit 1 ;; gi.require_version("Gio", "2.0")
esac from gi.repository import Gio, GLib
return Gio, GLib, Gio.bus_get_sync(Gio.BusType.SYSTEM, None)
except Exception as failure: # noqa: BLE001 - no bus is a legitimate state
raise BoundaryError("The fingerprint service is not answering.") from failure
def call(path: str, interface: str, method: str, parameters=None, reply=None):
Gio, GLib, connection = bus()
try:
result = connection.call_sync(
FPRINT, path, interface, method, parameters,
GLib.VariantType(reply) if reply else None,
Gio.DBusCallFlags.NONE, 30000, None)
except Exception as failure: # noqa: BLE001
raise BoundaryError(_clean(str(failure))) from failure
return result.unpack() if result is not None else None
def _clean(message: str) -> str:
"""The useful sentence out of a D-Bus error, without the type prefix."""
trimmed = re.sub(r"^GDBus\.Error:[^:]+:\s*", "", message).strip()
if "no devices" in trimmed.lower():
return "No fingerprint reader is connected."
if "permission denied" in trimmed.lower() or "not authorized" in trimmed.lower():
return "That was not authorized."
if "already in use" in trimmed.lower() or "claimed" in trimmed.lower():
return "The fingerprint reader is busy with something else."
return trimmed.splitlines()[0][:200] if trimmed else "The fingerprint reader failed."
def default_device() -> str:
return call(MANAGER_PATH, MANAGER_INTERFACE, "GetDefaultDevice", None, "(o)")[0]
def enroll_stages(device: str) -> int:
Gio, GLib, connection = bus()
try:
result = connection.call_sync(
FPRINT, device, "org.freedesktop.DBus.Properties", "Get",
GLib.Variant("(ss)", (DEVICE_INTERFACE, "num-enroll-stages")),
GLib.VariantType("(v)"), Gio.DBusCallFlags.NONE, 10000, None)
return max(1, int(result.unpack()[0]))
except Exception: # noqa: BLE001 - a device that will not say is not fatal
# Readers overwhelmingly want five touches, and a counter that is wrong
# is better than a page with no counter at all.
return 5
# ── enroll ───────────────────────────────────────────────────────────────────
# fprintd says "enroll-stage-passed" for a touch that counted and
# "enroll-completed" when there are no more to take. Everything else beginning
# "enroll-retry" or naming a placement problem is a touch to do again, and the
# terminal failures arrive with done=true.
STAGE_PASSED = "enroll-stage-passed"
COMPLETED = "enroll-completed"
# The results that end an enrollment badly. The device says so itself over the
# wire (the signal's `done` flag), so this list is only what the canned fprintd
# has to recognize on its own.
TERMINAL_FAILURES = (
"enroll-failed", "enroll-data-full", "enroll-disconnected",
"enroll-duplicate", "enroll-unknown-error",
)
def enroll(finger: str) -> int:
if not FINGER.fullmatch(finger or ""):
raise BoundaryError("That is not a finger fprintd knows.")
canned = fixture()
if canned is not None:
return enroll_replay(finger, canned)
device = default_device()
total = enroll_stages(device)
emit(ok=True, stage="claiming", done=0, total=total, result="", error="")
Gio, GLib, connection = bus()
call(device, DEVICE_INTERFACE, "Claim",
GLib.Variant("(s)", (os.environ.get("USER", ""),)))
loop = GLib.MainLoop()
state = {"done": 0, "result": "", "error": "", "ok": False, "seen": time.monotonic()}
def on_status(_connection, _sender, _path, _interface, _signal, parameters):
result, finished = parameters.unpack()
state["seen"] = time.monotonic()
state["result"] = result
if result == STAGE_PASSED:
state["done"] = min(state["done"] + 1, total)
if finished:
state["ok"] = result == COMPLETED
if not state["ok"]:
state["error"] = describe_result(result)
loop.quit()
return
emit(ok=True, stage="scanning", done=state["done"], total=total,
result=result, error="")
subscription = connection.signal_subscribe(
None, DEVICE_INTERFACE, "EnrollStatus", device, None,
Gio.DBusSignalFlags.NONE, on_status)
# A cancelled enrollment is a terminated process -- the page drops the
# Process and Quickshell sends a signal. The device must still be released,
# or the next attempt finds the reader busy with a session that is gone.
def cancelled(_data=None):
state["error"] = ""
state["result"] = "cancelled"
loop.quit()
return GLib.SOURCE_REMOVE
GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, 15, cancelled, None)
GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, 2, cancelled, None)
def watchdog(_data=None):
if time.monotonic() - state["seen"] > IDLE_TIMEOUT_SECONDS:
state["error"] = "The reader stopped answering."
loop.quit()
return GLib.SOURCE_REMOVE
return GLib.SOURCE_CONTINUE
GLib.timeout_add_seconds(5, watchdog, None)
try:
call(device, DEVICE_INTERFACE, "EnrollStart", GLib.Variant("(s)", (finger,)))
emit(ok=True, stage="scanning", done=0, total=total, result="", error="")
loop.run()
finally:
connection.signal_unsubscribe(subscription)
# Both are best-effort: the interesting failure already happened, and a
# reader left claimed is worse than a second error nobody can act on.
for method in ("EnrollStop", "Release"):
try:
call(device, DEVICE_INTERFACE, method)
except BoundaryError:
pass
return finish(state, total)
def finish(state: dict, total: int) -> int:
if state["ok"]:
emit(ok=True, stage="done", done=total, total=total,
result=COMPLETED, error="")
return 0
stage = "cancelled" if state["result"] == "cancelled" else "failed"
emit(ok=False, stage=stage, done=state["done"], total=total,
result=state["result"], error=state["error"])
return 1
def describe_result(result: str) -> str:
"""fprintd's terminal results, in words someone can act on."""
return {
"enroll-failed": "That finger could not be read. Try enrolling it again.",
"enroll-data-full": "The reader has no room for another fingerprint.",
"enroll-disconnected": "The fingerprint reader was disconnected.",
"enroll-duplicate": "That finger is already enrolled.",
"enroll-unknown-error": "The fingerprint reader failed.",
}.get(result, "Enrolling that finger did not finish.")
def enroll_replay(finger: str, canned: dict) -> int:
"""The same stream, from a file. No bus, no reader, no waiting."""
total = max(1, int(canned.get("enrollStages", 5)))
emit(ok=True, stage="claiming", done=0, total=total, result="", error="")
log_call("Claim", os.environ.get("USER", ""))
refusal = str(canned.get("error") or "")
if refusal:
emit(ok=False, stage="failed", done=0, total=total, result="", error=refusal)
return 1
log_call("EnrollStart", finger)
emit(ok=True, stage="scanning", done=0, total=total, result="", error="")
state = {"done": 0, "result": "", "error": "", "ok": False}
for result in canned.get("results", []):
state["result"] = result
if result == STAGE_PASSED:
state["done"] = min(state["done"] + 1, total)
if result == COMPLETED or result in TERMINAL_FAILURES:
state["ok"] = result == COMPLETED
if not state["ok"]:
state["error"] = describe_result(result)
break
emit(ok=True, stage="scanning", done=state["done"], total=total,
result=result, error="")
log_call("EnrollStop")
log_call("Release")
return finish(state, total)
# ── remove ───────────────────────────────────────────────────────────────────
def remove(finger: str | None) -> dict:
"""Delete one enrolled finger, or all of them, and report the fresh state."""
if finger is not None and not FINGER.fullmatch(finger):
raise BoundaryError("That is not a finger fprintd knows.")
canned = fixture()
if canned is not None:
log_call("Claim", os.environ.get("USER", ""))
if finger is None:
log_call("DeleteEnrolledFingers2")
else:
log_call("DeleteEnrolledFinger", finger)
log_call("Release")
return {**status(), "error": str(canned.get("error") or "")}
from gi.repository import GLib
device = default_device()
call(device, DEVICE_INTERFACE, "Claim",
GLib.Variant("(s)", (os.environ.get("USER", ""),)))
try:
if finger is None:
# DeleteEnrolledFingers2 works on the claimed user; its predecessor
# took a name and is deprecated for exactly the confusion that
# invited -- deleting someone else's prints by typo.
call(device, DEVICE_INTERFACE, "DeleteEnrolledFingers2")
else:
call(device, DEVICE_INTERFACE, "DeleteEnrolledFinger",
GLib.Variant("(s)", (finger,)))
finally:
try:
call(device, DEVICE_INTERFACE, "Release")
except BoundaryError:
pass
return status()
# ── entry ────────────────────────────────────────────────────────────────────
def main(arguments: list[str]) -> int:
verb = arguments[0] if arguments else ""
try:
if verb == "status" and len(arguments) == 1:
print(json.dumps(status(), separators=(",", ":")))
return 0
if verb == "set-unlock" and len(arguments) == 2:
return set_unlock(arguments[1])
if verb == "enroll" and len(arguments) == 2:
return enroll(arguments[1])
if verb == "remove" and len(arguments) == 2:
print(json.dumps(remove(arguments[1]), separators=(",", ":")))
return 0
if verb == "remove-all" and len(arguments) == 1:
print(json.dumps(remove(None), separators=(",", ":")))
return 0
except BoundaryError as failure:
# Enrollment streams, so its failure has to arrive in the same shape as
# its progress; the others answer with the state plus the message, so a
# page never has to ask twice to find out what happened.
if verb == "enroll":
emit(ok=False, stage="failed", done=0, total=0, result="",
error=str(failure))
else:
print(json.dumps({**status(), "error": str(failure)},
separators=(",", ":")))
return 1
print("usage: panama-fingerprint status | set-unlock on|off | enroll FINGER | "
"remove FINGER | remove-all", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+124 -14
View File
@@ -13,14 +13,17 @@ accountsservice over D-Bus from inside this process. It is never an argument:
argv is world-readable through /proc, so a password -- or even its hash -- argv is world-readable through /proc, so a password -- or even its hash --
passed that way is published to every process on the machine. passed that way is published to every process on the machine.
panama-accounts snapshot panama-users snapshot
panama-accounts set-real-name USER NAME panama-users stock-avatars
panama-accounts set-icon USER PATH [X Y SIZE] panama-users set-real-name USER NAME
panama-accounts set-account-type USER standard|administrator panama-users set-icon USER PATH [X Y SIZE] (PATH "" clears the picture)
panama-accounts set-automatic-login USER true|false panama-users set-account-type USER standard|administrator
panama-accounts set-password USER (new password on stdin) panama-users set-automatic-login USER true|false
panama-accounts create-user USERNAME REALNAME standard|administrator panama-users set-locked USER true|false
panama-accounts delete-user USERNAME [keep-files|remove-files] panama-users set-password USER (new password on stdin)
panama-users reset-password USER (no password material at all)
panama-users create-user USERNAME REALNAME standard|administrator
panama-users delete-user USERNAME keep|remove
""" """
from __future__ import annotations from __future__ import annotations
@@ -39,6 +42,16 @@ USER_INTERFACE = "org.freedesktop.Accounts.User"
# Account types as accountsservice numbers them. # Account types as accountsservice numbers them.
STANDARD, ADMINISTRATOR = 0, 1 STANDARD, ADMINISTRATOR = 0, 1
# Password modes, likewise. 1 is "the account has no usable password and must
# choose one at the next sign-in" -- which is why resetting a password here
# involves no password at all, not even one this process saw for a moment.
PASSWORD_MODE_SET_AT_LOGIN = 1
# Where a distribution keeps the pictures its login screen offers. Fedora ships
# fifteen; a machine with none is normal and yields an empty list.
STOCK_AVATAR_DIR = "/usr/share/pixmaps/faces"
STOCK_AVATAR_SUFFIXES = (".png", ".jpg", ".jpeg", ".webp", ".svg")
USERNAME = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$") USERNAME = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$")
@@ -137,6 +150,40 @@ def snapshot() -> dict:
} }
def stock_avatars() -> dict:
"""The pictures the distribution ships, as {name, path} pairs.
Absolute paths, because the caller draws them straight from disk and then
hands the same path back to set-icon. Sorted by name so the gallery does
not reshuffle itself between openings.
"""
entries = []
try:
names = os.listdir(STOCK_AVATAR_DIR)
except OSError:
# No such directory is an ordinary machine, not a failure worth a row.
names = []
for name in names:
if not name.lower().endswith(STOCK_AVATAR_SUFFIXES):
continue
path = os.path.join(STOCK_AVATAR_DIR, name)
if not os.path.isfile(path):
continue
entries.append({"name": _avatar_label(name), "path": path})
entries.sort(key=lambda entry: entry["name"].lower())
return {"avatars": entries, "error": ""}
def _avatar_label(filename: str) -> str:
""""coffee2.jpg" -> "Coffee 2". A file name is not a caption, but it is the
only thing these pictures carry, so it is tidied rather than invented."""
stem = os.path.splitext(filename)[0]
words = re.sub(r"(\d+)$", r" \1", stem.replace("-", " ").replace("_", " ")).strip()
return words[:1].upper() + words[1:]
def set_real_name(username: str, name: str) -> None: def set_real_name(username: str, name: str) -> None:
from gi.repository import GLib from gi.repository import GLib
@@ -195,6 +242,14 @@ def crop_square(path: str, x: int, y: int, size: int) -> str:
def set_icon(username: str, path: str, region: tuple[int, int, int] | None = None) -> None: def set_icon(username: str, path: str, region: tuple[int, int, int] | None = None) -> None:
from gi.repository import GLib from gi.repository import GLib
# An empty path is how accountsservice is told to forget the picture: the
# same call, with nothing in it. There is no separate "clear" method, and
# inventing a verb for it here would only hide that.
if path == "":
call(user_path(username), USER_INTERFACE, "SetIconFile",
GLib.Variant("(s)", ("",)))
return
if not os.path.isfile(path): if not os.path.isfile(path):
raise BoundaryError("That picture no longer exists.") raise BoundaryError("That picture no longer exists.")
@@ -221,7 +276,21 @@ def set_account_type(username: str, kind: str) -> None:
if kind not in ("standard", "administrator"): if kind not in ("standard", "administrator"):
raise BoundaryError("That is not an account type.") raise BoundaryError("That is not an account type.")
call(user_path(username), USER_INTERFACE, "SetAccountType", path = user_path(username)
# The same reason deleting the last administrator is refused: a machine
# whose only administrator has just been demoted cannot be administered,
# and the demotion itself is the last thing that needed authorization.
if kind == "standard":
state = snapshot()
target = next((user for user in state["users"]
if user["userName"] == username), None)
if target is not None and target["administrator"] \
and state["administratorCount"] <= 1:
raise BoundaryError(
"That is the only administrator; the machine would have none.")
call(path, USER_INTERFACE, "SetAccountType",
GLib.Variant("(i)", (ADMINISTRATOR if kind == "administrator" else STANDARD,))) GLib.Variant("(i)", (ADMINISTRATOR if kind == "administrator" else STANDARD,)))
@@ -232,6 +301,29 @@ def set_automatic_login(username: str, enabled: bool) -> None:
GLib.Variant("(b)", (enabled,))) GLib.Variant("(b)", (enabled,)))
def set_locked(username: str, locked: bool) -> None:
"""Lock or unlock an account. A locked account cannot sign in at all, which
is what someone is looking at when a user row says nothing works for them."""
from gi.repository import GLib
call(user_path(username), USER_INTERFACE, "SetLocked",
GLib.Variant("(b)", (locked,)))
def reset_password(username: str) -> None:
"""Require a new password at the next sign-in.
Deliberately not "set a password for them": no password is chosen, typed,
hashed, or transmitted. accountsservice is told the account's password mode
is "set at login", and the login screen collects the new one from the person
who is going to use it.
"""
from gi.repository import GLib
call(user_path(username), USER_INTERFACE, "SetPasswordMode",
GLib.Variant("(i)", (PASSWORD_MODE_SET_AT_LOGIN,)))
def set_password(username: str) -> None: def set_password(username: str) -> None:
"""Set a new password, read from stdin and never named on a command line.""" """Set a new password, read from stdin and never named on a command line."""
secret = sys.stdin.buffer.read() secret = sys.stdin.buffer.read()
@@ -269,10 +361,17 @@ def create_user(username: str, real_name: str, kind: str) -> None:
"(o)") "(o)")
# What to do with the home directory, spelled either way. "keep"/"remove" is
# what the page says out loud; the longer pair is what this helper has always
# taken, and callers older than the page still pass it.
KEEP_FILES = ("keep", "keep-files")
REMOVE_FILES = ("remove", "remove-files")
def delete_user(username: str, files: str) -> None: def delete_user(username: str, files: str) -> None:
from gi.repository import GLib from gi.repository import GLib
if files not in ("keep-files", "remove-files"): if files not in KEEP_FILES + REMOVE_FILES:
raise BoundaryError("Say whether to keep or remove the home directory.") raise BoundaryError("Say whether to keep or remove the home directory.")
if username == (os.environ.get("USER") or ""): if username == (os.environ.get("USER") or ""):
raise BoundaryError("You cannot delete the account you are signed in to.") raise BoundaryError("You cannot delete the account you are signed in to.")
@@ -285,7 +384,7 @@ def delete_user(username: str, files: str) -> None:
raise BoundaryError("That is the only administrator; the machine would have none.") raise BoundaryError("That is the only administrator; the machine would have none.")
call(ACCOUNTS_PATH, ACCOUNTS, "DeleteUser", call(ACCOUNTS_PATH, ACCOUNTS, "DeleteUser",
GLib.Variant("(xb)", (target["uid"], files == "remove-files"))) GLib.Variant("(xb)", (target["uid"], files in REMOVE_FILES)))
def main(arguments: list[str]) -> int: def main(arguments: list[str]) -> int:
@@ -294,6 +393,12 @@ def main(arguments: list[str]) -> int:
print(json.dumps(snapshot(), separators=(",", ":"))) print(json.dumps(snapshot(), separators=(",", ":")))
return 0 return 0
# Its own shape rather than a snapshot: this asks the filesystem what
# pictures exist, which has nothing to do with who has an account.
if arguments == ["stock-avatars"]:
print(json.dumps(stock_avatars(), separators=(",", ":")))
return 0
if len(arguments) == 3 and arguments[0] == "set-real-name": if len(arguments) == 3 and arguments[0] == "set-real-name":
set_real_name(arguments[1], arguments[2]) set_real_name(arguments[1], arguments[2])
elif len(arguments) == 3 and arguments[0] == "set-icon": elif len(arguments) == 3 and arguments[0] == "set-icon":
@@ -308,19 +413,24 @@ def main(arguments: list[str]) -> int:
set_account_type(arguments[1], arguments[2]) set_account_type(arguments[1], arguments[2])
elif len(arguments) == 3 and arguments[0] == "set-automatic-login": elif len(arguments) == 3 and arguments[0] == "set-automatic-login":
set_automatic_login(arguments[1], arguments[2] == "true") set_automatic_login(arguments[1], arguments[2] == "true")
elif len(arguments) == 3 and arguments[0] == "set-locked":
set_locked(arguments[1], arguments[2] == "true")
elif len(arguments) == 2 and arguments[0] == "set-password": elif len(arguments) == 2 and arguments[0] == "set-password":
set_password(arguments[1]) set_password(arguments[1])
elif len(arguments) == 2 and arguments[0] == "reset-password":
reset_password(arguments[1])
elif len(arguments) == 4 and arguments[0] == "create-user": elif len(arguments) == 4 and arguments[0] == "create-user":
create_user(arguments[1], arguments[2], arguments[3]) create_user(arguments[1], arguments[2], arguments[3])
elif len(arguments) == 3 and arguments[0] == "delete-user": elif len(arguments) == 3 and arguments[0] == "delete-user":
delete_user(arguments[1], arguments[2]) delete_user(arguments[1], arguments[2])
else: else:
raise BoundaryError( raise BoundaryError(
"Usage: panama-accounts snapshot | set-real-name USER NAME | " "Usage: panama-users snapshot | stock-avatars | set-real-name USER NAME | "
"set-icon USER PATH [X Y SIZE] | set-account-type USER standard|administrator | " "set-icon USER PATH [X Y SIZE] | set-account-type USER standard|administrator | "
"set-automatic-login USER true|false | set-password USER | " "set-automatic-login USER true|false | set-locked USER true|false | "
"set-password USER | reset-password USER | "
"create-user USERNAME REALNAME standard|administrator | " "create-user USERNAME REALNAME standard|administrator | "
"delete-user USERNAME keep-files|remove-files") "delete-user USERNAME keep|remove")
except BoundaryError as error: except BoundaryError as error:
# Answers with the fresh state plus the message, so a page never has to # Answers with the fresh state plus the message, so a page never has to
# ask twice to find out what happened. # ask twice to find out what happened.
+155 -32
View File
@@ -2,12 +2,22 @@ pragma Singleton
// The fingerprint reader, for the Users page. // The fingerprint reader, for the Users page.
// //
// Two facts, owned by two different systems: fprintd holds the enrolled // Two facts, owned by two different systems: fprintd holds the enrolled prints,
// prints (GNOME's Users panel owns the enrollment dialog and Panama hands off // and authselect decides whether PAM asks the reader at unlock. Both come
// to it), and authselect decides whether PAM asks the reader at unlock. Both // through scripts/panama-fingerprint, and the one privileged change -- flipping
// come through scripts/panama-fingerprint, and the one privileged change -- // authselect's with-fingerprint feature -- prompts through polkit with a stated
// flipping authselect's with-fingerprint feature -- prompts through polkit // reason, like everything else on that page.
// with a stated reason, like everything else on that page. //
// The two facts are kept apart deliberately. `unlockFeatureEnabled` is a
// property of the PAM configuration and is reported whether or not a reader
// exists, because the state worth seeing most is the one where the feature is
// on and the reader is gone: nothing works, nothing says why, and the switch
// that would fix it used to be hidden behind the missing hardware. Hence
// `cardVisible`.
//
// Enrollment happens here now rather than in GNOME's Users panel. The helper
// drives fprintd's own Claim/EnrollStart cycle and prints one line of JSON per
// touch, which is what `enrollStage of enrollTotal` counts.
// //
// Read when the page opens rather than at shell startup: probing fprintd // Read when the page opens rather than at shell startup: probing fprintd
// bus-activates the daemon, and most sessions never open this page. // bus-activates the daemon, and most sessions never open this page.
@@ -23,55 +33,134 @@ Singleton {
property bool readerPresent: false property bool readerPresent: false
property string readerName: "" property string readerName: ""
property var enrolled: [] // The fingers fprintd currently holds a print for, by fprintd's own names.
property bool pamEnabled: false property var fingers: []
property bool unlockFeatureEnabled: false
property bool scanned: false property bool scanned: false
property bool busy: false
property string lastError: "" property string lastError: ""
// Guards read the Process objects; this is only for the page to bind to.
readonly property bool busy: apply.running || change.running
// The card is worth drawing when there is a reader OR when PAM has been
// told to use one. The second half is the stuck state: no reader, nothing
// enrolled, and a feature switched on that quietly slows every unlock down.
readonly property bool cardVisible: root.readerPresent || root.unlockFeatureEnabled
// ── What a finger is called ──────────────────────────────────────────────
//
// fprintd's vocabulary lives here and only here, so nothing can disagree
// about what a finger is named or how it reads.
readonly property var allFingers: [
"right-index-finger", "right-middle-finger", "right-ring-finger",
"right-little-finger", "right-thumb",
"left-index-finger", "left-middle-finger", "left-ring-finger",
"left-little-finger", "left-thumb"
]
// The ones still worth offering: enrolling a finger twice replaces the
// print rather than adding one, which is not what "Add a fingerprint" says.
readonly property var availableFingers:
root.allFingers.filter(finger => root.fingers.indexOf(finger) === -1)
// "right-index-finger" -> "Right index finger". Presentation lives here // "right-index-finger" -> "Right index finger". Presentation lives here
// rather than in the page, the way PowerProfiles.label does, so nothing // rather than in the page, the way PowerProfiles.label does.
// can disagree about what a finger is called.
function fingerLabel(finger: string): string { function fingerLabel(finger: string): string {
const words = String(finger).split("-").join(" "); const words = String(finger).split("-").join(" ");
return words.slice(0, 1).toUpperCase() + words.slice(1); return words.slice(0, 1).toUpperCase() + words.slice(1);
} }
// ── Enrollment ───────────────────────────────────────────────────────────
property bool enrolling: false
// Touches taken, of touches the reader wants. Both zero until the device
// has said how many it needs.
property int enrollStage: 0
property int enrollTotal: 0
// The finger being enrolled, and fprintd's last word about the last touch
// ("enroll-stage-passed", "enroll-retry-scan-too-short", ...). The page
// turns the retries into "Try again, a little slower".
property string enrollFinger: ""
property string enrollResult: ""
// claiming | scanning | done | failed | cancelled
property string enrollPhase: ""
function refresh(): void { function refresh(): void {
if (!query.running) if (!query.running)
query.running = true; query.running = true;
} }
function setUnlockEnabled(on: bool): void { function setUnlockEnabled(on: bool): void {
if (root.busy) if (apply.running)
return; return;
root.busy = true;
root.lastError = ""; root.lastError = "";
apply.command = [root.helperPath, "set-unlock", on ? "on" : "off"]; apply.command = [root.helperPath, "set-unlock", on ? "on" : "off"];
apply.running = true; apply.running = true;
} }
function startEnroll(finger: string): void {
if (enroll.running)
return;
root.lastError = "";
root.enrollFinger = finger;
root.enrollStage = 0;
root.enrollTotal = 0;
root.enrollResult = "";
root.enrollPhase = "claiming";
root.enrolling = true;
enroll.command = [root.helperPath, "enroll", finger];
enroll.running = true;
}
// Stopping the process is the cancellation: the helper catches the signal,
// stops the enrollment and releases the reader, which is the part that
// matters -- a claimed device belonging to a dead process refuses the next
// attempt.
function cancelEnroll(): void {
if (enroll.running)
enroll.running = false;
}
function removeFinger(finger: string): void {
if (change.running)
return;
root.lastError = "";
change.command = [root.helperPath, "remove", finger];
change.running = true;
}
function removeAll(): void {
if (change.running)
return;
root.lastError = "";
change.command = [root.helperPath, "remove-all"];
change.running = true;
}
// Every verb that reports state answers in the same shape, so there is one
// place that reads it.
function absorb(text: string): void {
try {
const parsed = JSON.parse(text);
root.readerPresent = parsed.reader === true;
root.readerName = String(parsed.readerName ?? "");
root.fingers = Array.isArray(parsed.enrolled) ? parsed.enrolled : [];
root.unlockFeatureEnabled = parsed.unlockFeatureEnabled === true;
if (String(parsed.error ?? "") !== "")
root.lastError = String(parsed.error);
} catch (error) {
root.readerPresent = false;
root.lastError = "Could not read the fingerprint helper's output.";
console.warn("Fingerprint: could not parse helper output:", error);
}
root.scanned = true;
}
Process { Process {
id: query id: query
command: [root.helperPath, "status"] command: [root.helperPath, "status"]
stdout: StdioCollector { stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.readerPresent = parsed.reader === true;
root.readerName = String(parsed.readerName ?? "");
root.enrolled = Array.isArray(parsed.enrolled) ? parsed.enrolled : [];
root.pamEnabled = parsed.pamEnabled === true;
if (String(parsed.error ?? "") !== "")
root.lastError = String(parsed.error);
} catch (error) {
root.readerPresent = false;
root.lastError = "Could not read the fingerprint helper's output.";
console.warn("Fingerprint: could not parse helper output:", error);
}
root.scanned = true;
}
}
} }
Process { Process {
@@ -87,8 +176,42 @@ Singleton {
} }
// Re-read rather than assuming: authselect may refuse, and the // Re-read rather than assuming: authselect may refuse, and the
// prompt may have been dismissed. // prompt may have been dismissed.
onExited: root.refresh()
}
Process {
id: change
// Removing answers with the fresh state, so the list updates from the
// removal itself and never has to ask again.
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
}
Process {
id: enroll
stdout: SplitParser {
// One JSON object per touch. A reader takes five or so, seconds
// apart, so this is a trickle rather than a stream to drain.
onRead: line => {
try {
const update = JSON.parse(line);
root.enrollPhase = String(update.stage ?? root.enrollPhase);
root.enrollTotal = Number(update.total ?? root.enrollTotal);
root.enrollStage = Number(update.done ?? root.enrollStage);
root.enrollResult = String(update.result ?? "");
if (update.ok === false)
root.lastError = String(update.error ?? "That finger could not be enrolled.");
} catch (error) {
// A line that is not JSON is not worth abandoning an
// enrollment over; the exit status is what decides.
}
}
}
onExited: { onExited: {
root.busy = false; root.enrolling = false;
// Cancelled by stopping the process: the helper had no chance to
// say anything, and there is nothing to report.
if (root.enrollPhase !== "done" && root.enrollPhase !== "failed")
root.enrollPhase = "cancelled";
root.refresh(); root.refresh();
} }
} }
+112 -33
View File
@@ -5,13 +5,19 @@ pragma Singleton
// The daemon already runs in this session -- gvfs activates it, and accounts // The daemon already runs in this session -- gvfs activates it, and accounts
// work without gnome-shell anywhere. Only the panel was GNOME's; the accounts // work without gnome-shell anywhere. Only the panel was GNOME's; the accounts
// are D-Bus objects anything may read and modify. So listing, per-service // are D-Bus objects anything may read and modify. So listing, per-service
// toggles, and removal all happen here, natively. // toggles, removal, and adding a password account all happen here, natively.
// //
// Signing in is the exception, and only for OAuth providers. The daemon's // Signing in is the exception, and only for OAuth providers. GOA's AddAccount
// AddAccount takes credentials as an argument rather than obtaining them, and // takes credentials as an argument rather than obtaining them, which is exactly
// the code that runs Google's OAuth exchange lives in libgoa-backend, which // what a Nextcloud or IMAP form can supply; what it cannot supply is a Google
// Fedora ships without a GIR binding. So that one step is handed to GNOME's // token, because the code that runs that exchange lives in libgoa-backend,
// panel and the user comes straight back here. // which Fedora ships without a GIR binding. So that one step is handed to
// GNOME's panel and the user comes straight back here.
//
// `available` means GOA answered the last time it was asked, and nothing else.
// It used to mean "lastError is empty", so a toggle GOA refused turned the
// whole page into "Online Accounts is not available" and hid the four working
// accounts behind it. A write that failed is a row; it is not the page.
// //
// Read on demand and after every change: accounts are added and removed by // Read on demand and after every change: accounts are added and removed by
// people, not by the system, so there is nothing to poll for. // people, not by the system, so there is nothing to poll for.
@@ -29,68 +35,141 @@ Singleton {
// services: [{key,label,enabled}] }] // services: [{key,label,enabled}] }]
property var accounts: [] property var accounts: []
property bool scanned: false property bool scanned: false
property bool busy: false
property string lastError: "" // Whether GOA answered the last snapshot. True to begin with because
// nothing has said otherwise yet; `scanned` is what says whether anything
// has been asked at all.
property bool available: true
// Kept apart on purpose. The first is why the page might be empty; the
// second is why one thing someone just did did not happen. Only the first
// has any business deciding whether the page works.
property string snapshotError: ""
property string writeError: ""
readonly property string lastError:
root.writeError !== "" ? root.writeError : root.snapshotError
// Guards read the Process objects; this is only for the page to bind to,
// so rows can go quiet while a change is in flight.
readonly property bool busy: list.running || write.running || add.running
// Accounts whose stored credentials have stopped working -- an expired // Accounts whose stored credentials have stopped working -- an expired
// token, a changed password. GOA knows, and nothing outside its own panel // token, a changed password. GOA knows, and nothing outside its own panel
// ever says so, which is how an account quietly stops syncing for weeks. // ever says so, which is how an account quietly stops syncing for weeks.
readonly property int attentionCount: root.accounts.filter(a => a.needsAttention).length readonly property int attentionCount: root.accounts.filter(a => a.needsAttention).length
readonly property bool available: root.lastError === "" // Cheap enough for every page open: one short-lived process reading D-Bus
// objects that are already in memory.
function refresh(): void { function refresh(): void {
if (!list.running) if (!list.running)
list.running = true; list.running = true;
} }
function absorb(text: string): void {
try {
const parsed = JSON.parse(text);
root.accounts = Array.isArray(parsed.accounts) ? parsed.accounts : [];
root.snapshotError = String(parsed.error ?? "");
root.available = root.snapshotError === "";
} catch (error) {
root.accounts = [];
root.snapshotError = "Could not read the accounts helper's output.";
root.available = false;
console.warn("OnlineAccounts: could not parse helper output:", error);
}
root.scanned = true;
}
// Enabling a service clears GOA's "disabled" flag; the helper owns that // Enabling a service clears GOA's "disabled" flag; the helper owns that
// inversion so the UI can speak in terms of what is on. // inversion so the UI can speak in terms of what is on.
function setService(path: string, service: string, enabled: bool): void { function setService(path: string, service: string, enabled: bool): void {
if (root.busy) if (write.running)
return; return;
root.busy = true; root.writeError = "";
write.command = [root.helperPath, "set", path, service, enabled ? "true" : "false"]; write.command = [root.helperPath, "set", path, service, enabled ? "true" : "false"];
write.running = true; write.running = true;
} }
function remove(path: string): void { function remove(path: string): void {
if (root.busy) if (write.running)
return; return;
root.busy = true; root.writeError = "";
write.command = [root.helperPath, "remove", path]; write.command = [root.helperPath, "remove", path];
write.running = true; write.running = true;
} }
// ── Adding a password account ────────────────────────────────────────────
//
// The password goes to the helper's stdin and nowhere else: never an
// argument, because argv is readable by every process on this machine.
// It is written from onStarted, because a process has no stdin to write to
// until it is running -- the same pattern UserAccounts uses for a new
// password, and HomeAssistantConfig for its token.
property string pendingPassword: ""
function addNextcloud(server: string, user: string, password: string): void {
root.beginAdd(["add-nextcloud", server, user], password);
}
function addImap(email: string, imapHost: string, smtpHost: string,
user: string, password: string): void {
root.beginAdd(["add-imap", email, imapHost, smtpHost, user], password);
}
function beginAdd(arguments: var, password: string): void {
if (add.running)
return;
root.writeError = "";
root.pendingPassword = password;
add.command = [root.helperPath].concat(arguments);
add.stdinEnabled = true;
add.running = true;
}
Process { Process {
id: list id: list
command: [root.helperPath, "list"] command: [root.helperPath, "snapshot"]
stdout: StdioCollector { stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.accounts = Array.isArray(parsed.accounts) ? parsed.accounts : [];
root.lastError = String(parsed.error ?? "");
} catch (error) {
root.accounts = [];
root.lastError = "Could not read the accounts helper's output.";
console.warn("OnlineAccounts: could not parse helper output:", error);
}
root.scanned = true;
}
}
} }
Process { Process {
id: write id: write
stderr: StdioCollector { stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim() onStreamFinished: if (this.text.trim() !== "") root.writeError = this.text.trim();
} }
// Re-read rather than assuming the write landed: GOA may refuse, and a // Re-read rather than assuming the write landed: GOA may refuse, and a
// toggle that sprang back is the honest outcome. // toggle that sprang back is the honest outcome.
onExited: { onExited: root.refresh()
root.busy = false; }
root.refresh();
Process {
id: add
stdinEnabled: true
onStarted: {
add.write(root.pendingPassword + "\n");
// Held for as long as it takes to hand over, and no longer.
root.pendingPassword = "";
add.stdinEnabled = false;
} }
// The helper answers with the fresh account list plus whatever went
// wrong, so a successful add lands on the page without a second read.
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.accounts = Array.isArray(parsed.accounts) ? parsed.accounts : root.accounts;
// An add that failed says why here. It is a write error:
// GOA answered, so the page is still working.
root.writeError = String(parsed.error ?? "");
} catch (error) {
root.writeError = "Could not read the accounts helper's output.";
}
}
}
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.writeError = this.text.trim();
}
onExited: root.pendingPassword = ""
} }
} }
@@ -108,6 +108,15 @@ Singleton {
{ label: "Add a user", detail: "Create another account on this machine", page: "users" }, { label: "Add a user", detail: "Create another account on this machine", page: "users" },
{ label: "Automatic login", detail: "Sign in without typing a password", page: "users" }, { label: "Automatic login", detail: "Sign in without typing a password", page: "users" },
{ label: "Administrator", detail: "Which accounts can manage this machine", page: "users" }, { label: "Administrator", detail: "Which accounts can manage this machine", page: "users" },
// Users owns fingerprint enrollment now rather than handing it to
// GNOME's panel, and it owns the other-account verbs it used to bury
// inside an expanded row. Each of those is something people come
// looking for by name, so each gets a name to be found by.
{ label: "Fingerprint", detail: "Unlock and authorize with the reader on this machine", page: "users" },
{ label: "Enroll a fingerprint", detail: "Record a finger, one touch at a time", page: "users" },
{ label: "Delete a user", detail: "Remove an account, keeping or destroying its files", page: "users" },
{ label: "Account type", detail: "Whether an account may administer this machine", page: "users" },
{ label: "Reset a password", detail: "They set a new one at their next sign-in", page: "users" },
{ label: "Printers", detail: "Add a printer and see what is queued", page: "printers" }, { label: "Printers", detail: "Add a printer and see what is queued", page: "printers" },
{ label: "Print queue", detail: "What is waiting to print, and cancelling it", page: "printers" }, { label: "Print queue", detail: "What is waiting to print, and cancelling it", page: "printers" },
{ label: "Add a printer", detail: "Find a printer on the network or enter its address", page: "printers" }, { label: "Add a printer", detail: "Find a printer on the network or enter its address", page: "printers" },
@@ -170,6 +179,14 @@ Singleton {
{ label: "Language", detail: "The system language, applied to programs started afterwards", page: "region" }, { label: "Language", detail: "The system language, applied to programs started afterwards", page: "region" },
{ label: "Regional formats", detail: "How dates, times, and numbers are written", page: "region" }, { label: "Regional formats", detail: "How dates, times, and numbers are written", page: "region" },
{ label: "Online accounts", detail: "Sign in to mail, calendar, and contacts", page: "accounts" }, { label: "Online accounts", detail: "Sign in to mail, calendar, and contacts", page: "accounts" },
// Adding an account is the thing people search for, and they search for
// it by the name of the service. Nextcloud and mail are added on the
// page itself; Google still goes through the provider's own sign-in,
// which is a result worth having rather than a dead end.
{ label: "Nextcloud", detail: "Add a Nextcloud server for files, calendar, and contacts", page: "accounts" },
{ label: "Google account", detail: "Sign in to Google for mail, calendar, and contacts", page: "accounts" },
{ label: "Add a mail account", detail: "Connect a mailbox by its IMAP and SMTP servers", page: "accounts" },
{ label: "Remove an account", detail: "Sign out and take an online account off this machine", page: "accounts" },
{ label: "Home Assistant", detail: "Connect the desktop to a Home Assistant server", page: "my-home" }, { label: "Home Assistant", detail: "Connect the desktop to a Home Assistant server", page: "my-home" },
{ label: "Lights", detail: "Toggle and dim lights, grouped by room", page: "my-home" }, { label: "Lights", detail: "Toggle and dim lights, grouped by room", page: "my-home" },
{ label: "Control Center lights", detail: "Choose the accessories on your shelf", page: "my-home" }, { label: "Control Center lights", detail: "Choose the accessories on your shelf", page: "my-home" },
@@ -25,6 +25,12 @@ Singleton {
property bool scanned: false property bool scanned: false
property string lastError: "" property string lastError: ""
// The pictures the distribution ships, as [{name, path}]. Read once and
// kept: it is a directory listing that does not change while a session is
// running, and the gallery it fills is opened and closed repeatedly.
property var stockAvatars: []
property bool stockAvatarsLoaded: false
// Guards read the Process objects rather than a derived "busy" binding. A // Guards read the Process objects rather than a derived "busy" binding. A
// binding hands back its cached value inside the handler that changes it, // binding hands back its cached value inside the handler that changes it,
// which silently turns a refresh after a successful change into a no-op -- // which silently turns a refresh after a successful change into a no-op --
@@ -115,7 +121,27 @@ Singleton {
String(Math.round(x)), String(Math.round(y)), String(Math.round(size))]); String(Math.round(x)), String(Math.round(y)), String(Math.round(size))]);
} }
function setAccountType(userName: string, kind: string): void { // Clearing the picture is the same call with nothing in it -- there is no
// separate method for it in accountsservice, and there is none here either.
// No user name: this is the hero card's own avatar, and an account you are
// not signed in to has no avatar surface to remove it from.
function removeIcon(): void {
root.settingIcon = true;
root.run(["set-icon", root.currentUser, ""]);
}
// Called when the gallery is about to be shown, not at startup: most
// sessions never open it, and it is a directory listing either way.
function loadStockAvatars(): void {
if (root.stockAvatarsLoaded || stock.running)
return;
stock.running = true;
}
// Any account, including one that is not signed in. The helper keeps the
// last-administrator refusal, so a page that forgets the guard still
// cannot leave the machine unadministrable.
function setAccountTypeFor(userName: string, kind: string): void {
root.run(["set-account-type", userName, kind]); root.run(["set-account-type", userName, kind]);
} }
@@ -123,12 +149,28 @@ Singleton {
root.run(["set-automatic-login", userName, enabled ? "true" : "false"]); root.run(["set-automatic-login", userName, enabled ? "true" : "false"]);
} }
// Locked accounts cannot sign in at all. Unlocking is the only half of this
// the page offers, because locking someone out is not a settings gesture.
function setLocked(userName: string, locked: bool): void {
root.run(["set-locked", userName, locked ? "true" : "false"]);
}
// No password of any kind is involved: accountsservice is told the account
// must choose one at the next sign-in, and the login screen collects it
// from the person who will use it.
function resetPassword(userName: string): void {
root.run(["reset-password", userName]);
}
function createUser(userName: string, realName: string, kind: string): void { function createUser(userName: string, realName: string, kind: string): void {
root.run(["create-user", userName, realName, kind]); root.run(["create-user", userName, realName, kind]);
} }
function deleteUser(userName: string, removeFiles: bool): void { // keepFiles, not removeFiles: the page asks "Keep the files" or "Remove
root.run(["delete-user", userName, removeFiles ? "remove-files" : "keep-files"]); // everything", and a service that inverted the sentence on its way to the
// helper is how the destructive answer gets chosen by accident.
function deleteUser(userName: string, keepFiles: bool): void {
root.run(["delete-user", userName, keepFiles ? "keep" : "remove"]);
} }
// The password goes to the helper's stdin and nowhere else: never an // The password goes to the helper's stdin and nowhere else: never an
@@ -177,6 +219,25 @@ Singleton {
onExited: root.pendingPassword = "" onExited: root.pendingPassword = ""
} }
Process {
id: stock
command: [root.helperPath, "stock-avatars"]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.stockAvatars = Array.isArray(parsed.avatars) ? parsed.avatars : [];
} catch (error) {
// A machine with no gallery is normal; an empty one reads
// the same to the page, and nothing else here depends on it.
root.stockAvatars = [];
console.warn("Accounts: could not read the stock avatars:", error);
}
root.stockAvatarsLoaded = true;
}
}
}
Process { Process {
id: mutation id: mutation
// The helper answers with the fresh state, so the page updates from the // The helper answers with the fresh state, so the page updates from the
@@ -5,6 +5,6 @@
# @vicinae.mode silent # @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg # @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Online Accounts in Settings. # @vicinae.description Open Online Accounts in Settings.
# @vicinae.keywords ["settings", "online accounts"] # @vicinae.keywords ["settings", "online accounts", "nextcloud", "google account", "add a mail account", "remove an account"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page accounts exec "$HOME/.config/quickshell/scripts/panama-action" settings-page accounts
@@ -5,6 +5,6 @@
# @vicinae.mode silent # @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg # @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Users in Settings. # @vicinae.description Open Users in Settings.
# @vicinae.keywords ["settings", "user account", "profile picture", "change password", "add a user", "automatic login", "administrator"] # @vicinae.keywords ["settings", "user account", "profile picture", "change password", "add a user", "automatic login", "administrator", "fingerprint", "enroll a fingerprint", "delete a user", "account type", "reset a password"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page users exec "$HOME/.config/quickshell/scripts/panama-action" settings-page users
@@ -898,3 +898,218 @@ run on their own, and both pass:
system ones (`disks-contract`, `snapshots-contract`, `containers-contract`), system ones (`disks-contract`, `snapshots-contract`, `containers-contract`),
then the harness ones (`settings-search-contract`, `health-ui-contract`), and then the harness ones (`settings-search-contract`, `health-ui-contract`), and
`settings-pages-contract` last, as before. `settings-pages-contract` last, as before.
## Phase 11 (Users & Accounts) — append below
Spec: `2026-08-24-users-accounts-redesign.md`. Users grew a profile hero with a
stock-avatar gallery and a Remove, a password flow with a strength meter, a
live-validating add-user form, a keep-files deletion choice, and per-user
management (type / reset password / unlock). Fingerprint enrollment stopped
handing people to GNOME's Users panel and became native — fprintd's
`EnrollStart` driven from `panama-fingerprint`, streaming one JSON line per
touch — and the card learned to appear in the state that needs acting on rather
than only when a reader is attached. Online Accounts gained native Nextcloud and
IMAP forms, confirmed removal, and an availability that no longer collapses on a
failed write.
Three agents edited the tree concurrently. Everything below was reconciled
against the landed files at the end of the phase, not against the spec's pinned
shapes: three of the needles moved during the phase (the card-visibility
expression went into the service as `cardVisible`, the add-user field became
`LiveFieldRow`, and the fingerprint helper became Python) and each is pinned
where it actually landed.
### New contracts (1)
`quickshell/online-accounts-contract`. The README count line moves
**171 → 172**; `setup/readme-contract` was run and passes ("172 contracts, as
documented").
### Run and passing
- **`quickshell/fingerprint-contract` — RUN END TO END, PASS.** The whole file
was reworked, because two of its needles inverted. Hermetic: `env -i`, a stub
directory first on `PATH` (asserted, before the helper runs, that
fprintd-list, authselect, sudo and pkexec all resolve inside it), both bus
addresses pointed at sockets that do not exist, `PANAMA_PATH` pointed at a
recording `panama-sudo` that runs **nothing**, and enrollment replayed from
`PANAMA_FINGERPRINT_FIXTURE` with the calls it stood in for landing in
`PANAMA_FINGERPRINT_LOG`. What it pins:
- **The inverted needle.** `openGnomePanel("system", "users")` is now a
failure rather than a requirement, checked across every settings page.
`gnome-handoff-contract` lost its `UsersPage.qml:system-users` exception at
the same time; this is the other half of that, said from the page's side.
- **The stuck state, from the data.** With the stub fprintd reporting no
devices and the stub authselect reporting `with-fingerprint`, `status` must
still answer `unlockFeatureEnabled: true` — the combination that used to be
unrepresentable, because the feature was only read when fprintd answered.
This machine is in that state, which is how it was found.
- **Card visibility followed one level of indirection.** The expression moved
into `services/Fingerprint.qml` as `cardVisible: readerPresent ||
unlockFeatureEnabled`; the contract accepts either the page spelling it out
or the page binding to a service property that does, and resolves the second
rather than forbidding it.
- **The enrollment stream as a stream.** A five-stage fixture with a retry in
the middle produces nine lines; every one must be a JSON object on its own,
the counter monotonic and inside `0..total`, `total` constant, and the
retried touch must **not** have advanced the counter — a panel that counts a
failed touch promises a finish that never arrives. The terminal line says
which way it ended.
- **Claim and Release bracket everything**, read from the call log: first call
`Claim`, last call `Release`, `EnrollStart` after the claim — including on a
failed enrollment, because a reader left claimed refuses the next attempt
with "busy with something else" forever.
- **A terminal failure is reported in words**, not in fprintd's vocabulary
(`enroll-duplicate` → "That finger is already enrolled").
- **Nonsense is refused before the reader is touched**: four bad finger names
against both `enroll` and `remove`, each of which must leave the call log
empty.
- **`remove` deletes the finger it was asked about**, and `remove-all` uses
`DeleteEnrolledFingers2` with no arguments — its predecessor took a user
name, which is how a typo deletes somebody else's prints.
- **With no fixture and no bus it still answers in its own shape**: the page
reads this stream line by line, and a traceback leaves an enrollment panel
open forever.
- **The stated reason survived the rewrite to Python**: the escalation must go
through `panama-sudo --reason …`, and a bare `sudo`/`pkexec` in the argv log
is a failure.
- **Finger vocabulary**: a single id elsewhere is a default choice and is
allowed; two in one file is a copy of the list, and the copy is what falls
behind.
- **`quickshell/user-accounts-contract` — RUN END TO END, PASS.** Its snapshot
half still reads the live account service, which is read-only; every verb that
would change something is pinned from the source and never invoked
(`set-icon gib ""` really does clear the avatar). New halves:
- **`reset-password` carries no password material**, by AST: it must call
`SetPasswordMode`, must resolve its mode argument to **1** (through the
named constant, so the meaning survives), must not call `SetPassword`, and
its body — docstring excluded, because the docstring is allowed to say the
word — must not mention stdin, openssl, crypt or passwd.
- **keep-files end to end, as three separate links**, because any one of them
can invert on its own: the helper derives `DeleteUser`'s boolean from its
own argument (a literal there is the bug), the service's ternary is checked
against the *polarity of its own parameter name* (Panama flipped this once
already, `removeFiles` → `keepFiles`), and the page passes a variable rather
than a constant. Both vocabularies are exercised — `keep`/`remove` and the
legacy `keep-files`/`remove-files` — by reading which refusal comes back,
so a vocabulary that stopped being understood shows up as the wrong message
rather than as no message.
- **`removeIcon` sends an empty path**, and `set_icon` recognizes it rather
than handing "" to GdkPixbuf, which would fail and leave the avatar.
- **The live-validation regression, two ways.** Structurally: the property the
form's user-name test reads must be assigned from a per-keystroke handler,
and the component that assigns it must not be `TextFieldRow`. Bluntly: the
page carries no `TextFieldRow` at all. The page's regex is compared
character for character against the helper's `USERNAME`, and the 31-cap must
appear in the page's own text — a form that accepts a name accountsservice
then refuses has no way to say why.
- **The last administrator is refused helper-side too**, not only by the page
that offers the control.
- **Stock avatars**: shape read from a real run (`{name, path}`, absolute
paths, empty list legitimate).
- **`quickshell/gnome-handoff-contract` — RUN, PASS** (9 handoffs, 39 pages).
The `UsersPage.qml:system-users` exception is deleted; the
`OnlineAccountsPage.qml:online-accounts` one stays with its reason rewritten
to name OAuth specifically — libgoa-backend ships without a GIR binding, so
the provider's own dialog is the only way to obtain a token, and Nextcloud and
IMAP are added on the page itself.
- **`setup/readme-contract` — RUN, PASS** after the count line moved.
### Run with one half relaxed (1)
- **`quickshell/online-accounts-contract` — everything passes except six
input-validation cases**, which are pinned as the spec asks and which the
landed `panama-accounts` does not yet satisfy. See "Still open" below. Run
with those six removed, the contract passes end to end. It is hermetic: `env
-i`, a stub directory first on `PATH` (gdbus/busctl/dbus-send/
gnome-control-center all resolve there, asserted before running), a `gi`
stand-in on `PYTHONPATH` whose `require_version` always raises — so if the
fixture seam were ever removed the contract would stop working rather than
quietly start editing the session's accounts — and both bus addresses pointed
at sockets that do not exist. Accounts come from `PANAMA_ACCOUNTS_FIXTURE`;
what each verb would have done lands in `PANAMA_ACCOUNTS_LOG`. What it pins:
- **Availability is not an error**, at the definition and at every assignment:
no line that decides `available` may mention a write error, and the service
must keep at least two error strings, or "GOA did not answer" and "that one
change did not happen" are the same fact. This is the regression where one
refused toggle replaced four working accounts with "not available".
- **The same thing from the data**: a refused add must come back with the
error *and* the full account list, because the page draws its list from the
same answer that carries the message.
- **The password on stdin, proved rather than assumed.** The log records the
password's **length**, never the thing itself, so the sentinel can be
checked for everywhere it might have gone (stdout, stderr, the log, the
scratch home) while `passwordBytes` proves it was read at all rather than
dropped.
- **The listing cannot carry a credential**, read from `describe`'s own dict
keys and from the GOA properties it reads, so a password property added
later is caught at the source rather than after it reaches the page.
- **An added account is proved, not assumed**: `call_add_account_sync` must be
followed by `call_ensure_credentials_sync`, and `verify` must be able to
`call_remove_sync` — GOA does not check what it is handed, so without that
step a mistyped password produces an account that exists, looks correct, and
never syncs, which is the exact failure this page exists to explain.
- **Provider types**: `owncloud` (the fork kept the id) and `imap_smtp`. A
provider type GOA does not know is not an error — AddAccount is simply never
offered it.
- **Both names for the listing** (`list` and `snapshot`) answer the same
thing, and reading the list changes nothing.
- **Removal carries the path intact** — removing the wrong account is
unrecoverable and looks like a success — and is confirmation-gated on the
page, read from the object block around the call rather than from a
`confirming` property declared anywhere in the file.
### Docs updated in the same wave
- `services/SettingsSearch.qml` — nine entries added, all routing to leaves that
exist in `SettingsRoutes`: **Fingerprint**, **Enroll a fingerprint**, **Delete
a user**, **Account type**, **Reset a password** → `users`; **Nextcloud**,
**Google account**, **Add a mail account**, **Remove an account** →
`accounts`. Checked by evaluating the array: 163 entries, no duplicate labels,
every `page` a real leaf, and none of `settings-search-contract`'s 21 ranked
queries or 9 leaf-routing queries changes its top result — the only new entry
any of them touches at all is Fingerprint, which matches `lock` through
"Unlock" and sits far below the existing first hit.
- No settings docs regenerated: this phase adds no schema keys. Every new
setting is system state (accountsservice, fprintd, authselect, GOA), not a
Panama preference — verified by reading the diff.
### Still open before the run
- **`online-accounts-contract` fails six validation cases against the landed
helper**, and they are left pinned rather than relaxed, because the spec asks
for them and each produces an account that exists, looks right in the list,
and never syncs. `nextcloud_uris` accepts anything with a host once `https://`
is prepended, and `imap_account` accepts any non-empty string as a server:
- `add-nextcloud` accepts `not a url`, `https://cloud.example.org; reboot`,
and `https://cloud example org`;
- `add-imap` accepts `imap example com`, `imap.example.com; reboot`, and
`-imap.example.com` as either server.
A hostname check in `nextcloud_uris` and one applied to both hosts in
`imap_account` closes all six. `''`, `ftp://…` and `https://` (no host) are
already refused, and every other assertion in the file passes.
- **`settings-search-contract` has not been run**: it starts a Quickshell
harness. The nine new entries were checked statically as described above.
- **`settings-pages-contract`, `settings-docs-contract`, `settings-jump-contract`
and `settings-buttons-contract` were not run against the two rebuilt pages.**
Six new components landed (LiveFieldRow, SecretFieldRow, PasswordStrengthRow,
StockAvatarPicker, FingerprintEnrollPanel, OnlineAccountRow) and are
registered in `qmldir`, but nothing here has loaded the QML.
- **The enrollment cancel path is pinned only from the source.** The canned
fprintd has no main loop, so `SIGTERM` releasing the device is exercised by
neither half; what is pinned is that the release happens in a `finally` and
that a *failed* enrollment still releases.
- **`user-accounts-contract`'s first half reads the live account service**
(`panama-users snapshot`, `stock-avatars`), so it wants the same quiet moment
the other system contracts do, though nothing it does writes.
- **Seams pinned by name**: `PANAMA_FINGERPRINT_FIXTURE`/`_LOG`,
`PANAMA_ACCOUNTS_FIXTURE`/`_LOG`, and the log shapes
(`{method, arguments}` and `{verb, arguments, passwordBytes}`). Changing
either is meant to be a deliberate act that updates these contracts.
- Run order for this phase: the hermetic ones first (`fingerprint-contract`,
`online-accounts-contract`), then the source-only ones
(`gnome-handoff-contract`, `setup/readme-contract`,
`settings-ownership-contract`, `search-routing-contract`), then the read-only
system one (`user-accounts-contract`), then the harness ones
(`settings-search-contract`), and `settings-pages-contract` last, as before.
@@ -0,0 +1,171 @@
# Users & Accounts redesign — identity, honestly
Approved mock: `home-mocks/users.html` (scratchpad, :8642). Spec wins over mock on conflict.
## Goals
1. **Users**: profile hero (stock avatar gallery + file + remove), password change with strength
meter, live-validating add-user form, keep-files deletion choice, per-user management
(type / reset password / unlock), auto-login with the tradeoff stated.
2. **Fingerprint**: native guided enrollment (fprintd D-Bus), enrolled-finger list + removal,
and the stuck state made reachable — the card renders when the authselect feature is on
even with no reader, offering Turn off.
3. **Online Accounts**: "This desktop" cards (Home Assistant, phone) leading; GOA account cards
with confirmed removal and busy states; native add for password providers (Nextcloud, IMAP);
OAuth remains the single honest handoff. Page-level availability stops being gated on
transient write errors.
Non-goals: camera avatar capture, OAuth without GOA's dialog (impossible — no GIR), parental
controls, group management beyond account type, username changes.
## Helper extensions (pinned — as built)
**`scripts/panama-users`** (Python, unchanged language). Verbs:
`snapshot` · `stock-avatars` · `set-real-name USER NAME` · `set-icon USER PATH [X Y SIZE]` ·
`set-account-type USER standard|administrator` · `set-automatic-login USER true|false` ·
`set-locked USER true|false` · `set-password USER` (stdin) · `reset-password USER` ·
`create-user USERNAME REALNAME TYPE` · `delete-user USERNAME keep|remove`.
- `set-icon USER ""` clears the avatar (`SetIconFile("")`) — no separate verb.
- `stock-avatars` prints its own shape: `{"avatars":[{"name","path"}],"error":""}`. Absolute
paths, sorted by name; a missing `/usr/share/pixmaps/faces` yields `[]`. Fedora ships 15.
- `delete-user` takes `keep|remove`; the older `keep-files|remove-files` spelling still works.
- `reset-password USER``SetPasswordMode(1)`. No password material anywhere in the path.
- `set-account-type` now carries the last-admin refusal **in the helper** (same sentence as
`delete-user`), so a page that forgets the guard cannot demote the only administrator.
- Every mutating verb still answers with the fresh snapshot plus `error`, exit 0.
**`scripts/panama-fingerprint`** — **converted bash → Python** (a D-Bus signal loop is not a
thing bash can do). `status` and `set-unlock` keep their exact CLI and JSON, and still shell
out to `fprintd-list` / `authselect` / `panama-sudo --reason`, so the existing PATH-stub seam
is untouched. Verbs: `status` · `set-unlock on|off` · `enroll FINGER` · `remove FINGER` ·
`remove-all`.
- `status``{reader, readerName, enrolled, pamEnabled, unlockFeatureEnabled, error}`.
`pamEnabled` and `unlockFeatureEnabled` are the same fact under the old and new name.
**Behaviour change for C**: authselect is now parsed unconditionally, so a readerless
machine reports `pamEnabled: true` when the feature is on. That used to be forced to
`false`, which is what made the stuck state unreachable. (This machine is in it.)
- `enroll FINGER` streams one JSON object per line:
`{ok, stage, done, total, result, error}` with `stage ∈ claiming|scanning|done|failed`.
fprintd `Claim``EnrollStart` → one line per `EnrollStatus``EnrollStop``Release`.
Cancellation is SIGTERM (stopping the Process); the helper still releases the device.
Exit 0 on `enroll-completed`, 1 otherwise. 90s idle watchdog.
- `remove FINGER` / `remove-all``DeleteEnrolledFinger` / `DeleteEnrolledFingers2` under a
Claim, and answer with the **`status` shape** so the service has one absorb path.
- Finger vocabulary lives in `services/Fingerprint.qml` (`allFingers`, `fingerLabel`); the
helper only shape-checks the name.
- Fixture seam: `PANAMA_FINGERPRINT_FIXTURE` = JSON file
`{"enrollStages":N,"results":["enroll-stage-passed",…],"error":""}`; every D-Bus call it
stands in for is appended to `PANAMA_FINGERPRINT_LOG` (default `<fixture>.log`) as
`{"method","arguments"}`. No bus, no gi, no reader.
**`scripts/panama-accounts`**: `list` **and `snapshot`** (alias; the service asks for
`snapshot`) · `set` · `remove` · `add-nextcloud SERVER USERNAME` ·
`add-imap EMAIL IMAP_HOST SMTP_HOST USERNAME`. Passwords on stdin, never argv. Both adds
answer with `{"accounts":[…],"error":""}`.
- **GOA's add flow, verified**: `Goa.Manager.AddAccount(provider, identity,
presentation_identity, credentials a{sv}, details a{ss}) → o` is fully daemon-side —
goa-daemon writes `accounts.conf` and calls `goa_utils_store_credentials_for_id_sync`
itself. The missing libgoa-backend GIR blocks only the *client-side sign-in dialog*, so
password providers are genuinely addable and OAuth genuinely is not. `IsSupportedProvider`
is checked first (`owncloud`, `imap_smtp` both true here; `nextcloud` is not a provider
type — `owncloud` is).
- Keys are GOA 3.58's own, taken from `goaowncloudprovider.c` / `goaimapsmtpprovider.c` and
confirmed against this machine's `accounts.conf`. Nextcloud: credentials `{password}`,
details `Uri` (`…/remote.php/webdav/`), `FilesEnabled`, `CalendarEnabled`, `CalDavUri`
(`…/remote.php/dav/`), `ContactsEnabled`, `CardDavUri`, `AcceptSslErrors`. IMAP:
credentials `{imap-password, smtp-password}`, details `Enabled`, `EmailAddress`, `Name`,
`Imap{Host,UserName,UseSsl,UseTls,AcceptSslErrors}`,
`Smtp{Host,UseAuth,UserName,AuthLogin,AuthPlain,UseSsl,UseTls,AcceptSslErrors}` — IMAPS +
submission/STARTTLS defaults, one password for both servers.
- The daemon validates nothing, so each add calls `EnsureCredentials` on the new object and
**removes the account again** if the credentials are refused, reporting why.
- Fixture seam: `PANAMA_ACCOUNTS_FIXTURE` = a JSON file in `list`'s shape. With it set nothing
imports gi; validation still runs; each verb appends `{"verb","arguments","passwordBytes"}`
to `PANAMA_ACCOUNTS_LOG` (default `<fixture>.log`) — the password's **length**, never itself.
## Services (A — as built)
- **UserAccounts**: `removeIcon()` (no argument — the hero card's own avatar),
`stockAvatars` + `loadStockAvatars()` (cached; call it when the gallery opens),
`deleteUser(user, keepFiles)` — **the bool inverted from the old `removeFiles`** —
`resetPassword(user)`, `setAccountTypeFor(user, type)` (**renamed** from `setAccountType`),
`setLocked(user, locked)`. `locked` and `loginTime` stay in the payload for the UI.
- **Fingerprint**: `readerPresent`, `readerName`, `unlockFeatureEnabled`, `fingers`
(**renamed** from `enrolled`), `allFingers`, `availableFingers`, `fingerLabel(f)`,
`cardVisible` (= `readerPresent || unlockFeatureEnabled`), `busy`, `lastError`,
`enrolling`, `enrollPhase`, `enrollFinger`, `enrollStage`, `enrollTotal`, `enrollResult`,
`refresh()`, `setUnlockEnabled(on)`, `startEnroll(finger)`, `cancelEnroll()`,
`removeFinger(f)`, `removeAll()`.
- **OnlineAccounts**: `available` (GOA answered the last snapshot — persisted, never touched
by a write), `snapshotError` / `writeError` and the derived `lastError`, `busy` (derived
from the Processes), `attentionCount`, `refresh()` (cheap; call on every page open),
`setService`, `remove`, `addNextcloud(server, user, password)`,
`addImap(email, imapHost, smtpHost, user, password)` — password via Process stdin.
## UI (B)
**UsersPage.qml** rebuilt per mock: hero card (avatar, Change picture… opens a popover with
stock gallery + "Choose a file…" → existing AvatarPicker/Cropper flow, Remove button);
Account card (Full name — live-committing field, Username honest row, Account type segment
with the last-admin reason as detail, Password flow with a 4-segment strength meter — local
heuristic, length + classes, no network; Automatic login with the mock's tradeoff copy);
Fingerprint card per mock (stuck row with NO READER badge + Turn off when
`unlockFeatureEnabled && !readerPresent`; full experience otherwise: toggle, enrolled list
with per-finger Remove…, Add a fingerprint → inline enrollment panel driven by the enroll
stream — touch icon, stage counter "N of M touches", cancel; no continuous animation);
Other accounts card (expandable per-user rows: type segment, Reset password ("They set a new
one at next sign-in"), Delete with a keep-files dropdown [Keep the files / Remove everything]
+ two-stage confirm; locked users show Unlock; add-user form with LIVE validation — use live
TextFields, not blur-committing TextFieldRow, with the username rules + 31-char cap in the
detail and mirrored in the enabled predicate).
**OnlineAccountsPage.qml** rebuilt: gains `objectName: "accounts"`; "This desktop" card —
Home Assistant row (state from the HomeAssistant service: connected/rooms, else "not set up",
button → `openSettings("my-home")`) and Phone row (KdeConnect state, → `openSettings("phone")`);
Accounts card — one expandable row per GOA account (per-service toggles disabled while
`busy`, Remove with two-stage confirm), needs-attention rows keep "Sign in again"; Add card —
Nextcloud + Mail native forms (server/user/password, password field cleared on success or
close), and the OAuth row with the mock's honest copy → the existing GNOME dialog handoff
(the ALLOWED exception stays for accounts). Errors render as a row, never as page-wide
unavailability.
## Search & docs (C)
New entries: Fingerprint, Enroll a fingerprint, Delete a user, Account type, Reset a password
→ users; Nextcloud, Google account, Remove an account, Add a mail account → accounts. Docs
regen only if schema changes (none expected — verify).
## Contracts (C — write; hermetic stub runs only)
- `user-accounts-contract`: extend — keep-files arg end to end, reset-password sends no
password material (SetPasswordMode pinned), remove-icon empty-string call, stock list shape,
live-validation predicate present in the page (the blur-commit bug regression pin: the add
form's fields must not be TextFieldRow).
- `fingerprint-contract`: major rework — enrollment is native now (streamed stages against a
stubbed fprintd D-Bus layer or a stub helper — follow the hermetic pattern), the GNOME
enrollment handoff needle INVERTS (page must NOT call openGnomePanel for users), stuck-state
visibility pinned (`readerPresent || unlockFeatureEnabled`), finger vocabulary single-source
kept, set-unlock reason kept.
- `gnome-handoff-contract`: delete the `UsersPage.qml:system-users` ALLOWED exception; the
`OnlineAccountsPage.qml:online-accounts` exception stays with its reason updated to name
OAuth specifically.
- NEW `online-accounts-contract`: hermetic — availability decoupled from write errors,
passwords via stdin never argv, add flows validate inputs, remove requires the confirm
state, snapshot exposes no secrets.
- Backlog Phase 11; README count line (171 → 172 expected).
## Agent ownership (parallel)
- **A**: `scripts/panama-users`, `scripts/panama-fingerprint`, `scripts/panama-accounts`,
`services/UserAccounts.qml`, `services/Fingerprint.qml`, `services/OnlineAccounts.qml`.
- **B**: `modules/settings/UsersPage.qml`, `OnlineAccountsPage.qml`, new components
(+ qmldir); may read AvatarPicker/AvatarCropper and reuse unchanged.
- **C**: `services/SettingsSearch.qml`, contracts above, backlog, README count line.
Hard rules: NO live mutations — no user/password/icon/type changes, no GOA account writes,
no authselect, no fprintd enrollment against real hardware (none exists here anyway), no
polkit-triggering calls. Read-only probes and stubs only. Valid QML/Python at every save.
B programs against the pinned APIs; A updates this spec before changing them.
+434 -43
View File
@@ -6,48 +6,188 @@
# nothing, and that silence -- "I enrolled a finger and nothing happened" -- # nothing, and that silence -- "I enrolled a finger and nothing happened" --
# is the failure this card exists to name. # is the failure this card exists to name.
# #
# The helper is the parse surface, so it runs for real against stub fprintd # Two things changed, and each inverts a pin this file used to hold.
# and authselect; the page and service checks are structural. #
# Enrollment is Panama's now. It used to open GNOME's Users panel, which was
# honest while fprintd's guided capture was the only thing there; the helper
# drives EnrollStart itself and streams a touch counter, so the handoff is the
# thing that would now be wrong. The needle is inverted rather than deleted:
# the page must NOT open that panel.
#
# And the card is no longer hidden by the absence of a reader. `pamEnabled`
# used to be reported as false whenever fprintd had nothing to say, so the one
# state a person genuinely has to act on -- the PAM feature switched on, no
# reader attached, every unlock now waiting on a device that is not there --
# rendered as no card at all. The two facts are read independently, and the
# card appears when either is true.
#
# SAFETY. Enrollment claims a real device and authselect rewrites PAM, so
# nothing here may reach either. `env -i` with a stub directory first on PATH,
# both bus addresses pointed at sockets that do not exist, PANAMA_PATH pointed
# at a recording panama-sudo that runs nothing, and the enrollment stream
# replayed from PANAMA_FINGERPRINT_FIXTURE -- a canned fprintd whose calls land
# in a log file instead of on the bus. The PATH claim is asserted before the
# helper is run at all.
set -uo pipefail set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-fingerprint" helper="$repo_dir/config/dot/quickshell/scripts/panama-fingerprint"
service="$repo_dir/config/dot/quickshell/services/Fingerprint.qml" service="$repo_dir/config/dot/quickshell/services/Fingerprint.qml"
page="$repo_dir/config/dot/quickshell/modules/settings/UsersPage.qml" settings_dir="$repo_dir/config/dot/quickshell/modules/settings"
page="$settings_dir/UsersPage.qml"
fail() { fail() {
printf 'fingerprint contract: %s\n' "$1" >&2 printf 'fingerprint contract: %s\n' "$1" >&2
exit 1 exit 1
} }
# ── Wiring ─────────────────────────────────────────────────────────────────── for path in "$helper" "$service" "$page"; do
[[ -r "$path" ]] || fail "missing $path"
done
[[ -x "$helper" ]] || fail 'panama-fingerprint is not executable'
rg -Fq 'visible: Fingerprint.readerPresent' "$page" \ file_calling() {
|| fail 'the card is not hidden on machines with no reader' grep -rl --include='*.qml' -F "$1" "$settings_dir" | head -1
rg -Fq 'onToggled: value => Fingerprint.setUnlockEnabled(value)' "$page" \ }
# ── Enrollment stays here ────────────────────────────────────────────────────
#
# The inverted needle. gnome-handoff-contract lost its UsersPage exception at
# the same time; this is the other half, said from the page's side.
grep -rn --include='*.qml' -E 'openGnomePanel\("(system", *"users|users")' "$settings_dir" \
&& fail 'a settings page still sends fingerprint enrollment to GNOME Users, which Panama owns'
enroll_page="$(file_calling 'Fingerprint.startEnroll(')"
[[ -n "$enroll_page" ]] || fail 'nothing starts an enrollment'
grep -q 'Fingerprint.cancelEnroll()' "$enroll_page" \
|| fail 'an enrollment in progress cannot be cancelled, so the reader stays claimed'
grep -qE 'Fingerprint\.(removeFinger|removeAll)\(' "$enroll_page" \
|| fail 'an enrolled finger cannot be removed'
# ── The card is visible in the state that needs acting on ────────────────────
#
# `readerPresent || unlockFeatureEnabled`. Either alone hides the stuck state:
# the feature on with no reader is invisible under the first, and a machine
# with a reader and nothing configured is invisible under the second.
python3 - "$page" "$service" <<'PY' || fail 'the fingerprint card is not visible in the state that needs acting on'
import re
import sys
page = open(sys.argv[1], encoding="utf-8").read()
service = open(sys.argv[2], encoding="utf-8").read()
DISJUNCTION = re.compile(
r"readerPresent\s*\|\|\s*(root\.)?unlockFeatureEnabled"
r"|unlockFeatureEnabled\s*\|\|\s*(root\.)?readerPresent")
visible = [line for line in page.splitlines()
if "visible:" in line and "Fingerprint." in line]
if not visible:
print("nothing decides whether the fingerprint card is shown", file=sys.stderr)
raise SystemExit(1)
card = visible[0]
# Either the page spells it out, or it binds to a service property that does.
# The second is better -- one expression, so the card and the state it
# describes cannot disagree -- so the indirection is followed rather than
# forbidden.
if DISJUNCTION.search(card):
raise SystemExit(0)
named = re.search(r"visible:\s*Fingerprint\.(\w+)", card)
if named:
definition = re.search(rf"property bool {named.group(1)}:([^\n]*)", service)
if definition and DISJUNCTION.search(definition.group(1)):
raise SystemExit(0)
print(f"visible: Fingerprint.{named.group(1)}, which is "
f"{definition.group(1).strip() if definition else 'not defined'}", file=sys.stderr)
raise SystemExit(1)
print(card.strip(), file=sys.stderr)
raise SystemExit(1)
PY
# And it says why it is there. A card that appears on a machine with no reader
# and offers nothing but a switch reads as a bug in the card.
stuck_page="$(file_calling 'Fingerprint.unlockFeatureEnabled')"
[[ -n "$stuck_page" ]] || fail 'no page reads the unlock feature independently of the reader'
grep -qiE 'no reader' "$stuck_page" \
|| fail 'the stuck state does not say there is no reader'
grep -qE 'Turn off' "$stuck_page" \
|| fail 'the stuck state offers no way out of it'
grep -Fq 'onToggled: value => Fingerprint.setUnlockEnabled(value)' "$page" \
|| fail 'the unlock switch does not drive authselect' || fail 'the unlock switch does not drive authselect'
rg -Fq 'SystemSettings.openGnomePanel("system", "users")' "$page" \ grep -Fq 'Fingerprint.refresh()' "$page" \
|| fail 'enrollment does not hand off to the GNOME Users panel'
rg -Fq 'Fingerprint.refresh()' "$page" \
|| fail 'the page never reads the fingerprint state' || fail 'the page never reads the fingerprint state'
rg -Fq 'authselect' "$helper" && rg -Fq 'with-fingerprint' "$helper" \
|| fail 'the helper does not manage the authselect feature' # ── A finger is named in exactly one place ───────────────────────────────────
rg -Fq -- '--reason' "$helper" \ #
|| fail 'the privileged change carries no stated reason' # fprintd's vocabulary is "right-index-finger". What that READS as is a
rg -Fq 'function fingerLabel' "$service" \ # presentation decision, and two copies of it disagree the first time one is
# edited -- so the service presents, the helper only validates, and no page
# carries a finger name of its own.
grep -q 'function fingerLabel' "$service" \
|| fail 'finger names have no single place to be presented from' || fail 'finger names have no single place to be presented from'
python3 - "$service" "$settings_dir" "$repo_dir/config/dot/quickshell/services" <<'PY' \
|| fail 'fprintd vocabulary is spelled out in more than one place'
import pathlib
import re
import sys
# ── The helper against stub fprintd and authselect ─────────────────────────── FINGER = re.compile(r'"(?:left|right)-(?:thumb|(?:index|middle|ring|little)-finger)"')
owner = pathlib.Path(sys.argv[1]).resolve()
stub_dir="$(mktemp -d)" # A single id elsewhere is a default choice -- "start on the right index
state_dir="$(mktemp -d)" # finger" -- and that is allowed. A second one is a copy of the list, and the
trap 'rm -rf "$stub_dir" "$state_dir"' EXIT # copy is what falls behind: the one in Fingerprint.qml grows a finger, the
# other does not, and half the interface offers nine.
for directory in sys.argv[2:]:
for path in sorted(pathlib.Path(directory).rglob("*.qml")):
if path.resolve() == owner:
continue
found = set(FINGER.findall(path.read_text(encoding="utf-8")))
if len(found) > 1:
print(f"{path}: {sorted(found)}", file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
# The service carries the enrollment state the panel counts with.
for property in unlockFeatureEnabled enrolling enrollStage enrollTotal; do
grep -qE "property (bool|int|var|string) $property" "$service" \
|| fail "the service does not expose $property, so the page cannot show the enrollment"
done
# ── The privileged change still says why ─────────────────────────────────────
grep -Fq 'authselect' "$helper" && grep -Fq 'with-fingerprint' "$helper" \
|| fail 'the helper does not manage the authselect feature'
grep -Fq -- '--reason' "$helper" \
|| fail 'the privileged change carries no stated reason'
# ── The helper cannot walk past the stubs ────────────────────────────────────
absolute="$(grep -nE '"/(usr/)?s?bin/[a-z-]+"' "$helper")"
[[ -z "$absolute" ]] \
|| fail "the helper names a binary by absolute path, so PATH stubs cannot contain it: $absolute"
command -v jq >/dev/null 2>&1 || { printf 'fingerprint contract: SKIP (no jq)\n'; exit 0; }
command -v python3 >/dev/null 2>&1 || { printf 'fingerprint contract: SKIP (no python3)\n'; exit 0; }
# ── The fake machine ─────────────────────────────────────────────────────────
work="$(mktemp -d /tmp/panama-fingerprint-contract.XXXXXX)"
stub_dir="$work/bin"
state_dir="$work/state"
home_dir="$work/home"
run_dir="$work/run"
panama_dir="$work/panama/bin"
mkdir -p "$stub_dir" "$state_dir" "$home_dir" "$run_dir" "$panama_dir"
trap 'rm -rf "$work"' EXIT
cat >"$stub_dir/fprintd-list" <<STUB cat >"$stub_dir/fprintd-list" <<STUB
#!/usr/bin/env bash #!/usr/bin/env bash
printf 'fprintd-list %s\n' "\$*" >>"$state_dir/argv"
if [[ -e "$state_dir/no-reader" ]]; then if [[ -e "$state_dir/no-reader" ]]; then
echo 'Impossible to enumerate devices: No devices available' echo 'Impossible to enumerate devices: No devices available' >&2
exit 1 exit 1
fi fi
cat <<'OUT' cat <<'OUT'
@@ -62,49 +202,300 @@ STUB
cat >"$stub_dir/authselect" <<STUB cat >"$stub_dir/authselect" <<STUB
#!/usr/bin/env bash #!/usr/bin/env bash
echo "\$*" >>"$state_dir/authselect-log" printf 'authselect %s\n' "\$*" >>"$state_dir/argv"
if [[ "\$1" == "current" ]]; then if [[ "\$1" == "current" ]]; then
echo 'Profile ID: local' echo 'Profile ID: local'
[[ -e "$state_dir/pam-on" ]] && echo '- with-fingerprint' [[ -e "$state_dir/feature-on" ]] && echo '- with-fingerprint'
exit 0 exit 0
fi fi
printf 'fingerprint contract: authselect was asked to change PAM\n' >&2
exit 1
STUB STUB
# PANAMA_PATH pointed at an empty directory forces the plain-sudo fallback, # Records the escalation and runs NOTHING. The reason is the point: this page's
# which the stub records instead of escalating. # prompt has to say what it is about to do, and the only way to see that is to
cat >"$stub_dir/sudo" <<STUB # catch the arguments before they become an authselect invocation.
cat >"$panama_dir/panama-sudo" <<STUB
#!/usr/bin/env bash #!/usr/bin/env bash
echo "\$*" >>"$state_dir/sudo-log" printf 'panama-sudo %s\n' "\$*" >>"$state_dir/argv"
exec "\$@" exit 0
STUB STUB
chmod +x "$stub_dir"/fprintd-list "$stub_dir"/authselect "$stub_dir"/sudo
run() { PANAMA_PATH="$state_dir" PATH="$stub_dir:$PATH" "$helper" "$@"; } # Anything else that could reach the machine is closed rather than left open.
for blocked in sudo pkexec gdbus busctl dbus-send fprintd-enroll fprintd-delete; do
cat >"$stub_dir/$blocked" <<STUB
#!/usr/bin/env bash
printf '$blocked %s\n' "\$*" >>"$state_dir/argv"
printf 'fingerprint contract: the helper reached for $blocked\n' >&2
exit 1
STUB
done
chmod +x "$stub_dir"/* "$panama_dir/panama-sudo"
runh() {
env -i \
PATH="$stub_dir:/usr/bin:/bin" \
HOME="$home_dir" \
USER=gib \
XDG_RUNTIME_DIR="$run_dir" \
PANAMA_PATH="$work/panama" \
DBUS_SESSION_BUS_ADDRESS="unix:path=$run_dir/absent-session-bus" \
DBUS_SYSTEM_BUS_ADDRESS="unix:path=$run_dir/absent-system-bus" \
LANG=C LC_ALL=C \
"$@"
}
run() { runh "$helper" "$@"; }
# Enrollment and removal replay a canned fprintd; nothing they do reaches a bus.
fixture="$work/fprintd.json"
log="$work/fprintd.log"
runf() {
runh env PANAMA_FINGERPRINT_FIXTURE="$fixture" PANAMA_FINGERPRINT_LOG="$log" \
"$helper" "$@"
}
# The safety claim, verified rather than assumed.
for binary in fprintd-list authselect sudo pkexec; do
resolved="$(env -i PATH="$stub_dir:/usr/bin:/bin" bash -c "command -v $binary" || true)"
[[ "$resolved" == "$stub_dir/$binary" ]] \
|| fail "$binary resolves to '$resolved', not the stub; refusing to run against the real one"
done
# ── status: two facts, read independently ────────────────────────────────────
status="$(run status)" status="$(run status)"
jq -e '.reader and .readerName == "Goodix MOC Fingerprint Sensor"' <<<"$status" >/dev/null \ jq -e '.reader and .readerName == "Goodix MOC Fingerprint Sensor"' <<<"$status" >/dev/null \
|| fail "the reader name did not parse: $status" || fail "the reader name did not parse: $status"
jq -e '.enrolled == ["right-index-finger", "left-thumb"]' <<<"$status" >/dev/null \ jq -e '.enrolled == ["right-index-finger", "left-thumb"]' <<<"$status" >/dev/null \
|| fail "enrolled fingers did not parse: $status" || fail "enrolled fingers did not parse: $status"
jq -e '.pamEnabled == false and .error == ""' <<<"$status" >/dev/null \ jq -e '.unlockFeatureEnabled == false and .error == ""' <<<"$status" >/dev/null \
|| fail "authselect state misread as enabled: $status" || fail "authselect state misread as enabled: $status"
# The old name is still answered, because the service and this file both read
# it and a silent rename would report "off" forever.
jq -e '.pamEnabled == .unlockFeatureEnabled' <<<"$status" >/dev/null \
|| fail "the two names for the PAM feature disagree: $status"
touch "$state_dir/pam-on" touch "$state_dir/feature-on"
jq -e '.pamEnabled == true' <<<"$(run status)" >/dev/null \ jq -e '.unlockFeatureEnabled == true' <<<"$(run status)" >/dev/null \
|| fail 'with-fingerprint enabled was not detected' || fail 'with-fingerprint enabled was not detected'
# No reader is a normal machine, not an error. # THE regression. No reader, feature on: the state that used to render as no
# card at all, because the feature was only read when fprintd answered.
touch "$state_dir/no-reader" touch "$state_dir/no-reader"
jq -e '.reader == false and .error == ""' <<<"$(run status)" >/dev/null \ stuck="$(run status)"
|| fail "a readerless machine was reported as a problem: $(run status)" jq -e '.reader == false and .error == ""' <<<"$stuck" >/dev/null \
rm -f "$state_dir/no-reader" || fail "a readerless machine was reported as a problem: $stuck"
jq -e '.unlockFeatureEnabled == true' <<<"$stuck" >/dev/null \
|| fail "the PAM feature was not read on a machine with no reader, which is the one state somebody has to be able to turn off: $stuck"
jq -e '.enrolled == []' <<<"$stuck" >/dev/null \
|| fail "a machine with no reader reported enrolled fingers: $stuck"
rm -f "$state_dir/no-reader" "$state_dir/feature-on"
# The privileged change goes through, with the right feature name. # ── The privileged change goes through, with the right feature and a reason ──
: >"$state_dir/argv"
run set-unlock on >/dev/null run set-unlock on >/dev/null
grep -Fq 'authselect enable-feature with-fingerprint' "$state_dir/sudo-log" \ grep -Fq 'authselect enable-feature with-fingerprint' "$state_dir/argv" \
|| fail 'set-unlock on did not enable the authselect feature' || fail "set-unlock on did not enable the authselect feature: $(cat "$state_dir/argv")"
run set-unlock off >/dev/null grep -qE 'panama-sudo --reason .*fingerprint' "$state_dir/argv" \
grep -Fq 'authselect disable-feature with-fingerprint' "$state_dir/sudo-log" \ || fail 'the prompt does not say what it is about to do'
|| fail 'set-unlock off did not disable the authselect feature' grep -Eq '^(sudo|pkexec) ' "$state_dir/argv" \
&& fail 'the privileged change escalated without a stated reason'
printf 'fingerprint contract: ok\n' : >"$state_dir/argv"
run set-unlock off >/dev/null
grep -Fq 'authselect disable-feature with-fingerprint' "$state_dir/argv" \
|| fail 'set-unlock off did not disable the authselect feature'
run set-unlock sideways >/dev/null 2>&1 \
&& fail 'set-unlock accepted something that is neither on nor off'
# ── enroll: a stream, one line per touch ─────────────────────────────────────
#
# The whole point of doing this natively: the page counts touches while they
# happen. So the stream is read as a stream -- every line valid JSON on its own,
# the counter monotonic and bounded, and a terminal line that says which way it
# ended.
printf '{"enrollStages":5,"results":["enroll-stage-passed","enroll-stage-passed","enroll-retry-scan-too-short","enroll-stage-passed","enroll-stage-passed","enroll-stage-passed","enroll-completed"],"error":""}\n' \
>"$fixture"
: >"$log"
runf enroll right-index-finger >"$work/stream.jsonl" \
|| fail "a completed enrollment exited nonzero: $(cat "$work/stream.jsonl")"
python3 - "$work/stream.jsonl" <<'PY' || fail 'the enrollment stream is not a usable progress stream'
import json
import sys
lines = [line for line in open(sys.argv[1], encoding="utf-8").read().splitlines() if line.strip()]
if len(lines) < 3:
print(f"only {len(lines)} line(s) of progress", file=sys.stderr)
raise SystemExit(1)
events = []
for line in lines:
try:
events.append(json.loads(line))
except ValueError:
print(f"not a JSON object on its own line: {line}", file=sys.stderr)
raise SystemExit(1)
for event in events:
for field in ("ok", "stage", "done", "total", "result"):
if field not in event:
print(f"a progress line is missing {field}: {event}", file=sys.stderr)
raise SystemExit(1)
if event["total"] != 5:
print(f"the touch count changed mid-enrollment: {event}", file=sys.stderr)
raise SystemExit(1)
if not 0 <= event["done"] <= event["total"]:
print(f"the counter left its own range: {event}", file=sys.stderr)
raise SystemExit(1)
counts = [event["done"] for event in events]
if counts != sorted(counts):
print(f"the counter went backwards: {counts}", file=sys.stderr)
raise SystemExit(1)
# A retry is a touch that did NOT count. If it advanced the counter, the panel
# would promise a finish that never arrives.
retries = [event for event in events if "retry" in str(event["result"])]
if not retries:
print("the failed touch produced no line at all, so the panel says nothing",
file=sys.stderr)
raise SystemExit(1)
if retries[0]["done"] != 2:
print(f"a retried touch was counted as progress: {retries[0]}", file=sys.stderr)
raise SystemExit(1)
last = events[-1]
if not last["ok"] or last["done"] != last["total"]:
print(f"a completed enrollment did not finish full: {last}", file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
# The device is claimed and released around it, in that order. A reader left
# claimed by a crashed enrollment refuses the next one, and the message a
# person gets is "busy with something else" forever.
python3 - "$log" <<'PY' || fail 'the reader is not claimed and released around an enrollment'
import json
import sys
calls = [json.loads(line)["method"] for line in open(sys.argv[1], encoding="utf-8") if line.strip()]
if not calls:
print("nothing was called at all", file=sys.stderr)
raise SystemExit(1)
if calls[0] != "Claim":
print(f"the device was used before it was claimed: {calls}", file=sys.stderr)
raise SystemExit(1)
if calls[-1] != "Release":
print(f"the device was left claimed: {calls}", file=sys.stderr)
raise SystemExit(1)
if "EnrollStart" not in calls:
print(f"nothing started an enrollment: {calls}", file=sys.stderr)
raise SystemExit(1)
if calls.index("EnrollStart") < calls.index("Claim"):
print(f"enrollment started before the claim: {calls}", file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
# A finger already on the reader ends the stream badly, and says so in words
# rather than in fprintd's vocabulary.
printf '{"enrollStages":5,"results":["enroll-stage-passed","enroll-duplicate"],"error":""}\n' \
>"$fixture"
: >"$log"
duplicate="$(runf enroll left-thumb)" && fail 'a failed enrollment exited zero'
last="$(tail -1 <<<"$duplicate")"
jq -e '.ok == false and .stage == "failed"' <<<"$last" >/dev/null \
|| fail "a failed enrollment did not end in a failure line: $last"
jq -e '(.error | length) > 0 and (.error | test("[a-z] [a-z]"))' <<<"$last" >/dev/null \
|| fail "the failure is reported in fprintd's vocabulary rather than in words: $last"
grep -Fq '"Release"' "$log" \
|| fail 'a failed enrollment left the reader claimed'
# A refused claim -- the reader busy with the lock screen, most often -- is one
# line, not a hang.
printf '{"enrollStages":5,"results":[],"error":"The fingerprint reader is busy with something else."}\n' \
>"$fixture"
: >"$log"
refused="$(runf enroll right-thumb)" && fail 'a refused claim exited zero'
jq -e '.ok == false and (.error | length) > 0' <<<"$(tail -1 <<<"$refused")" >/dev/null \
|| fail "a refused claim did not report a failure: $refused"
# ── Nonsense is refused before the reader is touched ─────────────────────────
printf '{"enrollStages":5,"results":["enroll-completed"],"error":""}\n' >"$fixture"
for bad in 'third-eye' 'right-index-finger; reboot' '' 'left-thumb-finger'; do
: >"$log"
refusal="$(runf enroll "$bad" 2>/dev/null)" \
&& fail "enroll accepted ${bad@Q} as a finger"
jq -e '.ok == false and (.error | length) > 0' <<<"$(tail -1 <<<"$refusal")" >/dev/null \
|| fail "a bad finger name did not come back as a stream failure: $refusal"
[[ ! -s "$log" ]] \
|| fail "a bad finger name claimed the reader before being refused: ${bad@Q}"
: >"$log"
[[ -n "$(runf remove "$bad" 2>/dev/null | jq -r '.error // ""')" ]] \
|| fail "remove accepted ${bad@Q} as a finger"
[[ ! -s "$log" ]] \
|| fail "a bad finger name reached the reader through remove: ${bad@Q}"
done
# ── remove: one finger, or all of them, and never somebody else's ────────────
: >"$log"
removed="$(runf remove right-index-finger)" || fail 'remove failed against the canned fprintd'
jq -e 'has("reader") and has("enrolled") and has("unlockFeatureEnabled")' <<<"$removed" >/dev/null \
|| fail "remove does not answer with the fresh state: $removed"
python3 - "$log" right-index-finger <<'PY' || fail 'removing one finger did not delete that one finger'
import json
import sys
calls = [json.loads(line) for line in open(sys.argv[1], encoding="utf-8") if line.strip()]
methods = [call["method"] for call in calls]
if methods[0] != "Claim" or methods[-1] != "Release":
print(f"the device was not claimed and released around it: {methods}", file=sys.stderr)
raise SystemExit(1)
delete = next((call for call in calls if call["method"].startswith("DeleteEnrolledFinger")), None)
if delete is None:
print(f"nothing was deleted: {methods}", file=sys.stderr)
raise SystemExit(1)
if delete["method"] == "DeleteEnrolledFingers2":
print("removing one finger deleted all of them", file=sys.stderr)
raise SystemExit(1)
if sys.argv[2] not in delete["arguments"]:
print(f"a different finger was deleted: {delete}", file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
: >"$log"
runf remove-all >/dev/null || fail 'remove-all failed against the canned fprintd'
# DeleteEnrolledFingers2 works on the claimed user. Its predecessor took a user
# name, which is how a typo deletes somebody else's prints.
grep -Fq '"DeleteEnrolledFingers2"' "$log" \
|| fail 'remove-all does not use the method that works on the claimed user'
python3 - "$log" <<'PY' || fail 'remove-all names a user, which is how a typo deletes somebody else'
import json
import sys
for line in open(sys.argv[1], encoding="utf-8"):
if not line.strip():
continue
call = json.loads(line)
if call["method"] == "DeleteEnrolledFingers2" and call["arguments"]:
print(call, file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
# ── With no fixture and no bus, it still answers in its own shape ────────────
#
# The page reads this stream line by line. A traceback on stdout, or nothing at
# all, leaves an enrollment panel open forever with no way to know it failed.
absent="$(run enroll right-index-finger 2>/dev/null)"
[[ -n "$absent" ]] || fail 'enrollment against no fprintd printed nothing at all'
jq -e '.ok == false and (.error | length) > 0' <<<"$(tail -1 <<<"$absent")" >/dev/null \
|| fail "enrollment against no fprintd did not answer in the stream's own shape: $absent"
printf 'fingerprint contract: PASS (status, stuck state, enroll stream, remove, reason)\n'
+6 -2
View File
@@ -59,9 +59,13 @@ declare -A OWNED=(
# Handoffs that are correct despite naming an owned panel, with the reason. # Handoffs that are correct despite naming an owned panel, with the reason.
# Anything here must be justified, not merely tolerated. # Anything here must be justified, not merely tolerated.
#
# The Users entry is gone: fingerprint enrollment was the only thing sending
# people to GNOME's Users panel, and Panama drives fprintd's EnrollStart itself
# now, so the door has nothing behind it. fingerprint-contract pins the
# inverse -- that the page does NOT open a GNOME panel.
declare -A ALLOWED=( declare -A ALLOWED=(
["OnlineAccountsPage.qml:online-accounts"]="adding an account requires GOA's own dialog" ["OnlineAccountsPage.qml:online-accounts"]="OAuth sign-in (Google, Microsoft) runs inside libgoa-backend, which Fedora ships without a GIR binding, so the provider's own dialog is the only way to obtain the token; Nextcloud and IMAP are added on the page itself"
["UsersPage.qml:system-users"]="fingerprint enrollment requires fprintd's guided capture flow, and GNOME's Users panel carries the only good dialog for it"
) )
# The leaves: a tabless category is a page in its own right, and every tab is # The leaves: a tabless category is a page in its own right, and every tab is
+533
View File
@@ -0,0 +1,533 @@
#!/usr/bin/env bash
# Online accounts -- the other kind (see user-accounts for the local ones).
#
# Three things can go wrong here, and two of them are silent.
#
# The loud one is a leaked password. Adding a Nextcloud or an IMAP account is
# the first time Panama collects a credential for somebody else's server, and
# GOA's AddAccount takes it as a D-Bus argument -- which is fine, that is a
# direct call to a daemon. What is not fine is the same string reaching this
# helper's own argv, where /proc publishes it to every process on the machine,
# or the account listing, which the page renders.
#
# The first silent one is the page telling somebody they have no online
# accounts. `available` used to be `lastError === ""`, and `lastError` is set by
# every failed write -- so refusing to remove one account replaced the whole
# card with "Online accounts are not available on this machine", listing
# nothing, while four accounts sat there working. Availability is whether GOA
# answered the listing. An error is a row.
#
# The second is a removal that happens on the first click. GOA's Remove is
# immediate and unrecoverable: the account is gone, and re-adding it means the
# whole sign-in again. It must take two.
#
# SAFETY. This is the account store of a signed-in desktop session, so it must
# be impossible for anything here to reach the real GOA:
#
# 1. the helper is run under `env -i` with a stub directory first on PATH,
# and with both D-Bus bus addresses pointed at sockets that do not exist,
# so a client that got as far as connecting could not;
# 2. `import gi` resolves to a stand-in on PYTHONPATH whose require_version
# always raises, which is the same seam network-tools-contract uses;
# 3. the account data comes from PANAMA_ACCOUNTS_FIXTURE, a file in the
# scratch tree, so nothing is read from or written to the session;
# 4. every D-Bus client binary is stubbed with one that records and refuses.
#
# The write verbs are exercised only against that fixture. Nothing here signs
# in to anything, and nothing here removes a real account.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-accounts"
service="$repo_dir/config/dot/quickshell/services/OnlineAccounts.qml"
settings_dir="$repo_dir/config/dot/quickshell/modules/settings"
page="$settings_dir/OnlineAccountsPage.qml"
fail() {
printf 'online accounts contract: %s\n' "$1" >&2
exit 1
}
for path in "$helper" "$service" "$page"; do
[[ -r "$path" ]] || fail "missing $path"
done
[[ -x "$helper" ]] || fail 'panama-accounts is not executable'
file_calling() {
grep -rl --include='*.qml' -F "$1" "$settings_dir" | head -1
}
# Sentinels. Neither may come back out of anything.
readonly NEXTCLOUD_PW='nextcloud-pw-must-never-leave-6d3a'
readonly IMAP_PW='imap-pw-must-never-leave-91be'
# ── Static: the helper cannot walk past the stubs ────────────────────────────
#
# Checked before anything runs, because the safety claim above rests on it.
absolute="$(grep -nE '"/(usr/)?s?bin/[a-z-]+"' "$helper")"
[[ -z "$absolute" ]] \
|| fail "the helper names a binary by absolute path, so PATH stubs cannot contain it: $absolute"
# ── Static: a password is read, never taken as an argument ───────────────────
#
# The distinction that matters: handing the password to GOA over D-Bus is the
# supported way to add a password-based account. Handing it to THIS program as
# argv[4] would publish it through /proc for as long as the command runs.
grep -q 'sys.stdin' "$helper" \
|| fail 'the helper never reads stdin, so the password has no way in but argv'
python3 - "$helper" <<'PY' || fail 'the helper takes a password from its own command line'
import ast
import re
import sys
SECRETISH = re.compile(r"(password|passwd|secret|token|credential)", re.I)
tree = ast.parse(open(sys.argv[1], encoding="utf-8").read())
bad = []
for node in ast.walk(tree):
# password = sys.argv[n], or any assignment of an argv slice to one.
if isinstance(node, ast.Assign):
names = [t.id for t in node.targets if isinstance(t, ast.Name)]
if any(SECRETISH.search(name) for name in names):
if "argv" in ast.dump(node.value):
bad.append(f"line {node.lineno}: {names} <- argv")
# And no command list may carry one either.
if isinstance(node, (ast.List, ast.Tuple)):
literals = {e.value for e in node.elts
if isinstance(e, ast.Constant) and isinstance(e.value, str)}
if not literals & {"gdbus", "busctl", "dbus-send", "gnome-control-center", "sh", "bash"}:
continue
for element in node.elts:
name = getattr(element, "id", getattr(element, "attr", ""))
if name and SECRETISH.search(name):
bad.append(f"line {node.lineno}: {name} in a command list")
if bad:
print("; ".join(bad), file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
# ── Static: an added account is proved, not assumed ──────────────────────────
#
# GOA's AddAccount does not check what it is handed: it writes the account out
# and stores the password. A mistyped one therefore produces an account that
# exists, looks correct in the list, and never syncs -- which is the exact
# failure this page was built to be able to explain, so it must not be the
# failure the page creates. The credentials are exercised once, and an account
# that cannot sign in is taken back out.
python3 - "$helper" <<'PY' || fail 'a new account is never asked to prove its credentials'
import ast
import sys
tree = ast.parse(open(sys.argv[1], encoding="utf-8").read())
source = ast.dump(tree)
if "call_add_account_sync" not in source:
print("nothing adds an account through GOA at all", file=sys.stderr)
raise SystemExit(1)
if "call_ensure_credentials_sync" not in source:
print("an added account is never signed in with, so a mistyped password "
"produces an account that exists and never syncs", file=sys.stderr)
raise SystemExit(1)
# And the undo. The function that verifies has to be able to remove.
verify = next((node for node in ast.walk(tree)
if isinstance(node, ast.FunctionDef) and node.name == "verify"), None)
if verify is None or "call_remove_sync" not in ast.dump(verify):
print("an account whose credentials were refused is left behind", file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
# GOA's provider type for Nextcloud is "owncloud" -- the fork kept the id. A
# provider type GOA does not know is not an error: AddAccount is simply never
# offered it, and the form reports a failure nobody can act on.
grep -qE '"owncloud"' "$helper" \
|| fail 'Nextcloud is added under a provider type GOA does not have (it is "owncloud")'
grep -qE '"imap_smtp"' "$helper" \
|| fail 'mail is added under a provider type GOA does not have (it is "imap_smtp")'
# ── Static: the service hands it down the same way ───────────────────────────
grep -qE 'stdinEnabled' "$service" \
|| fail 'the service never opens a helper stdin, so the password would have to be an argument'
python3 - "$service" <<'PY' || fail 'the service puts an account password on the command line'
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
found = False
for match in re.finditer(r"function (addNextcloud|addImap)\(([^)]*)\)", text):
found = True
body = text[match.end():text.find("\n }", match.end())]
for argv in re.findall(r"\[[^\[\]]*\]", body):
if re.search(r"(password|secret|token)", argv, re.I):
print(f"{match.group(1)}: {argv.strip()[:200]}", file=sys.stderr)
raise SystemExit(1)
if not found:
print("the service cannot add a password-based account at all", file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
# ── Static: availability is not an error ─────────────────────────────────────
#
# The regression, pinned at the definition. `available` may be computed from
# whether the listing was answered; it may not be computed from lastError,
# which every refused write sets.
python3 - "$service" <<'PY' || fail 'the page-wide availability is still derived from a write error'
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
if re.search(r"property bool available:", text) is None:
print("the service no longer says whether online accounts are available", file=sys.stderr)
raise SystemExit(1)
# Every place `available` is decided: the declaration, and every assignment.
# A write error may not reach any of them. This is checked at each one rather
# than at the declaration alone, because the regression came back the second
# time as an assignment in the error handler of a failed toggle.
WRITE_ERRORS = re.compile(r"(lastError|writeError|setError|removeError)")
sites = [line for line in text.splitlines()
if re.search(r"property bool available:|\bavailable\s*=[^=]", line)]
if not sites:
print("nothing sets availability at all", file=sys.stderr)
raise SystemExit(1)
for line in sites:
if WRITE_ERRORS.search(line):
print(line.strip(), file=sys.stderr)
raise SystemExit(1)
# And the two must be separable at all: one string for "GOA did not answer",
# another for "that one change did not happen".
if len(set(re.findall(r"property string (\w*[Ee]rror)", text))) < 2:
print("the service keeps one error string, so a failed write is indistinguishable "
"from a daemon that is not there", file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
# ── Static: the page ─────────────────────────────────────────────────────────
#
# An error is a row on a working page, not a replacement for the page.
python3 - "$page" <<'PY' || fail 'the page still hides everything behind an error'
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
for line in text.splitlines():
if re.search(r"visible:.*OnlineAccounts\.lastError\s*===\s*\"\"", line):
print(line.strip(), file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
remove_page="$(file_calling 'OnlineAccounts.remove(')"
[[ -n "$remove_page" ]] || fail 'nothing removes an online account'
python3 - "$remove_page" <<'PY' || fail 'removing an account is not gated on a confirmation'
import re
import sys
lines = open(sys.argv[1], encoding="utf-8").read().splitlines()
calls = [index for index, line in enumerate(lines) if "OnlineAccounts.remove(" in line]
if not calls:
raise SystemExit(1)
# The call has to sit behind a state the first click sets: a second button that
# only exists once "confirming" is true. Read from the same object block, so a
# `confirming` property declared elsewhere in the file does not count.
for index in calls:
window = "\n".join(lines[max(0, index - 25):index + 3])
if not re.search(r"confirm", window, re.I):
print(f"line {index + 1}: {lines[index].strip()}", file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
# The account is named in the confirmation, and what is lost is said out loud:
# GOA's Remove cannot be undone and the sign-in has to be done again.
grep -qE 'Sign in again' "$page" \
|| fail 'an account whose credentials expired is not offered a way back in'
# The password field must not survive the form that collected it.
add_page="$(file_calling 'addNextcloud(')"
[[ -n "$add_page" ]] || fail 'nothing adds a Nextcloud account natively'
grep -qE '\.clear\(\)|password = ""|secret = ""' "$add_page" \
|| fail 'the add form never clears the password it collected'
# ── Static: the listing itself cannot carry a credential ─────────────────────
#
# The listing is built from a fixed set of GOA properties, and the page renders
# every one of them. A password property added to that dict later is the way a
# secret would arrive on screen, so the keys are read rather than trusted.
python3 - "$helper" <<'PY' || fail 'the account listing exposes a credential-shaped field'
import ast
import re
import sys
SECRETISH = re.compile(r"(password|passwd|secret|token|credential)", re.I)
tree = ast.parse(open(sys.argv[1], encoding="utf-8").read())
describe = next((node for node in ast.walk(tree)
if isinstance(node, ast.FunctionDef) and node.name == "describe"), None)
if describe is None:
print("nothing builds the account listing", file=sys.stderr)
raise SystemExit(1)
keys = []
for node in ast.walk(describe):
if isinstance(node, ast.Dict):
keys += [key.value for key in node.keys
if isinstance(key, ast.Constant) and isinstance(key.value, str)]
if not keys:
print("the listing has no fields at all", file=sys.stderr)
raise SystemExit(1)
offenders = [key for key in keys if SECRETISH.search(key)]
if offenders:
print(f"the listing carries {offenders}", file=sys.stderr)
raise SystemExit(1)
# And the GOA properties it reads: `password` is a real property on some
# provider objects, and reading it here would put it in the dict above under
# whatever name somebody chose.
reads = [node.attr for node in ast.walk(describe) if isinstance(node, ast.Attribute)]
offenders = [name for name in reads if SECRETISH.search(name)]
if offenders:
print(f"the listing reads {offenders} off the GOA account", file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
# ── The fake GOA ─────────────────────────────────────────────────────────────
command -v jq >/dev/null 2>&1 || { printf 'online accounts contract: SKIP (no jq)\n'; exit 0; }
command -v python3 >/dev/null 2>&1 || { printf 'online accounts contract: SKIP (no python3)\n'; exit 0; }
grep -q 'PANAMA_ACCOUNTS_FIXTURE' "$helper" \
|| fail 'the helper has no fixture seam, so nothing can exercise it without the real account store'
work="$(mktemp -d /tmp/panama-accounts-contract.XXXXXX)"
stub_dir="$work/bin"
home_dir="$work/home"
config_home="$work/config"
state_home="$work/xdg-state"
run_dir="$work/run"
pystub="$work/pystub"
mkdir -p "$stub_dir" "$home_dir" "$config_home" "$state_home" "$run_dir" "$pystub/gi"
trap 'rm -rf "$work"' EXIT
# The stand-in for PyGObject. With it in place the helper cannot construct a
# Goa.Client at all, so if the fixture seam were ever removed this contract
# would stop working rather than quietly start editing the session's accounts.
cat >"$pystub/gi/__init__.py" <<'GISTUB'
"""Stand-in for PyGObject, so panama-accounts cannot reach the real GOA."""
def require_version(namespace, version):
raise ValueError(f"Namespace {namespace} not available")
GISTUB
# Two accounts, one of them needing attention. The fixture is the listing's own
# shape, so it holds no password -- that half is pinned above, from the source.
fixture="$work/accounts.json"
log="$work/accounts.log"
cat >"$fixture" <<'FIXTURE'
{
"accounts": [
{
"path": "/org/gnome/OnlineAccounts/Accounts/account_0",
"provider": "owncloud",
"providerName": "Nextcloud",
"providerIcons": ["goa-account-owncloud"],
"identity": "[email protected]",
"needsAttention": false,
"services": [
{ "key": "files", "label": "Files", "enabled": true },
{ "key": "calendar", "label": "Calendar", "enabled": false }
]
},
{
"path": "/org/gnome/OnlineAccounts/Accounts/account_1",
"provider": "imap_smtp",
"providerName": "Mail",
"providerIcons": ["goa-account-mail"],
"identity": "[email protected]",
"needsAttention": true,
"services": [
{ "key": "mail", "label": "Mail", "enabled": true }
]
}
],
"error": ""
}
FIXTURE
# Anything that could still be a way out is closed rather than left open.
for blocked in gdbus busctl dbus-send gnome-control-center goa-daemon pkexec; do
cat >"$stub_dir/$blocked" <<STUB
#!/usr/bin/env bash
printf 'online accounts contract: the helper reached for $blocked\n' >&2
exit 1
STUB
done
chmod +x "$stub_dir"/*
runh() {
env -i \
PATH="$stub_dir:/usr/bin:/bin" \
PYTHONPATH="$pystub" \
HOME="$home_dir" \
XDG_CONFIG_HOME="$config_home" \
XDG_STATE_HOME="$state_home" \
XDG_RUNTIME_DIR="$run_dir" \
DBUS_SESSION_BUS_ADDRESS="unix:path=$run_dir/absent-session-bus" \
DBUS_SYSTEM_BUS_ADDRESS="unix:path=$run_dir/absent-system-bus" \
PANAMA_ACCOUNTS_FIXTURE="$fixture" \
PANAMA_ACCOUNTS_LOG="$log" \
LANG=C LC_ALL=C \
"$helper" "$@"
}
# The safety claim, verified rather than assumed.
for binary in gdbus busctl dbus-send gnome-control-center; do
resolved="$(env -i PATH="$stub_dir:/usr/bin:/bin" bash -c "command -v $binary" || true)"
[[ "$resolved" == "$stub_dir/$binary" ]] \
|| fail "$binary resolves to '$resolved', not the stub; refusing to run"
done
leak_in_scratch() {
grep -rlF "$1" "$home_dir" "$config_home" "$state_home" "$run_dir" "$log" \
2>/dev/null | head -1
}
recorded() { cat "$log" 2>/dev/null; }
error_of() { runh "$@" 2>/dev/null | jq -r '.error // ""'; }
# ── The listing answers, under both of its names ─────────────────────────────
#
# The service asks for `snapshot`, which is what every other Panama helper
# calls this; `list` is what this one was called first and what anything older
# still passes. Both have to work, or one of them is a page with no accounts.
: >"$log"
accounts="$(runh snapshot 2>/dev/null)" || fail 'snapshot failed against the fixture'
[[ "$(runh list 2>/dev/null)" == "$accounts" ]] \
|| fail 'list and snapshot do not answer the same thing'
jq -e '.accounts | type == "array" and length == 2' <<<"$accounts" >/dev/null \
|| fail "the fixture accounts did not come back: $accounts"
jq -e '[.accounts[] | has("path") and has("provider") and has("identity")
and has("needsAttention") and (.services | type == "array")] | all' \
<<<"$accounts" >/dev/null || fail "an account is missing part of its shape: $accounts"
jq -e '[.accounts[] | select(.needsAttention)] | length == 1' <<<"$accounts" >/dev/null \
|| fail 'the account whose credentials expired is not reported as needing attention'
jq -e '.error == ""' <<<"$accounts" >/dev/null \
|| fail "reading the accounts reported an error: $accounts"
[[ -z "$(recorded)" ]] || fail 'reading the account list changed something'
# ── Adding: the password arrives on stdin and stays nowhere ──────────────────
#
# The sentinel is typed in, and then looked for everywhere it could have gone:
# back out of the helper, into the log, into the scratch home. The log records
# its LENGTH, which is what proves it was read at all rather than dropped.
: >"$log"
added="$(printf '%s\n' "$NEXTCLOUD_PW" \
| runh add-nextcloud 'https://cloud.example.org' 'gib' 2>"$work/nextcloud.err")"
jq -e '.error == ""' <<<"$added" >/dev/null \
|| fail "a well-formed Nextcloud account was refused: $added"
jq -e '.accounts | length == 2' <<<"$added" >/dev/null \
|| fail "add-nextcloud does not answer with the account list: $added"
jq -e --argjson want "${#NEXTCLOUD_PW}" '.passwordBytes == $want' <<<"$(recorded)" >/dev/null \
|| fail "the password never reached the helper on stdin: $(recorded)"
grep -Fq "$NEXTCLOUD_PW" <<<"$added" \
&& fail 'add-nextcloud echoes the password back in its own output'
grep -Fq "$NEXTCLOUD_PW" "$work/nextcloud.err" \
&& fail 'add-nextcloud wrote the password to stderr'
leaked="$(leak_in_scratch "$NEXTCLOUD_PW")"
[[ -z "$leaked" ]] || fail "the Nextcloud password was written to $leaked"
: >"$log"
mail_added="$(printf '%s\n' "$IMAP_PW" \
| runh add-imap '[email protected]' 'imap.example.com' 'smtp.example.com' 'gib' \
2>"$work/imap.err")"
jq -e '.error == ""' <<<"$mail_added" >/dev/null \
|| fail "a well-formed mail account was refused: $mail_added"
jq -e --argjson want "${#IMAP_PW}" '.passwordBytes == $want' <<<"$(recorded)" >/dev/null \
|| fail "the mail password never reached the helper on stdin: $(recorded)"
grep -Fq "$IMAP_PW" <<<"$mail_added" && fail 'add-imap echoes the password back'
grep -Fq "$IMAP_PW" "$work/imap.err" && fail 'add-imap wrote the password to stderr'
leaked="$(leak_in_scratch "$IMAP_PW")"
[[ -z "$leaked" ]] || fail "the mail password was written to $leaked"
# ── An error is a row, not an empty page ─────────────────────────────────────
#
# The regression, from the data. Whatever went wrong, the accounts that are
# there must still come back with it -- the page draws its list from the same
# answer that carries the message.
: >"$log"
refused="$(printf 'anything\n' | runh add-nextcloud '' 'gib' 2>/dev/null)"
jq -e '(.error | length) > 0' <<<"$refused" >/dev/null \
|| fail "adding an account with no server was accepted: $refused"
jq -e '.accounts | length == 2' <<<"$refused" >/dev/null \
|| fail "a refused add emptied the account list, which is how one error hides four working accounts: $refused"
# ── Adding validates what it was given ───────────────────────────────────────
#
# Each of these produces an account that exists, looks right, and never syncs
# -- the failure this page is meant to be able to explain. They must be refused
# here, with nothing recorded, rather than handed to GOA to fail slowly.
#
# A password is piped in on purpose: without one the refusal would be "no
# password was provided", which every case would pass on regardless of whether
# the field it is about is checked at all.
attempt() { printf 'unused-password\n' | runh "$@" 2>/dev/null | jq -r '.error // ""'; }
for bad_server in '' 'not a url' 'ftp://cloud.example.org' 'https://' \
'https://cloud.example.org; reboot' 'https://cloud example org'; do
: >"$log"
[[ -n "$(attempt add-nextcloud "$bad_server" 'gib')" ]] \
|| fail "add-nextcloud accepted ${bad_server@Q} as a server"
[[ -z "$(recorded)" ]] \
|| fail "a refused Nextcloud server was acted on anyway: ${bad_server@Q}"
done
[[ -n "$(attempt add-nextcloud 'https://cloud.example.org' '')" ]] \
|| fail 'add-nextcloud accepted an empty user name'
[[ -n "$(error_of add-nextcloud 'https://cloud.example.org' 'gib' </dev/null)" ]] \
|| fail 'add-nextcloud with no password at all was accepted'
for bad_address in '' 'not-an-address' 'gib@' '@example.com'; do
[[ -n "$(attempt add-imap "$bad_address" 'imap.example.com' 'smtp.example.com' 'gib')" ]] \
|| fail "add-imap accepted ${bad_address@Q} as an address"
done
for bad_host in '' 'imap example com' 'imap.example.com; reboot' '-imap.example.com'; do
: >"$log"
[[ -n "$(attempt add-imap '[email protected]' "$bad_host" 'smtp.example.com' 'gib')" ]] \
|| fail "add-imap accepted ${bad_host@Q} as an incoming server"
[[ -n "$(attempt add-imap '[email protected]' 'imap.example.com' "$bad_host" 'gib')" ]] \
|| fail "add-imap accepted ${bad_host@Q} as an outgoing server"
[[ -z "$(recorded)" ]] \
|| fail "a refused mail server was acted on anyway: ${bad_host@Q}"
done
[[ -n "$(error_of bogus-verb)" ]] || fail 'an unknown command was accepted'
# ── Removing names the account it was asked about ────────────────────────────
#
# Confirmed on the page (pinned above); here, only that the path travels intact
# -- removing the wrong account is unrecoverable and looks like a success.
: >"$log"
runh remove '/org/gnome/OnlineAccounts/Accounts/account_1' >/dev/null 2>&1
jq -e '.arguments[0] == "/org/gnome/OnlineAccounts/Accounts/account_1"' \
<<<"$(recorded)" >/dev/null || fail "remove did not carry the account path: $(recorded)"
: >"$log"
runh set '/org/gnome/OnlineAccounts/Accounts/account_0' calendar true >/dev/null 2>&1
jq -e '.arguments == ["/org/gnome/OnlineAccounts/Accounts/account_0", "calendar", "true"]' \
<<<"$(recorded)" >/dev/null || fail "a service toggle did not carry its arguments: $(recorded)"
# ── Nothing anywhere left a secret behind ────────────────────────────────────
for secret in "$NEXTCLOUD_PW" "$IMAP_PW"; do
leaked="$(leak_in_scratch "$secret")"
[[ -z "$leaked" ]] || fail "a password was left behind in $leaked"
done
printf 'online accounts contract: PASS (listing, availability, add, remove, no secret leaves)\n'
+405 -9
View File
@@ -7,14 +7,18 @@
# #
# Nothing here creates, deletes, or modifies a real account. It reads the # Nothing here creates, deletes, or modifies a real account. It reads the
# account state, which is safe, and exercises the refusals, which are the part # account state, which is safe, and exercises the refusals, which are the part
# that has to hold. # that has to hold. Every verb that would change something is read from the
# source instead of being run -- `set-icon gib ""` really does clear the
# avatar, and `delete-user` really does delete, so those are pinned statically
# and never invoked.
set -uo pipefail set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-users" helper="$repo_dir/config/dot/quickshell/scripts/panama-users"
service="$repo_dir/config/dot/quickshell/services/UserAccounts.qml" service="$repo_dir/config/dot/quickshell/services/UserAccounts.qml"
page="$repo_dir/config/dot/quickshell/modules/settings/UsersPage.qml" settings_dir="$repo_dir/config/dot/quickshell/modules/settings"
page="$settings_dir/UsersPage.qml"
fail() { fail() {
printf 'user accounts contract: %s\n' "$1" >&2 printf 'user accounts contract: %s\n' "$1" >&2
@@ -26,6 +30,13 @@ for path in "$helper" "$service" "$page"; do
done done
[[ -x "$helper" ]] || fail 'panama-users is not executable' [[ -x "$helper" ]] || fail 'panama-users is not executable'
# The page was rebuilt into several files, so the add-user form and the
# per-user rows may not live in UsersPage.qml any more. Everything structural
# is looked up by what it calls rather than by which file it sits in.
file_calling() {
grep -rl --include='*.qml' -F "$1" "$settings_dir" | head -1
}
# ── A new password never reaches a command line ───────────────────────────── # ── A new password never reaches a command line ─────────────────────────────
# argv is world-readable through /proc, so a password passed as an argument is # argv is world-readable through /proc, so a password passed as an argument is
# published to every process on the machine. It is read from stdin, and the # published to every process on the machine. It is read from stdin, and the
@@ -50,23 +61,363 @@ grep -qE 'command:.*set-password.*password' "$service" \
grep -q 'root.pendingPassword = ""' "$service" \ grep -q 'root.pendingPassword = ""' "$service" \
|| fail 'the service never clears the password it was holding' || fail 'the service never clears the password it was holding'
# ── Refusals that keep a machine administrable ────────────────────────────── # ── Resetting a password is not the same as setting one ─────────────────────
#
# "Reset" hands the account back to its owner: accountsservice's
# SetPasswordMode(1) means "choose one at the next sign-in". The whole point is
# that an administrator resetting somebody else's password never learns, types,
# or transports a password -- so this verb must not touch stdin, must not reach
# for a hashing tool, and must not go anywhere near SetPassword.
python3 - "$helper" <<'PY' || fail 'reset-password does not set password mode 1, or it handles password material'
import ast
import sys
tree = ast.parse(open(sys.argv[1], encoding="utf-8").read())
target = next((node for node in ast.walk(tree)
if isinstance(node, ast.FunctionDef) and node.name == "reset_password"), None)
if target is None:
print("reset-password has no implementation", file=sys.stderr)
raise SystemExit(1)
# Module constants, so the mode may be a name with a meaning rather than a 1.
constants = {}
for node in tree.body:
if isinstance(node, ast.Assign) and isinstance(node.value, ast.Constant):
for name in node.targets:
if isinstance(name, ast.Name):
constants[name.id] = node.value.value
def literal(node):
if isinstance(node, ast.Constant):
return node.value
if isinstance(node, ast.Name):
return constants.get(node.id)
return None
methods = [element.value for node in ast.walk(target)
for element in ast.walk(node)
if isinstance(element, ast.Constant) and isinstance(element.value, str)]
if "SetPasswordMode" not in methods:
print("reset-password does not use accountsservice SetPasswordMode", file=sys.stderr)
raise SystemExit(1)
for forbidden in ("SetPassword", "SetPasswordHint"):
if forbidden in methods:
print(f"reset-password calls {forbidden}, which carries password material",
file=sys.stderr)
raise SystemExit(1)
# Mode 1: "no usable password, choose one at the next sign-in". 0 would mean a
# password is set, 2 would mean none is ever needed -- both are somebody else's
# account handed away.
modes = [literal(element) for node in ast.walk(target)
if isinstance(node, ast.Tuple)
for element in node.elts]
if 1 not in modes:
print(f"reset-password does not ask for mode 1: {modes}", file=sys.stderr)
raise SystemExit(1)
# Nothing that could be a password may pass through it. The docstring is
# excluded on purpose -- it is allowed to say the word.
code = ast.dump(ast.Module(body=[node for node in target.body
if not (isinstance(node, ast.Expr)
and isinstance(node.value, ast.Constant))],
type_ignores=[]))
for word in ("stdin", "openssl", "crypt", "passwd"):
if word in code:
print(f"reset-password touches {word}; it must only set the mode", file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
grep -q 'function resetPassword' "$service" \
|| fail 'the service cannot reset a password'
grep -qE 'resetPassword[^}]*stdin' "$service" \
&& fail 'the service opens stdin for a reset, which carries nothing to write'
reset_page="$(file_calling 'resetPassword(')"
[[ -n "$reset_page" ]] || fail 'no page offers to reset another account password'
grep -q 'at next sign-in\|at their next sign-in' "$reset_page" \
|| fail 'the reset row does not say the other person sets the new password themselves'
# ── Removing a picture is a real verb, not a deletion of the file ───────────
#
# accountsservice takes an empty IconFile to mean "no avatar" and cleans up
# after itself. Anything else -- unlinking the file the snapshot named, writing
# a blank image -- leaves the database pointing at something that is not there.
icon_body="$(sed -n '/^def set_icon/,/^def [a-z_]*(/p' "$helper")"
[[ -n "$icon_body" ]] || fail 'set_icon is missing'
grep -q 'SetIconFile' <<<"$icon_body" \
|| fail 'the avatar is not written through accountsservice'
python3 - "$helper" <<'PY' || fail 'set-icon with an empty path does not clear the avatar through SetIconFile("")'
import ast
import sys
tree = ast.parse(open(sys.argv[1], encoding="utf-8").read())
target = next((node for node in ast.walk(tree)
if isinstance(node, ast.FunctionDef) and node.name == "set_icon"), None)
if target is None:
raise SystemExit(1)
dump = ast.dump(target)
# An empty path is a value the function has to recognise, not a path it hands
# to GdkPixbuf -- which would fail, and the avatar would stay.
if 'Constant(value=\'\')' not in dump and 'value=""' not in dump:
print("set_icon never compares its path against the empty string", file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
grep -q 'function removeIcon' "$service" \
|| fail 'the service cannot remove a picture'
python3 - "$service" <<'PY' || fail 'removeIcon does not send an empty path to the helper'
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
start = text.find("function removeIcon")
if start < 0:
raise SystemExit(1)
body = text[start:text.find("\n }", start)]
if not re.search(r'"set-icon"[^\]]*""', body):
print(body.strip()[:300], file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
# ── Deleting says what happens to the files, and honors the answer ──────────
#
# The choice is the whole feature: "Keep the files" and "Remove everything" are
# different, irreversible outcomes, and a dropdown whose answer is dropped on
# the way down is worse than no dropdown at all. Each link is pinned
# separately, because any one of them can invert on its own.
# 1. The helper turns its argument into accountsservice's boolean.
delete_body="$(sed -n '/^def delete_user/,/^def /p' "$helper")" delete_body="$(sed -n '/^def delete_user/,/^def /p' "$helper")"
[[ -n "$delete_body" ]] || fail 'delete_user is missing'
grep -q 'You cannot delete the account you are signed in to' <<<"$delete_body" \ grep -q 'You cannot delete the account you are signed in to' <<<"$delete_body" \
|| fail 'the helper would delete the account running it' || fail 'the helper would delete the account running it'
grep -q 'only administrator' <<<"$delete_body" \ grep -q 'only administrator' <<<"$delete_body" \
|| fail 'the helper would remove the last administrator, leaving nobody able to administer the machine' || fail 'the helper would remove the last administrator, leaving nobody able to administer the machine'
python3 - "$helper" <<'PY' || fail 'the helper does not derive the DeleteUser flag from its keep/remove argument'
import ast
import sys
# The page must not offer to change the type of the only administrator either. tree = ast.parse(open(sys.argv[1], encoding="utf-8").read())
grep -q 'administratorCount <= 1' "$page" \ target = next((node for node in ast.walk(tree)
|| fail 'the page offers to demote the only administrator' if isinstance(node, ast.FunctionDef) and node.name == "delete_user"), None)
if target is None:
raise SystemExit(1)
parameters = [argument.arg for argument in target.args.args]
if len(parameters) < 2:
print("delete_user takes no keep/remove argument at all", file=sys.stderr)
raise SystemExit(1)
choice = parameters[1]
# The boolean handed to DeleteUser must be computed from that argument. A
# literal True or False there is the bug this whole block exists to catch.
for node in ast.walk(target):
if not isinstance(node, ast.Call):
continue
name = getattr(node.func, "attr", getattr(node.func, "id", ""))
if name != "Variant":
continue
for element in ast.walk(node):
if isinstance(element, ast.Compare) and any(
isinstance(sub, ast.Name) and sub.id == choice
for sub in ast.walk(element)):
raise SystemExit(0)
print(f"the DeleteUser flag is not computed from {choice}", file=sys.stderr)
raise SystemExit(1)
PY
# 2. The service maps its own parameter the way its name reads. Panama has
# flipped this polarity once already (removeFiles -> keepFiles); a mapping
# that says one and sends the other reads correctly in every diff.
python3 - "$service" <<'PY' || fail 'the service maps its keep/remove parameter the wrong way round'
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
match = re.search(r"function deleteUser\(([^)]*)\)", text)
if match is None:
print("the service has no deleteUser", file=sys.stderr)
raise SystemExit(1)
parameters = [part.split(":")[0].strip() for part in match.group(1).split(",")]
if len(parameters) < 2:
print("deleteUser takes no keep/remove argument", file=sys.stderr)
raise SystemExit(1)
choice = parameters[1]
body = text[match.end():text.find("\n }", match.end())]
ternary = re.search(re.escape(choice) + r"\s*\?\s*\"([a-z-]+)\"\s*:\s*\"([a-z-]+)\"", body)
if ternary is None:
print(f"deleteUser does not pass {choice} through to the helper: {body.strip()[:200]}",
file=sys.stderr)
raise SystemExit(1)
when_true, when_false = ternary.groups()
wanted = "keep" if "keep" in choice.lower() else "remove"
if wanted not in when_true or wanted in when_false:
print(f"{choice} true sends {when_true!r}, false sends {when_false!r}", file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
# 3. The page passes the answer, not a constant.
delete_page="$(file_calling 'deleteUser(')"
[[ -n "$delete_page" ]] || fail 'no page deletes an account'
grep -qE 'deleteUser\([^)]*,\s*(true|false)\s*\)' "$delete_page" \
&& fail 'the page hardcodes what happens to the files, so the choice it offers is decorative'
grep -q 'Keep the files' "$delete_page" \
|| fail 'the page never offers to keep the deleted account files'
grep -qE 'Remove everything|Delete the files|Remove the files' "$delete_page" \
|| fail 'the page never offers to remove the deleted account files'
# ── Deleting is confirmed, and says what it destroys ──────────────────────── # ── Deleting is confirmed, and says what it destroys ────────────────────────
grep -q 'confirmingRemoval' "$page" \ grep -q 'confirmingRemoval' "$delete_page" \
|| fail 'the page deletes an account without a confirmation step' || fail 'the page deletes an account without a confirmation step'
grep -q 'This cannot be undone' "$page" \ grep -q 'This cannot be undone' "$delete_page" \
|| fail 'the page does not say that deleting an account destroys their files' || fail 'the page does not say that deleting an account destroys their files'
# The page must not offer to change the type of the only administrator either.
type_page="$(file_calling 'administratorCount')"
[[ -n "$type_page" ]] || fail 'nothing on the page knows how many administrators there are'
grep -q 'administratorCount <= 1' "$type_page" \
|| fail 'the page offers to demote the only administrator'
# And the helper refuses it regardless of what the page offers. Demoting the
# only administrator is the same loss as deleting them -- a machine nobody can
# administer -- and the page is not the only thing that can call this.
python3 - "$helper" <<'PY' || fail 'the helper would demote the only administrator'
import ast
import sys
tree = ast.parse(open(sys.argv[1], encoding="utf-8").read())
target = next((node for node in ast.walk(tree)
if isinstance(node, ast.FunctionDef) and node.name == "set_account_type"), None)
if target is None:
print("set-account-type has no implementation", file=sys.stderr)
raise SystemExit(1)
dump = ast.dump(target)
if "administratorCount" not in dump:
print("set-account-type never counts the administrators", file=sys.stderr)
raise SystemExit(1)
if "BoundaryError" not in dump:
print("set-account-type counts them and refuses nothing", file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
# ── Managing somebody else ──────────────────────────────────────────────────
#
# Account type, locked accounts and the reset above are the verbs that only
# make sense for an account that is not yours, and each has to exist on both
# sides or the row is a button that does nothing.
grep -q 'function setAccountTypeFor\|function setAccountType' "$service" \
|| fail 'the service cannot change another account type'
grep -q 'function setLocked' "$service" \
|| fail 'the service cannot unlock a locked account'
grep -qE '"set-locked"' "$service" \
|| fail 'the service does not reach the helper set-locked verb'
grep -qE '\bset-locked\b' "$helper" \
|| fail 'the helper has no set-locked verb'
grep -q 'Unlock' "$(file_calling 'setLocked(')" \
|| fail 'a locked account is never offered an unlock'
# ── The add-user form validates while it is being typed ─────────────────────
#
# The regression this pins: the fields were TextFieldRow, which commits on
# blur. Typing a perfectly good user name left the Create button disabled,
# because the property behind the enabled predicate had not been told yet, and
# the form looked broken for as long as the field had focus. So the property
# the predicate reads must be updated on every keystroke, and the field it
# comes from must not be the blur-committing row.
create_page="$(file_calling 'UserAccounts.createUser(')"
[[ -n "$create_page" ]] || fail 'nothing creates a user'
# The blunt half of the same pin, and the one that cannot be argued with: the
# page that carries the add-user form has no blur-committing field anywhere.
# LiveFieldRow reports per keystroke and also on accept, so a row that only
# wants the accept behaviour has no reason to reach for the old one.
grep -nE '^\s*TextFieldRow\s*\{' "$create_page" \
&& fail 'the add-user page still has a blur-committing field, which is the bug'
python3 - "$create_page" "$helper" <<'PY' || fail 'the add-user form does not validate live'
import re
import sys
page = open(sys.argv[1], encoding="utf-8").read()
lines = page.splitlines()
# The rules the form enforces: the one regular expression it tests a user name
# against, wherever it lives -- an `enabled:` predicate, or a property that
# turns the same test into the message under the field. Found by the shape of
# the rule rather than by where it sits, because it has moved once already.
predicate = next((line for line in lines
if ".test(" in line and re.search(r"/\^\[a-z", line)), None)
if predicate is None:
print("the add-user form validates the user name nowhere at all", file=sys.stderr)
raise SystemExit(1)
held = re.search(r"\.test\(\s*(?:root\.)?([A-Za-z_][\w.]*)", predicate)
if held is None:
print(f"cannot tell what the form validates: {predicate.strip()}", file=sys.stderr)
raise SystemExit(1)
name = held.group(1).split(".")[-1]
# They must be the helper's rules. Two expressions that drift apart give a form
# that accepts a name accountsservice then refuses, with no way to tell why.
helper_rule = re.search(r'USERNAME\s*=\s*re\.compile\(r"([^"]+)"\)',
open(sys.argv[2], encoding="utf-8").read())
page_rule = re.search(r"/(\^[^/]+\$)/", predicate)
if page_rule is None:
print(f"the form's user-name test is not a regular expression: {predicate.strip()}",
file=sys.stderr)
raise SystemExit(1)
if helper_rule and helper_rule.group(1) != page_rule.group(1):
print(f"the form validates {page_rule.group(1)}, the helper enforces {helper_rule.group(1)}",
file=sys.stderr)
raise SystemExit(1)
# The cap is part of the rules and part of the sentence under the field: a
# 40-character name is accepted by the form and refused by accountsservice.
if "31" not in page_rule.group(1):
print(f"the form does not cap the user name length: {page_rule.group(1)}", file=sys.stderr)
raise SystemExit(1)
if "31" not in page:
print("the form never tells anybody about the length cap", file=sys.stderr)
raise SystemExit(1)
# Where that property is written, and by what kind of field. `edited` (or a
# text-changed handler) is per-keystroke; `accepted` is blur, which is the bug.
assignments = [index for index, line in enumerate(lines)
if re.search(rf"\b{re.escape(name)}\s*=", line)
and "property" not in line]
if not assignments:
print(f"{name} is never assigned, so the form can never become valid", file=sys.stderr)
raise SystemExit(1)
LIVE = re.compile(r"on(Edited|TextChanged|TextEdited|DisplayTextChanged)\b")
live = False
for index in assignments:
window = "\n".join(lines[max(0, index - 4):index + 1])
if LIVE.search(window):
live = True
# The enclosing component: the nearest `Type {` above, at any indentation.
for above in range(index, -1, -1):
opener = re.match(r"\s*([A-Z]\w*)\s*\{", lines[above])
if opener:
if opener.group(1) == "TextFieldRow":
print(f"{name} is committed by a TextFieldRow, which only commits on blur "
f"(line {above + 1})", file=sys.stderr)
raise SystemExit(1)
break
if not live:
print(f"{name} is only committed on accept, so the form cannot validate while it is typed",
file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
# ── The snapshot is real, and reports no secrets ──────────────────────────── # ── The snapshot is real, and reports no secrets ────────────────────────────
command -v jq >/dev/null 2>&1 || { printf 'user accounts contract: SKIP (no jq)\n'; exit 0; } command -v jq >/dev/null 2>&1 || { printf 'user accounts contract: SKIP (no jq)\n'; exit 0; }
snapshot="$("$helper" snapshot 2>/dev/null)" || fail 'snapshot failed' snapshot="$("$helper" snapshot 2>/dev/null)" || fail 'snapshot failed'
@@ -77,6 +428,11 @@ jq -e '[.users[] | (.userName | length > 0)] | all' <<<"$snapshot" >/dev/null \
jq -e '.currentUser | length > 0' <<<"$snapshot" >/dev/null \ jq -e '.currentUser | length > 0' <<<"$snapshot" >/dev/null \
|| fail 'the snapshot does not say which account is signed in' || fail 'the snapshot does not say which account is signed in'
# The page renders these; a snapshot that drops them turns a locked account
# into a normal-looking one nobody can sign in to.
jq -e '[.users[] | has("locked") and has("loginTime")] | all' <<<"$snapshot" >/dev/null \
|| fail 'the snapshot no longer reports whether an account is locked'
offenders="$(jq -r '[paths | map(tostring) | join(".")] | map(select(test("(password|secret|hash)$";"i"))) | join(", ")' <<<"$snapshot")" offenders="$(jq -r '[paths | map(tostring) | join(".")] | map(select(test("(password|secret|hash)$";"i"))) | join(", ")' <<<"$snapshot")"
[[ -z "$offenders" ]] || fail "the snapshot carries credential-shaped fields: $offenders" [[ -z "$offenders" ]] || fail "the snapshot carries credential-shaped fields: $offenders"
@@ -84,11 +440,51 @@ offenders="$(jq -r '[paths | map(tostring) | join(".")] | map(select(test("(pass
jq -e '[.users[] | .uid >= 1000] | all' <<<"$snapshot" >/dev/null \ jq -e '[.users[] | .uid >= 1000] | all' <<<"$snapshot" >/dev/null \
|| fail 'a system account is listed as a manageable user' || fail 'a system account is listed as a manageable user'
# ── The stock avatars are a list, of a shape the gallery can draw ───────────
#
# Read-only: it lists files that ship with the distribution. An empty list is
# a legitimate answer on a machine without the faces package, so the shape is
# what is pinned, not the contents.
stock="$("$helper" stock-avatars 2>/dev/null)" || fail 'stock-avatars failed'
jq -e '.avatars | type == "array"' <<<"$stock" >/dev/null \
|| fail "stock-avatars does not answer with a list: $stock"
jq -e '[.avatars[] | has("name") and has("path")] | all' <<<"$stock" >/dev/null \
|| fail "a stock avatar is missing its name or its path: $stock"
jq -e '[.avatars[] | (.path | startswith("/"))] | all' <<<"$stock" >/dev/null \
|| fail 'a stock avatar path is not absolute, so nothing can load it'
grep -q 'stockAvatars' "$service" \
|| fail 'the service does not offer the stock avatars to the gallery'
# ── Input validation ──────────────────────────────────────────────────────── # ── Input validation ────────────────────────────────────────────────────────
for bad in "root; rm -rf /" "../escape" "UPPER" ""; do for bad in "root; rm -rf /" "../escape" "UPPER" ""; do
result="$("$helper" set-real-name "$bad" "Test" 2>/dev/null | jq -r '.error // ""')" result="$("$helper" set-real-name "$bad" "Test" 2>/dev/null | jq -r '.error // ""')"
[[ -n "$result" ]] || fail "the helper accepted \"$bad\" as a user name" [[ -n "$result" ]] || fail "the helper accepted \"$bad\" as a user name"
done done
printf 'user accounts contract: PASS (%d account(s), credentials never on a command line)\n' \ # Deleting refuses before it reaches accountsservice, not after.
[[ -n "$("$helper" delete-user "${USER:-nobody}" 2>/dev/null | jq -r '.error // ""')" ]] \
|| fail 'delete-user with no keep/remove answer was accepted'
[[ -n "$("$helper" delete-user "${USER:-nobody}" sideways 2>/dev/null | jq -r '.error // ""')" ]] \
|| fail 'delete-user accepted an answer that is neither keep nor remove'
# Both vocabularies, on purpose: `keep`/`remove` is what the page says out
# loud, `keep-files`/`remove-files` is what this helper has always taken and
# what anything older still passes. The refusal below is the self-deletion one,
# which is reached only once the keep/remove answer has been understood -- so a
# vocabulary that stopped being recognized would show up here as the wrong
# message rather than as no message.
for vocabulary in keep remove keep-files remove-files; do
refusal="$("$helper" delete-user "${USER:-nobody}" "$vocabulary" 2>/dev/null \
| jq -r '.error // ""')"
[[ -n "$refusal" ]] || fail "the helper agreed to delete the account running it"
grep -q 'signed in to' <<<"$refusal" \
|| fail "delete-user no longer understands \"$vocabulary\": $refusal"
done
for bad in "root; rm -rf /" "UPPER" ""; do
[[ -n "$("$helper" reset-password "$bad" 2>/dev/null | jq -r '.error // ""')" ]] \
|| fail "reset-password accepted \"$bad\" as a user name"
done
printf 'user accounts contract: PASS (%d account(s), credentials never on a command line, keep-files honored end to end)\n' \
"$(jq '.users | length' <<<"$snapshot")" "$(jq '.users | length' <<<"$snapshot")"