From a556cd56b71e99badd0b1e3abcae9e8dd9bf6092 Mon Sep 17 00:00:00 2001 From: fy59 Date: Thu, 22 Oct 2026 08:52:38 +0200 Subject: [PATCH] feat(session-v2): gate restoration on GNOME Keyring readiness --- docs/session-restore-v2.md | 15 + docs/session-v2-keyring-before-restore-v1.md | 291 ++++++++++++++++++ .../labfy-sway/SessionStartupPromptV2.qml | 56 +++- .../controlcenter/SessionManagerV2.qml | 6 + .../quickshell/labfy-sway/session_v2/cli.py | 17 +- .../labfy-sway/session_v2/keyring.py | 198 ++++++++++++ .../labfy-sway/sessionui/SessionV2Service.qml | 36 ++- .../labfy-sway/tests/focus/test_qml.py | 30 ++ tests/session_v2/fixtures/secret_service.py | 66 ++++ tests/session_v2/keyring_runtime.py | 58 ++++ tests/session_v2/keyring_ui.py | 126 ++++++++ tests/session_v2/phase3b_ui.py | 5 +- tests/session_v2/test_keyring.py | 152 +++++++++ tests/session_v2/test_prewarm.py | 1 + tests/session_v2/validate_keyring.py | 52 ++++ 15 files changed, 1097 insertions(+), 12 deletions(-) create mode 100644 docs/session-v2-keyring-before-restore-v1.md create mode 100644 quickshell/.config/quickshell/labfy-sway/session_v2/keyring.py create mode 100644 tests/session_v2/fixtures/secret_service.py create mode 100644 tests/session_v2/keyring_runtime.py create mode 100644 tests/session_v2/keyring_ui.py create mode 100644 tests/session_v2/test_keyring.py create mode 100644 tests/session_v2/validate_keyring.py diff --git a/docs/session-restore-v2.md b/docs/session-restore-v2.md index d814d6ba1..de6469fbc 100644 --- a/docs/session-restore-v2.md +++ b/docs/session-restore-v2.md @@ -338,3 +338,18 @@ sans sauvegarder restent nécessaires. Aucun code d’erreur n’autorise le log Validation et limites détaillées : [rapport scratchpad](session-v2-scratchpad-save-and-restore-v1.md). + + +## Précondition Secret Service avant restauration — 22 octobre 2026 + +Le parcours utilisateur ajoute une barrière D-Bus indépendante du checkpoint. +Les CLI `apply-last` et `apply` exigent la collection default attendue réellement +déverrouillée ; le chooser propose son déverrouillage officiel et conserve le +clic explicite Restaurer après READY. Nouvelle session reste disponible sans +trousseau. Cette précondition ne modifie aucune postcondition de l'Executor, +aucun schéma de checkpoint/Recovery et ne résout pas automatiquement un +`RECOVERY_REQUIRED`. Une activation tardive garde les refus de focus existants. + +Implémentation, audit PAM (aucun patch applicable établi), tests isolés, +installation et rollback : +[trousseau avant restauration](session-v2-keyring-before-restore-v1.md). diff --git a/docs/session-v2-keyring-before-restore-v1.md b/docs/session-v2-keyring-before-restore-v1.md new file mode 100644 index 000000000..25b11c20e --- /dev/null +++ b/docs/session-v2-keyring-before-restore-v1.md @@ -0,0 +1,291 @@ +# Session V2 — trousseau avant restauration (22 octobre 2026) + +État : implémentation et validation isolées ; **non déployé**, vrai login non +validé. Base `d16aa69f`, branche `fix/session-v2-keyring-before-restore-20261022`. +Le dépôt principal était propre ; aucun autre worktree n'a été modifié. + +## Diagnostic et preuves disponibles + +À 08:01:35 CEST, systemd démarre GNOME Keyring ; QuickShell démarre à +08:01:38. À 08:01:48 le prompter reçoit BeginPrompting, crée GcrPromptDialog, +puis commence un password prompt. L'incident fourni situe le démarrage du +prompt à 08:01:48.235 et le rapport d'échec à 08:01:48.482. Le focus attendu +Firefox/con_id 10 est remplacé par con_id 18 après les six frontières du guard. +Ces événements concordent avec une activation du trousseau pendant la fenêtre +de validation finale. **L'identité historique de con_id 18 et la causalité ne +sont pas démontrées.** Aucune capture d'arbre contemporaine avec PID/starttime +ne permet ici de les établir. Même limite pour l'occurrence de 06:11:59. + +Les abonnements IPC Sway indiquent la fenêtre devenue active, pas l'origine +(user input ou commande d'un autre client IPC) d'un changement de focus. Un +app_id seul n'est pas une preuve de causalité. Une future investigation peut +corréler les événements fenêtre/workspace horodatés, con_id, PID/starttime et +les événements du prompter ; elle doit laisser « non attribué » ce qui le reste. +Ne pas activer de trace d'échange secret pour ce diagnostic. + +## Audit de l'installation réelle (lecture seule) + +- `/etc/pam.d/greetd` : securetty, nologin, puis inclusions auth/account/session + de system-local-login. Aucune règle gnome-keyring. SHA-256 observé : + `993a3096c2b113e6800f2abbd5d4233ebf1a97eef423990d3187d665d3490b92`. +- Inclusions : system-local-login → system-login → system-auth. pam_unix utilise + try_first_pass ; pam_systemd est dans la chaîne session avant le retour à + greetd. Les sauts numériques de system-auth ne sautent pas une règle ajoutée + après l'inclusion dans greetd. Rien de cette pile n'est modifié. +- Le greeter installé dans `/usr/local/libexec/labfy-greeter` répond aux messages + PAM avec post_auth_message_response, sur la même conversation greetd. Il ne + réauthentifie pas le mot de passe localement. Le code installé est antérieur + au sélecteur de session du dépôt mais possède ce même transport des réponses. +- Module présent : `/usr/lib/security/pam_gnome_keyring.so`, paquet + gnome-keyring `1:51.1-1`, greetd `0.10.3-2`, python-gobject `3.58.1-1`. +- Secret Service appartient à gnome-keyring-daemon. Un processus observé : + `gnome-keyring-daemon --foreground --components=pkcs11,secrets,ssh`. + ReadAlias(default) renvoie `/org/freedesktop/secrets/collection/Default_5fkeyring` ; + ReadAlias(login) renvoie `/`. Locked est false lors de la relecture finale + (cet état courant ne prouve pas son état pendant l’incident). Aucun contenu des fichiers du trousseau n'a été lu. + Les noms présents sont Default_keyring.keyring, default et user.keystore. +- L'unité utilisateur masque celle du paquet. Elle omet Requires=socket et le + `--control-directory=%t/keyring` explicite de l'unité distribuée. Son lien + d'activation est dans default.target.wants, même si Install mentionne + graphical-session.target. Le socket `%t/keyring/control` est actif. +- D-Bus possède aussi une entrée d'activation `--start --foreground + --components=secrets`. Les fichiers XDG autostart du paquet utilisent --start + et OnlyShowIn=GNOME;Unity;MATE;Cinnamon. Ils ne démontrent donc pas un second + lancement sous Sway. Aucun autre lancement n'a été trouvé dans les sources + Sway/QuickShell/greetd ; **une seule instance observée ne prouve pas l'absence + de toute course de démarrage**. + +### Décision PAM : pas de patch applicable dans cette mission + +L'intégration habituelle place auth optional pam_gnome_keyring.so après +l'authentification principale et session optional pam_gnome_keyring.so +auto_start après l'inclusion de session (donc après pam_systemd). Le transport +du greeter et PAM_AUTHTOK sont structurellement compatibles avec ce schéma. +Cela n'établit pas que Default_keyring soit le trousseau login, ni que son mot +de passe soit identique à celui du compte. Ces faits ne sont pas testés avec +un mot de passe dans cette mission. + +Le module GNOME cible le trousseau **login**, identifié en interne par CKA_ID. +Son chemin de déverrouillage peut le créer s'il manque. L'alias login est absent, +aucun fichier login n'est listé, et le rôle interne de Default_keyring n'est pas +prouvé. Activer auto_start pourrait donc provoquer une création et ne démontre +pas le déverrouillage du default existant. Ce mécanisme est vérifié dans le +[code GNOME Keyring 51.1, unlock_or_create_login](https://raw.githubusercontent.com/GNOME/gnome-keyring/51.1/daemon/login/gkd-login.c). +La [documentation PAM GNOME](https://wiki.gnome.org/Projects/GnomeKeyring/Pam) +décrit également la cible login et les règles usuelles. **Aucun fichier PAM candidat à installer +n'est livré**, aucun essai PAM réel n'est effectué. Une validation statique de +la syntaxe de deux règles ne validerait pas ces conditions opérationnelles. + +Le contrôle optional protège normalement la décision d'authentification +principale contre un échec du module ; il ne garantit pas à lui seul l'absence +d'attente ni d'effets de bord. Ne pas prendre cette analyse pour une preuve +qu'un changement PAM quelconque est sans risque. + +Une future tranche PAM exige une autorisation distincte, la confirmation +humaine du trousseau cible sans enregistrer le mot de passe, et une étude de +l'unité/socket du paquet comme propriétaire unique du démon. Si un patch +compatible est alors établi, l'application privilégiée doit sauvegarder le +fichier root:root/0644, contrôler le hash de départ, installer atomiquement la +copie validée et conserver un accès de secours hors greeter. Le rollback doit +réinstaller exactement la sauvegarde, sans modifier les inclusions système. +Aucune de ces opérations ni aucun logout/reboot n'a été exécuté ici. + +Aucune modification de l'unité utilisateur n'est nécessaire à la barrière +D-Bus. La normalisation de cette unité avec une future intégration PAM doit +être validée au login ; elle n'est pas appliquée sur le bureau actif. + +## Contrat de la barrière + +`session_v2/keyring.py` utilise PyGObject/Gio et une connexion D-Bus privée : + +1. Résoudre/activer Secret Service avec une attente bornée, puis fixer son + propriétaire D-Bus unique pour toutes les opérations. +2. ReadAlias(default) doit correspondre à la collection auditée ci-dessus. + Default absent/différent est un refus explicite. Si login existe, il doit + désigner la même collection. Login absent autorise le parcours manuel ; il + ne vaut jamais preuve de compatibilité PAM. Une installation utilisant une + autre collection doit faire réauditer cette constante avant intégration. +3. Lire org.freedesktop.Secret.Collection.Locked par Properties.Get. READY + exige un vrai booléen false, avec propriétaire inchangé. +4. Un clic Déverrouiller appelle Service.Unlock. S'il retourne un Prompt, + s'abonner à Completed **avant** Prompt(""). Le window-id vide présente un + prompt non parenté, sans prétendre à un handle X11/Wayland inexistant. +5. Garder la connexion vivante, attendre Completed non annulé, puis relire + alias/propriétaire/Locked. Une fin de prompt avec Locked=true refuse READY. +6. Limites : connexion/auth D-Bus 2 s, appels individuels au plus 2 s, budget + de lecture 8 s, opération interactive 90 s, nettoyage Dismiss au plus 1 s, + puis relecture avec budget 8 s. Un timeout n'autorise rien. Un mot de passe + incorrect peut maintenir le prompt GNOME ouvert jusqu'à correction, + annulation ou timeout ; le client n'essaie jamais de deviner cette cause. +7. Dismiss/fermeture de la connexion ne concernent que le prompt possédé. Le + traitement SIGTERM réveille la boucle GLib et permet ce nettoyage. Un arrêt + brutal du processus ferme sa connexion ; il ne laisse aucune autorisation + persistante. Aucun fichier READY/token n'est produit. + +Les commandes `keyring-status` et `keyring-unlock --execute` retournent +uniquement un état KEYRING_* et un code de diagnostic fixe. Aucun OpenSession, +GetSecret(s), SearchItems, changement de mot de passe, création de collection +ou lecture de fichier de trousseau n'est utilisé. Les erreurs D-Bus libres et +le payload de résultat de Completed ne sont pas journalisés. + +### Concurrence et transactions + +Le verrou privé `session-v2/keyring.lock` sérialise les helpers entre processus. +Une seconde demande voit KEYRING_UNLOCKING/KEYRING_OR_RESTORE_BUSY et ne crée +pas un second prompt. Avant Unlock, le helper vérifie aussi le verrou de +transaction existant (y compris celui hérité par un guard survivant). + +Les CLI `apply-last` **et** `apply` prennent keyring.lock, relisent D-Bus sans +ouvrir de prompt, puis appellent l'Executor inchangé en gardant le verrou +jusqu'à son retour. Ordre : keyring.lock puis transaction.lock. Aucune voie +CLI de restauration n'est autorisée par le seul booléen du QML. Un refus de +barrière ne publie aucun Attempt/Recovery et ne nettoie aucun ancien journal. +Les appels internes directs à Executor restent une API de moteur ; ils ne +constituent pas une commande utilisateur de restauration. + +La décision « Nouvelle session » conserve uniquement le verrou de transaction +historique. Elle reste donc disponible pendant une attente du trousseau. +QuickShell arrête son helper lors de ce choix. Un autre helper déjà en cours +n'est pas tué par une instance qui ne le possède pas. + +### QuickShell + +Le singleton démarre sa lecture indépendamment du checkpoint. Le chooser +reconstruit aussi l'état backend au redémarrage ; un déverrouillage détenu par +une autre instance est observé par polling borné des commandes, jamais relancé. +Les erreurs et annulations proposent une nouvelle action explicite. Le panneau +retire sa capture clavier avant le prompt natif ; il conserve le bouton +Nouvelle session. Après READY, seul un clic Restaurer appelle le chemin +historique de retrait de surface, prewarm, Guard et validation finale. + +Le gestionnaire de sessions nommées reçoit également le refus du backend et +l'affiche sans charger un ancien rapport d'échec. Les commandes de diagnostic, +checkpoint, sauvegarde, nouvelle session et ouverture normale du bureau ne +dépendent pas d'un trousseau disponible. + +## Limite tardive et invariants conservés + +Secret Service peut reverrouiller une collection à tout instant. Le verrou de +la barrière ne contrôle pas les autres applications, un autre trousseau ou +un autre client Secret Service. Il ne garantit donc pas qu'aucun prompt ne +pourra jamais apparaître après l'admission. Un prompt déjà demandé par un +client externe n'est pas notre objet Prompt et ne peut être annulé arbitrairement. +La [spécification Secret Service](https://specifications.freedesktop.org/secret-service/latest/unlocking.html) précise cette course inhérente. + +L'initialisation du default nécessaire à notre parcours est traitée avant la +restauration. Si une activation tardive interfère néanmoins, les mêmes preuves +con_id/PID/starttime/workspace/output et les mêmes postconditions de focus +refusent la transaction. **Aucun focus observé n'est remplacé pour fabriquer +un succès.** L'expérience headless crée une surface synthétique après +`after-guard-finish` et exige RECOVERY_FOCUS_MISMATCH, final_focus_verified=false +et RECOVERY_REQUIRED. Elle ne prétend pas identifier le dialogue de 08:01:48. + +Checkpoint V1, scratchpad V2, Guard.prepare, journaux et schémas Recovery, +placement et validations de focus ne sont pas modifiés. Les tests qui isolent +le prewarm simulent explicitement la nouvelle précondition ; les nouveaux tests +D-Bus et UI testent sa vraie implémentation séparément. + +## Reproduction de la validation + +Depuis ce worktree : + +```sh +python -B tests/session_v2/validate_keyring.py --sway /chemin/absolu/vers/sway-headless +``` + +Dépendances locales : Python/PyGObject/Gio, dbus-daemon, QuickShell/Qt/PySide6, +Sway pouvant démarrer headless avec pixman, outils GTK/compilateur déjà exigés +par les fixtures Session V2, kitty et grim. Aucun paquet système n'a été +installé. Le SwayFX installé ne convient pas au laboratoire sans DRM ; le +binaire Sway de test conservé par la mission précédente a été réutilisé : +`/tmp/labfy-scratchpad-tool/usr/bin/sway`. Son paquet avait le SHA-256 +`5704cfc9eba9804116c665cb4450352bf5a240c1a8300b36aca3218116648443`. + +Le runner impose une liste positive d'environnement, HOME et XDG privés. +Chaque test graphique réel crée son propre compositeur ; chaque service simulé +possède son bus privé. Aucun socket du bureau ni état de session utilisateur +n'est transmis. Les tests QML unitaires emploient des surfaces Item simulées et +Qt offscreen ; ils ne mappent aucune fenêtre sur un bureau réel. + +La matrice couvre les 20 cas demandés : déverrouillé ; verrouillé/Prompt ; +accepté avec relecture ; annulé ; mot de passe incorrect simulé par l'absence de +fin ; fin sans déverrouillage ; timeout RPC et prompt ; service absent ; default +absent/différent et login incompatible ; redémarrage helper et vrai QuickShell ; +deux demandes concurrentes ; refus apply-last ; absence d'autorestauration ; +nouvelle session ; surface tardive ; focus final stable ; RECOVERY_REQUIRED ; +régressions scratchpad/backend/focus ; erreurs contenant une sentinelle sensible +exclue des sorties et fichiers ; login sans aide PAM. Les scénarios de mot de +passe sont simulés : aucun mot de passe réel ou d'essai n'est manipulé. + +Validation finale : les onze lots réussissent (93 tests backend, 29 tests de +focus, 15 tests QML unitaires, 6 résultats Qt dont initialisation/nettoyage, +8 scénarios UI de trousseau, 2 scénarios moteur stable/tardif, recette scratchpad, +7 scénarios UI V2 existants, 5 scénarios P0 et 5 de stabilité des chemins). +Résultats et logs : `/tmp/labfy-keyring-validation-y8xyqh17/results.json` et ses +fichiers `.log`. Les chemins des laboratoires/surfaces synthétiques sont dans +ces logs. Après revue, les huit tests spécifiques D-Bus ont également été +repassés avec activation des services système désactivée dans le bus simulé : +`/tmp/labfy-keyring-privatebus-final.log`. Le test d'absence affirme NameHasOwner=false. Les avertissements PyGObject/Python de dépréciation ne sont pas des +erreurs ; aucune sortie ne contient de secret. Le runner est livré pour +reproduire les résultats si `/tmp` est nettoyé. + +## Installation contrôlée et rollback + +**Aucune installation n'a eu lieu.** Les fichiers de la configuration active +sont reliés aux sources du dépôt principal. Les modifier pendant que QuickShell +les surveille pourrait provoquer un rechargement. Ne pas appliquer le patch +sur ces sources pendant la session graphique réelle. + +Après revue et autorisation humaine de l'intégration et de la fermeture de la +session, depuis un TTY de secours sans QuickShell actif : + +1. Vérifier les changements postérieurs dans `/home/fy59/.dotfiles` et vérifier + la base. Conserver une copie privée de tous les fichiers concernés et du + manifeste de hashes. Ne pas toucher aux autres worktrees. +2. Examiner le patch complet livré (il inclut les nouveaux fichiers) et faire + `git apply --check /chemin/changes.patch` depuis le dépôt principal. Un refus + impose de réconcilier les changements ; ne jamais forcer/écraser. +3. Appliquer ce patch seulement après ce contrôle. Ne faire ni commit ni push + automatiquement. Vérifier la résolution des liens de configuration existants, + et `git diff --check`. Aucune modification PAM/systemd n'est à appliquer. +4. Vérifier `python -c 'from gi.repository import Gio, GLib'`. Si la dépendance + manque, reporter l'installation ; ne pas lancer un gestionnaire de paquets + sans autorisation. L'indisponibilité est un refus de restauration, pas un + refus d'ouverture du bureau. +5. Au prochain login autorisé, suivre le scénario ci-dessous. Ne pas redémarrer + le greeter ou le bureau pour « tester » depuis la session en cours. + +Rollback, également hors du QuickShell actif : + +```sh +cd /home/fy59/.dotfiles +git apply --reverse --check /chemin/changes.patch +git apply --reverse /chemin/changes.patch +``` + +En présence de dérive, restaurer seulement les fichiers sauvegardés après +comparaison avec le manifeste et revue du delta ; ne pas faire reset/restore/ +stash/clean. Les fichiers nouveaux du patch doivent être retirés uniquement +si leurs hashes correspondent encore à la livraison. Aucune donnée de +checkpoint, aucun journal Recovery et aucun trousseau ne sont supprimés ou +modifiés par l'installation/rollback. PAM et systemd restent identiques. + +## Prochain vrai login — autorisation humaine requise + +Ce scénario est proposé et **n'a pas été exécuté** : + +1. Autoriser l'intégration et une fermeture normale de la session, avec TTY de + secours disponible ; conserver les journaux et checkpoints existants. +2. S'authentifier normalement via greetd. Sans PAM ajouté, un default verrouillé + doit afficher Déverrouiller et Nouvelle session, sans restauration possible. +3. Déverrouiller dans le seul dialogue GNOME. N'enregistrer aucun secret et + n'activer aucune option créant/modifiant des trousseaux. Vérifier READY et + l'absence de lancement automatique ; cliquer ensuite explicitement Restaurer. +4. Après retrait du chooser, vérifier le rapport final : succès seulement si + arbre **et** focus sont validés, slots et nettoyage complets. Observer le + scratchpad sans corriger un focus inattendu pour rendre le test positif. +5. Lors d'une autre connexion autorisée, tester annulation et Nouvelle session. + Une panne du service doit laisser ouvrir le bureau et refuser la restauration. +6. Si un prompt tardif apparaît, conserver l'échec et RECOVERY_REQUIRED, + horodatages et identités observables. Ne jamais effacer le journal pour + relancer artificiellement. Une trace de focus seule ne prouve pas l'auteur. diff --git a/quickshell/.config/quickshell/labfy-sway/SessionStartupPromptV2.qml b/quickshell/.config/quickshell/labfy-sway/SessionStartupPromptV2.qml index 759535523..e145ec3f5 100644 --- a/quickshell/.config/quickshell/labfy-sway/SessionStartupPromptV2.qml +++ b/quickshell/.config/quickshell/labfy-sway/SessionStartupPromptV2.qml @@ -28,8 +28,31 @@ PanelWindow { property int windowCount: 0 property int workspaceCount: 0 readonly property bool busy: SessionV2Service.busy || watchingBackend + property bool unlockingRequested: false + readonly property bool keyringReady: SessionV2Service.keyringReady + function unlockRequested() { + if (!interactionArmed || busy || SessionV2Service.keyringBusy || unlockingRequested) return; + // CONTRACT: release exclusive keyboard before presenting the native + // prompt. Keep New session clickable throughout the unlock operation. + unlockingRequested = true; + unlockRelease.start(); + } + Timer { + id: unlockRelease + interval: 50; repeat: true + onTriggered: { + if (popup.focusable) return; + stop(); popup.unlockingRequested = false; + SessionV2Service.checkKeyring(true); + } + } + Timer { + interval: 2000; repeat: true + running: popup.visible && SessionV2Service.keyringState === "KEYRING_UNLOCKING" && !popup.releasing && !popup.unlockingRequested + onTriggered: SessionV2Service.checkKeyring(false) + } function restoreRequested() { - if (!interactionArmed || busy || releasing || (attempted && !retryAllowed)) return; + if (!keyringReady || !interactionArmed || busy || releasing || (attempted && !retryAllowed)) return; if (!SessionV2Service.prepareRestore(popup)) return; attempted = true; failed = false; @@ -44,7 +67,11 @@ PanelWindow { releasePoll.start(); } function newSessionRequested() { - if (interactionArmed && !busy) SessionV2Service.run("ack-new", "", "", popup); + if (interactionArmed && !busy) { + unlockRelease.stop(); unlockingRequested = false; + SessionV2Service.cancelKeyring(); + SessionV2Service.run("ack-new", "", "", popup); + } } function releaseFailed() { releasePoll.stop(); @@ -59,6 +86,12 @@ PanelWindow { } function fail(value) { inProgress = false; + if (value && /^KEYRING_/.test(value.reason)) { + // Admission failed before Executor: preserve the explicit retry + // path without fabricating a restore attempt or clearing Recovery. + attempted = false; failed = false; restored = false; + visible = true; SessionV2Service.checkKeyring(false); return; + } failed = true; restored = false; retryAllowed = !!value && value.status === "DEFERRED"; @@ -138,6 +171,7 @@ PanelWindow { SessionV2Service.run("restore-status", "", "", popup); } if (popup.visible) { + SessionV2Service.checkKeyring(false); popup.windowCount = value.windows || 0; popup.workspaceCount = value.workspaces || 0; } @@ -167,7 +201,7 @@ PanelWindow { exclusionMode: ExclusionMode.Ignore exclusiveZone: 0 aboveWindows: true - focusable: visible && !awaitingUserFocus && !releasing && !inProgress && !restored + focusable: visible && !unlockingRequested && !SessionV2Service.keyringBusy && SessionV2Service.keyringState !== "KEYRING_UNLOCKING" && !awaitingUserFocus && !releasing && !inProgress && !restored WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.namespace: "labfy-session-startup-v2" visible: false @@ -224,7 +258,11 @@ PanelWindow { text: popup.failed ? popup.failureMessage : popup.restored ? "Session restaurée" : popup.busy && popup.attempted ? "Restauration en cours…" - : popup.windowCount + " fenêtres • " + popup.workspaceCount + " espaces" + : popup.keyringReady ? "Une session précédente est disponible." + : SessionV2Service.keyringState === "KEYRING_CANCELLED" ? "Déverrouillage annulé. Réessayez ou choisissez Nouvelle session." + : SessionV2Service.keyringState === "KEYRING_UNAVAILABLE" ? "Trousseau indisponible. Réessayez ou choisissez Nouvelle session. (" + SessionV2Service.keyringReason + ")" + : SessionV2Service.keyringState === "KEYRING_UNLOCKING" ? "Déverrouillage du trousseau en cours…" + : "Le trousseau doit être déverrouillé avant la restauration de votre session." color: Theme.foreground wrapMode: Text.Wrap } @@ -250,9 +288,15 @@ PanelWindow { onClicked: popup.newSessionRequested() } ActionButton { - visible: !popup.failed || popup.retryAllowed + visible: !popup.keyringReady + label: "Déverrouiller le trousseau" + enabled: popup.interactionArmed && !popup.busy && !SessionV2Service.keyringBusy && !popup.unlockingRequested + onClicked: popup.unlockRequested() + } + ActionButton { + visible: popup.keyringReady && (!popup.failed || popup.retryAllowed) label: popup.retryAllowed ? "Réessayer" : "Restaurer" - enabled: popup.interactionArmed && !popup.busy && (!popup.attempted || popup.retryAllowed) + enabled: popup.keyringReady && popup.interactionArmed && !popup.busy && (!popup.attempted || popup.retryAllowed) onClicked: popup.restoreRequested() } } diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/SessionManagerV2.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/SessionManagerV2.qml index 741841b4a..b0a12c045 100644 --- a/quickshell/.config/quickshell/labfy-sway/controlcenter/SessionManagerV2.qml +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/SessionManagerV2.qml @@ -23,6 +23,12 @@ Item { if (owner !== page) return; if (kind === "list") { if (okay) page.sessions = value.sessions; return; } if (kind === "apply") { + if (value && /^KEYRING_/.test(value.reason)) { + // Admission precedes Attempt: never show an older report + // as the explanation for a refused keyring precondition. + page.feedback = "Déverrouillez le trousseau, puis cliquez à nouveau Restaurer. (" + value.reason + ")"; + return; + } if (okay && Protocol.restoreSucceeded(value)) page.feedback = "Session restaurée"; else { page.restoreFailed = true; diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/cli.py b/quickshell/.config/quickshell/labfy-sway/session_v2/cli.py index b5b41ea24..b5febc28c 100644 --- a/quickshell/.config/quickshell/labfy-sway/session_v2/cli.py +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/cli.py @@ -56,7 +56,7 @@ def migration(path): def main(argv=None): parser = argparse.ArgumentParser(description="Backend de restauration de session V2") - parser.add_argument("command", choices=("capture", "save", "list", "show", "delete", "plan", "apply", "checkpoint-last", "startup-status", "acknowledge-startup", "apply-last", "restore-status", "recovery-status", "analyze-v1-migration")) + parser.add_argument("command", choices=("keyring-status", "keyring-unlock", "capture", "save", "list", "show", "delete", "plan", "apply", "checkpoint-last", "startup-status", "acknowledge-startup", "apply-last", "restore-status", "recovery-status", "analyze-v1-migration")) parser.add_argument("name", nargs="?") parser.add_argument("--execute", action="store_true") parser.add_argument("--stdout", action="store_true") @@ -67,13 +67,18 @@ def main(argv=None): parser.add_argument("--anchor-binary", default=str(Path(__file__).parent / "libexec/labfy-v2-anchor")) args = parser.parse_args(argv) try: - if args.command in ("capture", "save", "delete", "apply", "checkpoint-last", "apply-last", "acknowledge-startup"): + if args.command in ("keyring-unlock", "capture", "save", "delete", "apply", "checkpoint-last", "apply-last", "acknowledge-startup"): require(args.execute, "EXECUTE_REQUIRED") if args.command == "recovery-status": from .recovery import recovery_status print(json.dumps(recovery_status(readonly_runtime()), ensure_ascii=False)) return 0 state, runtime = paths() + if args.command in ("keyring-status", "keyring-unlock"): + from .keyring import command as keyring_command + value = keyring_command(runtime, args.command == "keyring-unlock") + print(json.dumps(value)) + return 0 store = Store(state) command = args.command if command == "list": result = {"sessions": store.list()} @@ -105,7 +110,13 @@ def main(argv=None): result = data if command == "capture" else {"status": "saved"} else: data = store.last() if command == "apply-last" else store.load(args.name) - result = Executor(sway, catalog, provider, runtime, args.anchor_binary).apply(data, execute=True) + from .keyring import admission, require_ready + # WHY: QML readiness is advisory and can be stale after restart. + # INVARIANT: gate failure creates no restore/recovery attempt; + # the unchanged Executor still proves every postcondition. + with admission(runtime): + require_ready() + result = Executor(sway, catalog, provider, runtime, args.anchor_binary).apply(data, execute=True) print(json.dumps(result, ensure_ascii=False)) return 0 except Failure as exc: diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/keyring.py b/quickshell/.config/quickshell/labfy-sway/session_v2/keyring.py new file mode 100644 index 000000000..5f0991257 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/keyring.py @@ -0,0 +1,198 @@ +"""Secret Service readiness, without ever opening a secret session or reading items. + +WHY: daemon existence and a vanished prompt do not prove collection readiness. +CONTRACT: default must be the audited collection; an existing login alias must +agree. Missing login is supported (manual unlock), never proof of PAM success. +INVARIANT: no cached readiness, passwords, item metadata or free-form D-Bus errors +are persisted/logged. A private connection owns and dismisses only its prompt. +""" +from contextlib import contextmanager +import math +import signal +import time +from .errors import Failure, require +from .storage import lock, private_dir + +BUS = 'org.freedesktop.secrets' +ROOT = '/org/freedesktop/secrets' +SERVICE = 'org.freedesktop.Secret.Service' +COLLECTION = 'org.freedesktop.Secret.Collection' +PROMPT = 'org.freedesktop.Secret.Prompt' +EXPECTED = ROOT + '/collection/Default_5fkeyring' + + +def result(state, reason): + return {'state': 'KEYRING_' + state, 'reason': reason} + + +class Secrets: + def __init__(self, timeout=8): + # Lazy import keeps existing backend diagnostics usable without PyGObject. + from gi.repository import Gio, GLib, GLibUnix + self.Gio, self.GLib, self.GLibUnix = Gio, GLib, GLibUnix + self.deadline = time.monotonic() + timeout + address = Gio.dbus_address_get_for_bus_sync(Gio.BusType.SESSION, None) + # Bound connection/authentication too, not only subsequent RPC calls. + loop, cancellation, opened = GLib.MainLoop(), Gio.Cancellable(), [] + expired = [] + def connected(_source, response, _data): + try: + bus = Gio.DBusConnection.new_for_address_finish(response) + if expired: bus.close_sync(None) + else: opened.append(bus) + except GLib.Error: pass + loop.quit() + def expire(): + expired.append(True) + cancellation.cancel() + loop.quit() + return False + timer = GLib.timeout_add(2000, expire) + try: + Gio.DBusConnection.new_for_address(address, + Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT | + Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION, None, + cancellation, connected, None) + loop.run() + finally: + if GLib.MainContext.default().find_source_by_id(timer): GLib.source_remove(timer) + require(not expired, 'KEYRING_TIMEOUT') + require(opened, 'KEYRING_BUS_UNAVAILABLE') + self.bus = opened[0] + self.owner = None + try: + # Calls after activation are pinned to a unique owner. A replacement + # service must never inherit readiness from this connection. + try: self.owner = self.get_owner() + except GLib.Error: + self.call('org.freedesktop.DBus', '/org/freedesktop/DBus', + 'org.freedesktop.DBus', 'StartServiceByName', '(su)', (BUS, 0), '(u)') + self.owner = self.get_owner() + except BaseException: + self.close() + raise + + def call(self, destination, path, interface, method, signature, args, reply): + remaining = self.deadline - time.monotonic() + require(remaining > 0, 'KEYRING_TIMEOUT') + return self.bus.call_sync(destination, path, interface, method, + self.GLib.Variant(signature, args) if signature else None, + self.GLib.VariantType.new(reply), self.Gio.DBusCallFlags.NO_AUTO_START, + max(1, min(2000, math.ceil(remaining * 1000))), None).unpack() + + def get_owner(self): + return self.call('org.freedesktop.DBus', '/org/freedesktop/DBus', + 'org.freedesktop.DBus', 'GetNameOwner', '(s)', (BUS,), '(s)')[0] + + def alias(self, name): + return self.call(self.owner, ROOT, SERVICE, 'ReadAlias', '(s)', (name,), '(o)')[0] + + def inspect(self): + require(self.get_owner() == self.owner, 'KEYRING_OWNER_CHANGED') + collection = self.alias('default') + require(collection != '/', 'KEYRING_DEFAULT_MISSING') + require(collection == EXPECTED, 'KEYRING_COLLECTION_MISMATCH') + login = self.alias('login') + require(login in ('/', collection), 'KEYRING_LOGIN_MISMATCH') + value = self.call(self.owner, collection, 'org.freedesktop.DBus.Properties', + 'Get', '(ss)', (COLLECTION, 'Locked'), '(v)')[0] + require(type(value) is bool, 'KEYRING_INVALID_LOCKED') + require(self.get_owner() == self.owner, 'KEYRING_OWNER_CHANGED') + return value + + def unlock(self, timeout=90): + self.deadline = time.monotonic() + timeout + unlocked, prompt = self.call(self.owner, ROOT, SERVICE, 'Unlock', '(ao)', ([EXPECTED],), '(aoo)') + if prompt == '/': return + loop = self.GLib.MainLoop() + completed = [] + def receive(_bus, _sender, _path, _iface, _signal, parameters): + # Do not retain/inspect the operation-specific variant payload. + completed.append(parameters.get_child_value(0).get_boolean()) + loop.quit() + subscription = self.bus.signal_subscribe(self.owner, PROMPT, 'Completed', prompt, + None, self.Gio.DBusSignalFlags.NONE, receive) + timer = self.GLib.timeout_add(max(1, math.ceil((self.deadline - time.monotonic()) * 1000)), + lambda: (loop.quit(), False)[1]) + def cancel(): + completed.append(True) + loop.quit() + return False + # GLib owns the prompt wait: Python signal handlers alone are not + # dispatched while its native loop is idle. Termination must wake it. + termination = self.GLibUnix.signal_add(self.GLib.PRIORITY_DEFAULT, signal.SIGTERM, cancel) + try: + # Empty window-id is the portable unparented prompt. QuickShell + # releases its exclusive keyboard surface before this call. + self.call(self.owner, prompt, PROMPT, 'Prompt', '(s)', ('',), '()') + if not completed: loop.run() + require(completed, 'KEYRING_TIMEOUT') + require(completed[0] is False, 'KEYRING_CANCELLED') + finally: + for source in (timer, termination): + if self.GLib.MainContext.default().find_source_by_id(source): + self.GLib.source_remove(source) + self.bus.signal_unsubscribe(subscription) + # Best effort cleanup also runs on timeout/termination. Closing the + # private connection invalidates outstanding client-owned prompts. + try: + self.deadline = time.monotonic() + 1 + self.call(self.owner, prompt, PROMPT, 'Dismiss', None, (), '()') + except (Failure, self.GLib.Error): pass + + def close(self): + self.bus.close_sync(None) + + +def check(unlock=False, factory=Secrets, timeout=90): + connection = None + try: + connection = factory() + if not connection.inspect(): return result('READY', 'KEYRING_UNLOCKED') + if not unlock: return result('LOCKED', 'KEYRING_LOCKED') + connection.unlock(timeout) + connection.deadline = time.monotonic() + 8 + if connection.inspect(): return result('LOCKED', 'KEYRING_STILL_LOCKED') + return result('READY', 'KEYRING_UNLOCKED') + except Failure as exc: + return result('CANCELLED' if exc.code == 'KEYRING_CANCELLED' else 'UNAVAILABLE', exc.code) + except ImportError: + return result('UNAVAILABLE', 'KEYRING_DEPENDENCY_MISSING') + except Exception: + # Never expose remote error text; it may contain user-supplied material. + return result('UNAVAILABLE', 'KEYRING_DBUS_ERROR') + finally: + if connection is not None: + try: connection.close() + except Exception: pass + + +@contextmanager +def admission(runtime): + """Serialize unlock and CLI restoration across processes/QuickShell restarts. + + CONTRACT: acquire before the existing transaction lock, hold through apply. + No boolean, token or file survives as authorization for a future apply. + New-session acknowledgement uses only the existing transaction lock. + """ + with lock(private_dir(runtime / 'session-v2'), 'keyring.lock'): + yield + + +def command(runtime, unlock=False): + try: + with admission(runtime): + # An executor/guard predating this gate can still own the old lock. + # Check it before any Unlock; don't hold it during user interaction. + with lock(private_dir(runtime / 'session-v2')): pass + return check(unlock) + except KeyboardInterrupt: + return result('CANCELLED', 'KEYRING_CANCELLED') + except Failure as exc: + if exc.code == 'TRANSACTION_BUSY': return result('UNLOCKING', 'KEYRING_OR_RESTORE_BUSY') + raise + + +def require_ready(): + value = check() + require(value['state'] == 'KEYRING_READY', value['reason']) diff --git a/quickshell/.config/quickshell/labfy-sway/sessionui/SessionV2Service.qml b/quickshell/.config/quickshell/labfy-sway/sessionui/SessionV2Service.qml index da4aa209d..f13d13d6b 100644 --- a/quickshell/.config/quickshell/labfy-sway/sessionui/SessionV2Service.qml +++ b/quickshell/.config/quickshell/labfy-sway/sessionui/SessionV2Service.qml @@ -11,6 +11,37 @@ QtObject { property var client: null property bool startupChooserVisible: false readonly property bool busy: operation !== "" || backendProcess.running + // WHY: state is reconstructed from Secret Service, never persisted in QML. + // CONTRACT: readiness only enables a click; CLI rechecks under admission. + property string keyringState: "KEYRING_CHECKING" + property string keyringReason: "" + readonly property bool keyringBusy: keyringProcess.running + readonly property bool keyringReady: keyringState === "KEYRING_READY" && !keyringBusy + function checkKeyring(unlock) { + if (keyringBusy || busy) return false; + keyringState = unlock ? "KEYRING_UNLOCKING" : "KEYRING_CHECKING"; + const argv = ["python3", "-B", backend, unlock ? "keyring-unlock" : "keyring-status"]; + if (unlock) argv.push("--execute"); + keyringProcess.command = argv; + keyringProcess.running = true; + return true; + } + Component.onCompleted: checkKeyring(false) + function cancelKeyring() { keyringProcess.running = false; keyringState = "KEYRING_CHECKING"; } + property Process keyringProcess: Process { + running: false + stdout: StdioCollector { id: keyringOutput; waitForEnd: true } + // No remote errors or arbitrary stderr are displayed or journaled. + stderr: StdioCollector { waitForEnd: true } + onExited: (exitCode, exitStatus) => { + let value = null; + try { value = JSON.parse(keyringOutput.text); } catch (_) {} + const states = ["KEYRING_READY", "KEYRING_LOCKED", "KEYRING_CANCELLED", "KEYRING_UNAVAILABLE", "KEYRING_UNLOCKING"]; + service.keyringState = exitCode === 0 && exitStatus === 0 && value && states.indexOf(value.state) >= 0 + ? value.state : "KEYRING_UNAVAILABLE"; + service.keyringReason = value && /^KEYRING_[A-Z_]{1,48}$/.test(value.reason) ? value.reason : "KEYRING_BACKEND_ERROR"; + } + } signal completed(var owner, string kind, var value, bool okay) // Diagnostic lecture seule ; aucun endpoint IPC ne peut cliquer Restore. @@ -18,14 +49,14 @@ QtObject { target: "sessionV2" function status(): string { return JSON.stringify({backend: "v2", busy: service.busy, - operation: service.operation, startupChooserCount: service.startupChooserVisible ? 1 : 0}); + operation: service.operation, keyringState: service.keyringState, keyringReason: service.keyringReason, startupChooserCount: service.startupChooserVisible ? 1 : 0}); } } // Réserver la sérialisation avant de retirer la surface. Le Timer du // dialogue n'est pas propriétaire d'une seconde commande backend. function prepareRestore(owner) { - if (busy || !owner) return false; + if (busy || !owner || !keyringReady) return false; client = owner; operation = "release-focus"; return true; @@ -46,6 +77,7 @@ QtObject { // INVARIANT : un unique Process sérialise les commandes V2 de QuickShell. function run(kind, name, extra, owner) { if (busy) return false; + if (kind === "apply-last" && !keyringReady) return false; const allowed = ["startup-status", "ack-new", "ack-restored", "apply-last", "apply", "list", "show", "save", "delete", "plan", "restore-status", "checkpoint-last"]; if (allowed.indexOf(kind) < 0) return false; const argv = ["python3", "-B", backend]; diff --git a/quickshell/.config/quickshell/labfy-sway/tests/focus/test_qml.py b/quickshell/.config/quickshell/labfy-sway/tests/focus/test_qml.py index db3e2e94c..09ea3c3e5 100644 --- a/quickshell/.config/quickshell/labfy-sway/tests/focus/test_qml.py +++ b/quickshell/.config/quickshell/labfy-sway/tests/focus/test_qml.py @@ -60,6 +60,14 @@ QtObject { property QtObject stdout property QtObject stderr signal exited(int exitCode, int exitStatus) + // This focus fixture models only an already unlocked service. Private-bus + // and headless keyring tests exercise the real transport/prompt lifecycle. + onRunningChanged: { + if (running && command.indexOf("keyring-status") >= 0) Qt.callLater(() => { + stdout.text = JSON.stringify({state:"KEYRING_READY", reason:"KEYRING_UNLOCKED"}); + running = false; exited(0, 0); + }); + } }''') (self.dir / 'sessionui/StdioCollector.qml').write_text('import QtQuick\nQtObject { property bool waitForEnd; property string text: "" }') (self.dir / 'sessionui/IpcHandler.qml').write_text('import QtQuick\nQtObject { property string target }') @@ -85,6 +93,8 @@ Item { self.assertIsNotNone(self.root, '\n'.join(e.toString() for e in self.component.errors())) self.prompt = self.root.findChild(QObject, 'prompt') self.service = self.root.property('service') + QTest.qWait(5) # finish the asynchronous fixture's initial D-Bus check + self.service.setProperty('keyringState', 'KEYRING_READY') def tearDown(self): self.root.deleteLater(); self.engine.deleteLater(); QTest.qWait(1); self.tmp.cleanup() def call(self, name): QMetaObject.invokeMethod(self.prompt, name, Qt.DirectConnection) @@ -240,4 +250,24 @@ Item { self.finish('startup-status', dict(eligible=True, claimed=True, interrupted=True)) self.assertEqual(self.calls()[-1]['kind'], 'restore-status') + def test_keyring_gate_requires_ready_and_explicit_click(self): + self.service.setProperty('keyringState', 'KEYRING_LOCKED') + self.open(); self.call('restoreRequested'); QTest.qWait(100) + self.assertFalse(self.prompt.property('attempted')) + self.assertEqual(self.calls(), []) + self.service.setProperty('keyringState', 'KEYRING_READY') + QTest.qWait(150) + self.assertEqual(self.calls(), []) + self.call('restoreRequested'); QTest.qWait(100) + self.assertEqual([x['kind'] for x in self.calls()], ['apply-last']) + + def test_new_session_during_keyring_unlock_remains_accessible(self): + self.service.setProperty('keyringState', 'KEYRING_LOCKED') + self.open(); self.call('unlockRequested'); QTest.qWait(100) + self.assertTrue(self.service.property('keyringBusy')) + self.assertFalse(self.prompt.property('focusable')) + self.call('newSessionRequested') + self.assertEqual([x['kind'] for x in self.calls()], ['ack-new']) + self.assertFalse(self.service.property('keyringBusy')) + if __name__ == '__main__': unittest.main() diff --git a/tests/session_v2/fixtures/secret_service.py b/tests/session_v2/fixtures/secret_service.py new file mode 100644 index 000000000..e76f38879 --- /dev/null +++ b/tests/session_v2/fixtures/secret_service.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""TEST_ONLY Secret Service. No secrets/items/passwords exist in this fixture.""" +import sys +from gi.repository import Gio, GLib + +mode = sys.argv[1] +root = '/org/freedesktop/secrets' +collection = root + '/collection/Default_5fkeyring' +prompt = root + '/prompt/test' +locked = mode != 'ready' +xml = ''' + + + + + + + + +''' +bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) +bus.call_sync('org.freedesktop.DBus', '/org/freedesktop/DBus', 'org.freedesktop.DBus', + 'RequestName', GLib.Variant('(su)', ('org.freedesktop.secrets', 0)), + GLib.VariantType.new('(u)'), Gio.DBusCallFlags.NONE, 2000, None) + +def method(bus, sender, path, interface, name, args, invocation): + global locked + if name == 'ReadAlias': + if mode == 'error': + invocation.return_dbus_error('org.freedesktop.Secret.Error.Failed', 'TEST_ONLY_SENSITIVE_SENTINEL') + return + if mode == 'dbus-timeout': return # deliberately leave request unanswered + alias = args.unpack()[0] + value = collection if alias == 'default' else '/' + if mode == 'missing': value = '/' + if mode == 'different' and alias == 'default': value = root + '/collection/other' + if mode == 'login-different' and alias == 'login': value = root + '/collection/login' + invocation.return_value(GLib.Variant('(o)', (value,))) + elif name == 'Unlock': + print('UNLOCK', flush=True) + if mode == 'immediate': + locked = False + invocation.return_value(GLib.Variant('(aoo)', ([collection], '/'))) + else: invocation.return_value(GLib.Variant('(aoo)', ([], prompt))) + elif name == 'Prompt': + print('PROMPT', flush=True) + invocation.return_value(GLib.Variant('()', ())) + def complete(): + global locked + if mode not in ('still-locked', 'wrong-password', 'cancel'): locked = False + bus.emit_signal(sender, prompt, interface, 'Completed', + GLib.Variant('(bv)', (mode == 'cancel', GLib.Variant('ao', [collection])))) + return False + if mode not in ('timeout', 'wrong-password'): + GLib.timeout_add(400 if mode == 'slow' else 10, complete) + elif name == 'Dismiss': + print('DISMISS', flush=True) + invocation.return_value(GLib.Variant('()', ())) + +def prop(*_): return GLib.Variant('b', locked) + +node = Gio.DBusNodeInfo.new_for_xml(xml) +for path, iface in ((root, 0), (collection, 1), (prompt, 2)): + bus.register_object(path, node.interfaces[iface], method, prop, None) +print('READY', flush=True) +GLib.MainLoop().run() diff --git a/tests/session_v2/keyring_runtime.py b/tests/session_v2/keyring_runtime.py new file mode 100644 index 000000000..0a2673fd9 --- /dev/null +++ b/tests/session_v2/keyring_runtime.py @@ -0,0 +1,58 @@ +"""Late synthetic keyring-like surface: real focus proof must still fail. + +CONTRACT: the surface is TEST_ONLY and does not establish the historical +identity of con_id 18. No event infers user-vs-IPC causality from app_id. +""" +import json +from unittest.mock import patch +from common import * +from lab import Lab, Surfaces, build_helper +from session_v2.executor import Executor +from session_v2.recovery import Recovery, recovery_status +from session_v2.ipc import Sway +from session_v2.errors import Failure +from session_v2.storage import private_dir, read + + +def main(): + for late in (False, True): + with Lab() as lab: + print(lab.directory, flush=True) + helper = build_helper(lab.directory) + apps = Surfaces(lab, helper) + lab.command('workspace TEST_ONLY_TARGET') + apps.create('TEST_ONLY_app-A') + data = fixture('A') + sway = Sway(lab.socket) + runtime = private_dir(lab.directory / 'runtime') + binary = build(lab.directory) + original = Recovery.boundary + observed = [] + def boundary(recovery, phase): + original(recovery, phase) + if phase == 'after-guard-finish' and late: + # Same placement in the lifecycle as the historical race. + # A real compositor maps/focuses this independent surface. + observed.append(apps.create('TEST_ONLY_keyring_prompt')) + engine = Executor(sway, catalog(data), None, runtime, binary, lab.env) + with patch.object(Recovery, 'boundary', boundary): + if late: + try: engine.apply(data, True) + except Failure as exc: assert exc.code == 'RECOVERY_FOCUS_MISMATCH', exc.code + else: raise AssertionError('LATE_FOCUS_WAS_ACCEPTED') + else: + value = engine.apply(data, True) + assert value['status'] == 'success' and value['final_focus_verified'] + report = read(runtime / 'session-v2-restore-attempt.json') + if late: + assert report['status'] == 'failed' and report['reason'] == 'RECOVERY_FOCUS_MISMATCH', report + assert not report['final_focus_verified'] + assert recovery_status(runtime)['status'] == 'RECOVERY_REQUIRED' + # Attribution remains an observation; no focus correction to + # manufacture success after an unexpected late activation. + assert observed[0]['app_id'] == 'TEST_ONLY_keyring_prompt' + print('late-refused' if late else 'stable-final-focus', 'PASS', flush=True) + apps.close() + + +if __name__ == '__main__': main() diff --git a/tests/session_v2/keyring_ui.py b/tests/session_v2/keyring_ui.py new file mode 100644 index 000000000..f3737ac6f --- /dev/null +++ b/tests/session_v2/keyring_ui.py @@ -0,0 +1,126 @@ +"""Actual chooser/Secret Service client on private Sway and private D-Bus only.""" +import json +import shutil +import subprocess +from common import * +from lab import Lab +from test_keyring import KeyringTests + +SOURCE = BACKEND +HARNESS = '''//@ pragma UseQApplication +import QtQuick +import Quickshell +import "sessionui" +ShellRoot { + PanelWindow { id: bar; visible: false } + SessionStartupPromptV2 { id: prompt; barWindow: bar; startupHost: true } + Timer { interval: 1250; running: true; onTriggered: { + if (!prompt.interactionArmed) { console.error("KEYRING_UI_FAIL_NOT_ARMED"); Qt.quit(); return; } + if (Quickshell.env("CASE") !== "ready") { + prompt.restoreRequested(); + if (prompt.attempted) { console.error("KEYRING_UI_FAIL_BYPASS"); Qt.quit(); return; } + if (Quickshell.env("CASE") !== "restart-check") prompt.unlockRequested(); + } + } } + Timer { interval: 2300; running: true; onTriggered: { + const mode = Quickshell.env("CASE"); + if (prompt.attempted || prompt.inProgress) { console.error("KEYRING_UI_FAIL_AUTORESTORE"); Qt.quit(); return; } + if (mode === "ready" || mode === "accept") { + if (!prompt.keyringReady) { console.error("KEYRING_UI_FAIL_NOT_READY"); Qt.quit(); return; } + prompt.restoreRequested(); + } else { + if (prompt.keyringReady) { console.error("KEYRING_UI_FAIL_FALSE_READY"); Qt.quit(); return; } + prompt.newSessionRequested(); + } + } } + Timer { interval: 3700; running: true; onTriggered: { + const mode = Quickshell.env("CASE"); + const okay = mode === "ready" || mode === "accept" ? prompt.restored && !prompt.visible : !prompt.visible && !prompt.attempted; + console.log(okay ? "KEYRING_UI_PASS" : "KEYRING_UI_FAIL_FINAL"); Qt.quit(); + } } +} +''' +# The chooser and gate are production. Only restoration/checkpoint discovery are +# synthetic here; actual Executor/focus behavior is tested in keyring_runtime.py. +WRAPPER = '''import sys, json, os +from pathlib import Path +sys.path.insert(0, SOURCE) +from session_v2.cli import main +cmd=sys.argv[1] +with Path(os.environ['CALLS']).open('a') as log: log.write(cmd+'\\n') +if cmd.startswith('keyring-'): sys.exit(main()) +if cmd == 'startup-status': value={'eligible':True,'claimed':True,'windows':4,'workspaces':2} +elif cmd == 'apply-last': value={'schema':'labfy.sway.session-v2-restore-attempt','version':1,'status':'success','transaction_id':'a'*32,'identity_level':'exact','final_tree_verified':True,'final_focus_verified':True,'slots_expected':4,'slots_filled':4,'applications_expected':3,'applications_observed':3,'anchors_created':3,'anchors_cleaned':3,'helpers_suspended':0,'helpers_resumed':0} +elif cmd == 'acknowledge-startup': value={'status':'acknowledged','choice':sys.argv[sys.argv.index('--choice')+1]} +else: raise RuntimeError('TEST_ONLY_UNEXPECTED') +print(json.dumps(value)) +''' + + +def main(): + with Lab() as lab: + print(lab.directory, flush=True) + for mode in ('ready', 'accept', 'cancel', 'timeout', 'absent', 'still-locked', 'different'): + with KeyringTests().service(mode) as (busenv, _): + base = lab.directory / mode / 'config/quickshell/labfy-sway' + base.mkdir(parents=True) + for item in ('sessionui', 'controlcenter', 'theme'): + shutil.copytree(SOURCE / item, base / item) + shutil.copy(SOURCE / 'SessionStartupPromptV2.qml', base) + (base / 'shell.qml').write_text(HARNESS) + (base / 'session-v2.py').write_text(WRAPPER.replace('SOURCE', repr(str(SOURCE)))) + calls = lab.directory / (mode + '.calls') + env = {**lab.env, 'DBUS_SESSION_BUS_ADDRESS': busenv['DBUS_SESSION_BUS_ADDRESS'], + 'XDG_CONFIG_HOME': str(base.parents[1]), 'XDG_RUNTIME_DIR': busenv['XDG_RUNTIME_DIR'], + 'WAYLAND_DISPLAY': str(lab.directory / lab.env['WAYLAND_DISPLAY']), + 'CASE': mode, 'CALLS': str(calls)} + proc = subprocess.run(['qs', '-p', str(base), '--no-color'], env=env, + capture_output=True, text=True, timeout=12) + output = proc.stdout + proc.stderr + (lab.directory / (mode + '.qml.log')).write_text(output) + assert 'KEYRING_UI_PASS' in output and 'KEYRING_UI_FAIL' not in output, output + seen = calls.read_text().splitlines() + assert seen.count('apply-last') == (1 if mode in ('ready', 'accept') else 0), seen + assert seen.count('acknowledge-startup') == 1, seen + print(mode, 'PASS', flush=True) + # Real QuickShell process destruction, not just a reset QML boolean. + with KeyringTests().service('timeout') as (busenv, server): + base = lab.directory / 'restart/config/quickshell/labfy-sway' + base.mkdir(parents=True) + for item in ('sessionui', 'controlcenter', 'theme'): + shutil.copytree(SOURCE / item, base / item) + shutil.copy(SOURCE / 'SessionStartupPromptV2.qml', base) + (base / 'shell.qml').write_text(HARNESS) + (base / 'session-v2.py').write_text(WRAPPER.replace('SOURCE', repr(str(SOURCE)))) + calls = lab.directory / 'restart.calls' + env = {**lab.env, 'DBUS_SESSION_BUS_ADDRESS': busenv['DBUS_SESSION_BUS_ADDRESS'], + 'XDG_CONFIG_HOME': str(base.parents[1]), 'XDG_RUNTIME_DIR': busenv['XDG_RUNTIME_DIR'], + 'WAYLAND_DISPLAY': str(lab.directory / lab.env['WAYLAND_DISPLAY']), + 'CASE': 'timeout', 'CALLS': str(calls)} + first = subprocess.Popen(['qs', '-p', str(base), '--no-color'], env=env, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + try: + import select + # Read raw pipe so TextIO buffering cannot hide the second line. + observed = b'' + deadline = time.monotonic() + 8 + while b'PROMPT\n' not in observed and time.monotonic() < deadline: + if select.select([server.stdout], [], [], .2)[0]: + observed += os.read(server.stdout.fileno(), 4096) + assert b'UNLOCK\n' in observed and b'PROMPT\n' in observed, observed + finally: + first.terminate() + first.communicate(timeout=5) + # A replacement shell reconstructs locked/busy from backend/D-Bus; + # it must not reissue Unlock or Restore just because it restarted. + env['CASE'] = 'restart-check' + restarted = subprocess.run(['qs', '-p', str(base), '--no-color'], env=env, + capture_output=True, text=True, timeout=12) + output = restarted.stdout + restarted.stderr + assert 'KEYRING_UI_PASS' in output and 'KEYRING_UI_FAIL' not in output, output + seen = calls.read_text().splitlines() + assert seen.count('keyring-unlock') == 1 and 'apply-last' not in seen, seen + print('quickshell-restart-during-unlock PASS', flush=True) + + +if __name__ == '__main__': main() diff --git a/tests/session_v2/phase3b_ui.py b/tests/session_v2/phase3b_ui.py index e315a6445..1d833b8c6 100644 --- a/tests/session_v2/phase3b_ui.py +++ b/tests/session_v2/phase3b_ui.py @@ -21,6 +21,7 @@ log = Path(os.environ['LABFY_UI_LOG']) with log.open('a') as s: s.write(cmd + '\\n') mode = os.environ['LABFY_UI_MODE'] if cmd == 'startup-status': r = {'eligible': mode != 'claimed', 'claimed': mode != 'claimed', 'windows': 3, 'workspaces': 2} +elif cmd == 'keyring-status': r = {'state': 'KEYRING_READY', 'reason': 'KEYRING_UNLOCKED'} elif cmd == 'apply-last': r = {'schema':'labfy.sway.session-v2-restore-attempt', 'version':1, 'status':'success', 'transaction_id':'a'*32, 'identity_level':'exact', 'final_tree_verified':mode != 'invalid', 'final_focus_verified':True, 'slots_expected':3, 'slots_filled':3, 'applications_expected':2, 'applications_observed':2, 'anchors_created':3, 'anchors_cleaned':3, 'helpers_suspended':1, 'helpers_resumed':1} if mode == 'failure': r = {'status':'failed', 'reason':'APPLICATION_WINDOWS_INCOMPLETE'} @@ -86,7 +87,9 @@ ShellRoot { def main(): rows = [] - with Lab() as lab: + from test_keyring import KeyringTests + with KeyringTests().service('ready') as (busenv, _), Lab() as lab: + lab.env['DBUS_SESSION_BUS_ADDRESS'] = busenv['DBUS_SESSION_BUS_ADDRESS'] for mode in ('success', 'failure', 'invalid', 'new', 'claimed', 'real', 'deferred-real'): config = lab.directory / mode / 'config' base = config / 'quickshell/labfy-sway' diff --git a/tests/session_v2/test_keyring.py b/tests/session_v2/test_keyring.py new file mode 100644 index 000000000..825b77b22 --- /dev/null +++ b/tests/session_v2/test_keyring.py @@ -0,0 +1,152 @@ +"""Real Gio client against a private mock bus; never accesses the desktop bus.""" +import contextlib +import io +import json +import subprocess +import tempfile +import unittest +from unittest.mock import patch +from common import BACKEND, REPO, Path, os, sys +from session_v2 import keyring, cli +from session_v2.errors import Failure +from session_v2.storage import private_dir, lock + + +class KeyringTests(unittest.TestCase): + @contextlib.contextmanager + def service(self, mode): + with tempfile.TemporaryDirectory(prefix='labfy-keyring-TEST_ONLY-') as tmp: + env = {'PATH': '/usr/bin:/bin', 'LANG': 'C.UTF-8', 'PYTHONDONTWRITEBYTECODE': '1'} + for name in ('HOME', 'XDG_RUNTIME_DIR', 'XDG_CONFIG_HOME', 'XDG_STATE_HOME', 'XDG_CACHE_HOME'): + path = Path(tmp) / name; path.mkdir(mode=0o700); env[name] = str(path) + # No standard service directories: an "absent" fixture must not + # accidentally activate the installed real GNOME Keyring daemon. + config = Path(tmp) / 'bus.conf' + config.write_text('sessionunix:tmpdir=' + tmp + '' + 'EXTERNAL' + '') + daemon = subprocess.Popen(['dbus-daemon', '--config-file=' + str(config), '--nofork', '--print-address=1'], + env=env, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True) + server = None + try: + address = daemon.stdout.readline().strip() + env['DBUS_SESSION_BUS_ADDRESS'] = address + if mode != 'absent': + server = subprocess.Popen([sys.executable, '-B', str(REPO / 'tests/session_v2/fixtures/secret_service.py'), mode], + env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + self.assertEqual(server.stdout.readline().strip(), 'READY') + with patch.dict(os.environ, env, clear=True): + yield env, server + finally: + for proc in (server, daemon): + if proc is not None: + proc.terminate() + try: proc.communicate(timeout=5) + except subprocess.TimeoutExpired: + proc.kill(); proc.communicate(timeout=5) + raise + + def test_actual_dbus_states(self): + cases = [('ready', False, 'READY'), ('locked', False, 'LOCKED'), + ('accept', True, 'READY'), ('immediate', True, 'READY'), + ('cancel', True, 'CANCELLED'), ('still-locked', True, 'LOCKED'), + ('wrong-password', True, 'UNAVAILABLE'), ('timeout', True, 'UNAVAILABLE'), + ('absent', False, 'UNAVAILABLE'), ('missing', False, 'UNAVAILABLE'), + ('different', False, 'UNAVAILABLE'), ('login-different', False, 'UNAVAILABLE'), + ('dbus-timeout', False, 'UNAVAILABLE'), ('error', False, 'UNAVAILABLE')] + for mode, unlock, expected in cases: + with self.subTest(mode=mode), self.service(mode): + value = keyring.check(unlock, timeout=.15) + self.assertEqual(value['state'], 'KEYRING_' + expected, value) + self.assertEqual(set(value), {'state', 'reason'}) + if mode == 'absent': + owner = subprocess.run(['busctl', '--address=' + os.environ['DBUS_SESSION_BUS_ADDRESS'], + 'call', 'org.freedesktop.DBus', '/org/freedesktop/DBus', 'org.freedesktop.DBus', + 'NameHasOwner', 's', keyring.BUS], capture_output=True, text=True, timeout=5) + self.assertEqual(owner.stdout.strip(), 'b false') + + def test_two_processes_and_restart(self): + with self.service('timeout') as (env, server): + argv = [sys.executable, '-B', str(BACKEND / 'session-v2.py'), 'keyring-unlock', '--execute'] + child = subprocess.Popen(argv, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + try: + self.assertEqual(server.stdout.readline().strip(), 'UNLOCK') + self.assertEqual(server.stdout.readline().strip(), 'PROMPT') + # A new QuickShell/CLI must discover busy from the kernel lock. + status = subprocess.run([*argv[:3], 'keyring-status'], env=env, capture_output=True, text=True, timeout=10) + self.assertEqual(json.loads(status.stdout)['state'], 'KEYRING_UNLOCKING') + duplicate = subprocess.run(argv, env=env, capture_output=True, text=True, timeout=10) + self.assertEqual(json.loads(duplicate.stdout)['state'], 'KEYRING_UNLOCKING') + runtime = Path(env['XDG_RUNTIME_DIR']) / 'labfy-sway' + from session_v2.startup import acknowledge + self.assertEqual(acknowledge(runtime, 'TEST_ONLY', 'new')['choice'], 'new') + finally: + child.terminate() + try: child.communicate(timeout=5) + except subprocess.TimeoutExpired: + child.kill(); child.communicate(timeout=5) + raise + status = subprocess.run([*argv[:3], 'keyring-status'], env=env, capture_output=True, text=True, timeout=10) + self.assertEqual(json.loads(status.stdout)['state'], 'KEYRING_LOCKED') + # No readiness/cache/secret persists; only private lock and startup decision. + files = list((Path(env['XDG_RUNTIME_DIR']) / 'labfy-sway').rglob('*')) + self.assertTrue(all(p.name in ('session-v2', 'keyring.lock', 'transaction.lock', 'startup.json') for p in files)) + + def test_apply_last_cannot_bypass_gate(self): + with self.service('locked'): + with patch.object(cli, 'Store') as store, patch.object(cli, 'Sway'), \ + patch.object(cli, 'Catalog'), patch.object(cli, 'FirefoxProvider'), \ + patch.object(cli, 'Executor') as executor, contextlib.redirect_stdout(io.StringIO()) as out: + store.return_value.last.return_value = {} + self.assertEqual(cli.main(['apply-last', '--execute']), 2) + self.assertEqual(json.loads(out.getvalue())['reason'], 'KEYRING_LOCKED') + executor.assert_not_called() + + def test_ready_preserves_executor_result_and_recovery_required(self): + for code in (None, 'RECOVERY_REQUIRED', 'RECOVERY_FOCUS_MISMATCH'): + with self.subTest(code=code), self.service('ready'): + with patch.object(cli, 'Store'), patch.object(cli, 'Sway'), patch.object(cli, 'Catalog'), \ + patch.object(cli, 'FirefoxProvider'), patch.object(cli, 'Executor') as executor, \ + contextlib.redirect_stdout(io.StringIO()) as out: + if code: executor.return_value.apply.side_effect = Failure(code) + else: executor.return_value.apply.return_value = {'status': 'TEST_ONLY_EXECUTOR_RESULT'} + rc = cli.main(['apply-last', '--execute']) + self.assertEqual(rc, 2 if code else 0) + self.assertEqual(json.loads(out.getvalue()), {'status': 'failed', 'reason': code} if code else {'status': 'TEST_ONLY_EXECUTOR_RESULT'}) + executor.return_value.apply.assert_called_once() + + def test_unlock_never_restores(self): + with self.service('accept'): + with patch.object(cli, 'Executor') as executor, contextlib.redirect_stdout(io.StringIO()) as out: + self.assertEqual(cli.main(['keyring-unlock', '--execute']), 0) + self.assertEqual(json.loads(out.getvalue())['state'], 'KEYRING_READY') + executor.assert_not_called() + + def test_remote_error_text_is_never_logged_or_persisted(self): + with self.service('error') as (env, _): + value = subprocess.run([sys.executable, '-B', str(BACKEND / 'session-v2.py'), 'keyring-status'], + env=env, capture_output=True, text=True, timeout=10) + self.assertNotIn('TEST_ONLY_SENSITIVE_SENTINEL', value.stdout + value.stderr) + self.assertEqual(json.loads(value.stdout)['state'], 'KEYRING_UNAVAILABLE') + for path in Path(env['XDG_RUNTIME_DIR']).rglob('*'): + if path.is_file(): self.assertNotIn(b'TEST_ONLY_SENSITIVE_SENTINEL', path.read_bytes()) + + def test_apply_is_excluded_during_unlock(self): + with self.service('ready') as (env, _): + runtime = private_dir(Path(env['XDG_RUNTIME_DIR']) / 'labfy-sway') + with keyring.admission(runtime), patch.object(cli, 'Store'), patch.object(cli, 'Sway'), \ + patch.object(cli, 'Catalog'), patch.object(cli, 'FirefoxProvider'), \ + patch.object(cli, 'Executor') as executor, contextlib.redirect_stdout(io.StringIO()) as out: + self.assertEqual(cli.main(['apply-last', '--execute']), 2) + self.assertEqual(json.loads(out.getvalue())['reason'], 'TRANSACTION_BUSY') + executor.assert_not_called() + + def test_transaction_prevents_prompt(self): + with self.service('locked') as (env, server): + runtime = private_dir(Path(env['XDG_RUNTIME_DIR']) / 'labfy-sway') + with lock(private_dir(runtime / 'session-v2')): + self.assertEqual(keyring.command(runtime, True)['state'], 'KEYRING_UNLOCKING') + self.assertEqual(keyring.check()['state'], 'KEYRING_LOCKED') + + +if __name__ == '__main__': unittest.main() diff --git a/tests/session_v2/test_prewarm.py b/tests/session_v2/test_prewarm.py index 171b5a668..df4e9ecf3 100644 --- a/tests/session_v2/test_prewarm.py +++ b/tests/session_v2/test_prewarm.py @@ -105,6 +105,7 @@ class PrewarmTests(unittest.TestCase): self.assertEqual(seen['deferred_reason'], 'APPLICATION_LAUNCH_FAILED') output = io.StringIO() with patch('session_v2.cli.paths', return_value=(self.runtime/'state', self.runtime)), \ + patch('session_v2.keyring.require_ready'), \ patch('session_v2.cli.Store'), patch('session_v2.cli.Sway'), patch('session_v2.cli.FirefoxProvider'), \ patch('session_v2.cli.Executor.apply', side_effect=Deferred('APPLICATION_LAUNCH_FAILED')), redirect_stdout(output): self.assertEqual(main(['apply-last', '--execute', '--socket', '/TEST_ONLY']), 2) diff --git a/tests/session_v2/validate_keyring.py b/tests/session_v2/validate_keyring.py new file mode 100644 index 000000000..b066fe09e --- /dev/null +++ b/tests/session_v2/validate_keyring.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Reproducible keyring tranche: isolated buses, HOME/XDG and private Sway only.""" +import argparse +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--sway', required=True, type=Path) + args = parser.parse_args() + if not args.sway.is_absolute() or not args.sway.is_file() or not os.access(args.sway, os.X_OK): + parser.error('--sway must be an absolute executable (headless-capable)') + root = Path(__file__).resolve().parents[2] + output = Path(tempfile.mkdtemp(prefix='labfy-keyring-validation-')) + # No SWAYSOCK, graphical bus, user configurations, secrets or session are + # inherited. Every graphical test creates its own child headless compositor. + env = {'PATH': '/usr/bin:/bin', 'LANG': 'C.UTF-8', 'PYTHONDONTWRITEBYTECODE': '1', + 'QT_QPA_PLATFORM': 'offscreen', 'QT_QUICK_BACKEND': 'software', + 'LABFY_TEST_SWAY': str(args.sway), 'LABFY_TEST_RENDERER': 'pixman', + 'LABFY_SESSION_V2_EVIDENCE': str(output / 'evidence')} + for key in ('HOME', 'XDG_CONFIG_HOME', 'XDG_STATE_HOME', 'XDG_CACHE_HOME', 'XDG_RUNTIME_DIR'): + target = output / key; target.mkdir(mode=0o700); env[key] = str(target) + py = [sys.executable, '-B'] + checks = { + 'backend': [*py, '-m', 'unittest', 'discover', '-s', 'tests/session_v2', '-p', 'test_*.py'], + 'focus': [*py, 'quickshell/.config/quickshell/labfy-sway/tests/focus/test_focus.py'], + 'qml-unit': [*py, 'quickshell/.config/quickshell/labfy-sway/tests/focus/test_qml.py'], + 'qml-qt': ['/usr/lib/qt6/bin/qmltestrunner', '-input', 'tests/session_v2/qml', '-o', '-,txt'], + **{name: [*py, 'tests/session_v2/' + filename + '.py'] for name, filename in ( + ('keyring-ui', 'keyring_ui'), ('keyring-runtime', 'keyring_runtime'), + ('scratchpad-runtime', 'scratchpad_runtime'), ('phase3b-ui', 'phase3b_ui'), + ('p0-runtime', 'p0_runtime'), ('path-runtime', 'path_stability_runtime'))}, + 'diff-check': ['git', 'diff', '--check'], + } + results = {} + print(output, flush=True) + for name, argv in checks.items(): + with (output / (name + '.log')).open('w') as log: + try: results[name] = subprocess.run(argv, cwd=root, env=env, stdout=log, stderr=subprocess.STDOUT, timeout=180).returncode + except (OSError, subprocess.TimeoutExpired) as exc: + log.write(str(exc) + '\n'); results[name] = 'UNAVAILABLE_OR_TIMEOUT' + print(name, results[name], flush=True) + (output / 'results.json').write_text(json.dumps(results, indent=2) + '\n') + return int(any(value != 0 for value in results.values())) + + +if __name__ == '__main__': sys.exit(main())