diff options
| -rwxr-xr-x | archsetup | 69 | ||||
| -rw-r--r-- | packages/gnome-keyring/0001-daemon-Add-Cinnamon-to-autostart-files.patch | 51 | ||||
| -rw-r--r-- | packages/gnome-keyring/0002-secret-service-missing-client.patch | 275 | ||||
| -rw-r--r-- | packages/gnome-keyring/PKGBUILD | 82 | ||||
| -rw-r--r-- | packages/gnome-keyring/gnome-keyring.install | 14 | ||||
| -rw-r--r-- | tests/installer-steps/test_install_gnome_keyring_fix.py | 209 | ||||
| -rw-r--r-- | working/keyring-crash/deploy-ratio.sh | 65 | ||||
| -rw-r--r-- | working/keyring-crash/gnome-keyring-51.1-secret-service-missing-client.patch | 275 | ||||
| -rw-r--r-- | working/keyring-crash/probe.py | 300 | ||||
| -rwxr-xr-x | working/keyring-crash/run-alias-only.sh | 23 | ||||
| -rwxr-xr-x | working/keyring-crash/run-package-test.sh | 24 | ||||
| -rwxr-xr-x | working/keyring-crash/run-regression.sh | 50 | ||||
| -rwxr-xr-x | working/keyring-crash/run-upstream-tests.sh | 11 | ||||
| -rw-r--r-- | working/keyring-crash/setup-guest.sh | 40 | ||||
| -rw-r--r-- | working/keyring-crash/status.org | 118 | ||||
| -rwxr-xr-x | working/keyring-crash/vm-run.sh | 18 |
16 files changed, 1621 insertions, 3 deletions
@@ -1719,6 +1719,65 @@ aur_installer() { error_fatal "$action" "$?" "confirm base-devel is installed (pacman -S base-devel), then re-run" } +### GNOME Keyring Secret Service fix +# gnome-keyring 48 through 51.1 aborts when a client's first OpenSession is +# dispatched before the daemon has created its per-client record (GNOME issue +# 190, merge request !112, unmerged as of 2026-10-17). Each abort drops the +# unlocked state, so the next request pops the keyring password dialog; on +# ratio that fired about three times a day once the ChatGPT desktop app's +# codex app-server started bursting keyring reads. Until Arch or upstream +# ships the fix, packages/gnome-keyring in this repo is Arch's own recipe for +# 1:51.1-1 plus the patch, at pkgrel 1.1. That version sorts above stock and +# below any future 1:51.1-2 or 51.2, so a normal upgrade replaces it the +# moment a real fix lands; nothing here pins or holds the package. +# +# The build runs as the user from a copy of the recipe under the source dir: +# makepkg refuses to run as root and writes build products next to the +# PKGBUILD, which must not land in the user's checkout. A failed build warns +# and leaves the stock package in place rather than installing anything by +# hand. +# +# This step owns the stock install too. pacman's --needed only skips an equal +# version, so a plain pacman_install gnome-keyring ahead of this step would +# downgrade the rebuild back to stock on every re-run and trigger a fresh +# build and daemon restart each time. Stock is installed only when no +# gnome-keyring is present at all, as the baseline the rebuild upgrades. +install_gnome_keyring_fix() { + local recipe="${user_archsetup_dir:-/home/$username/code/archsetup}/packages/gnome-keyring" + local build_dir="$source_dir/gnome-keyring" + local fixed_version="1:51.1-1.1" + local installed + + action="patched gnome-keyring (Secret Service missing-client fix)" && display "task" "$action" + + installed="$(pacman -Q gnome-keyring 2>/dev/null | awk '{print $2}')" + if [ -z "$installed" ]; then + pacman_install gnome-keyring + else + case "$(vercmp "$installed" "$fixed_version")" in + 0) return 0 ;; # the rebuild is already installed + 1) # A deliberate skip, not an error: something newer than the + # rebuild is installed, so the fix is assumed to be upstream. + display "task" "gnome-keyring $installed is newer than the rebuild, skipping; verify the Secret Service no longer aborts" + return 0 ;; + esac + fi + + [ -f "$recipe/PKGBUILD" ] || { error_warn "$action: recipe missing at $recipe" 1; return 1; } + + if [ -e "$build_dir" ]; then + safe_rm_rf "$build_dir" "$source_dir" >> "$logfile" 2>&1 || \ + { error_warn "$action: removing stale build dir $build_dir" "$?"; return 1; } + fi + (mkdir -p "$build_dir" && cp -a "$recipe/." "$build_dir/" && chown -R "$username": "$build_dir") >> "$logfile" 2>&1 || \ + { error_warn "$action: staging the recipe" "$?"; return 1; } + + # -s installs the makedepends, -i installs the result; one command, as the + # yay bootstrap does, so a build failure is the install failure. + (cd "$build_dir" && sudo -u "$username" makepkg --noconfirm -si >> "$logfile" 2>&1) || \ + { error_warn "$action: makepkg failed, stock gnome-keyring left in place" "$?"; return 1; } +} + ### Essential Services essential_services() { display "title" "Essential Services" @@ -2748,7 +2807,7 @@ desktop_environment() { action="Authentication Tools" && display "subtitle" "$action" pacman_install gnupg pacman_install polkit - pacman_install gnome-keyring + install_gnome_keyring_fix # installs stock gnome-keyring first when none is present pacman_install seahorse pacman_install pass pacman_install rbw # Bitwarden CLI client (Rust) @@ -2760,8 +2819,12 @@ desktop_environment() { find /home/"$username"/.gnupg -type f -exec chmod 600 {} \; find /home/"$username"/.gnupg -type d -exec chmod 700 {} \; - # pre-create gnome-keyring directory and set default keyring to "login" - # this allows auto-unlock without password prompt (works with autologin) + # Pre-create the gnome-keyring directory and name "login" as the default + # collection. That only picks which collection applications use; the + # unlock itself comes from pam_gnome_keyring in the login and hyprlock PAM + # stacks, which unlock the login collection with the password typed at + # login. It does not unlock anything under console autologin, where no + # password is typed (I had claimed otherwise here; corrected 2026-10-17). keyring_dir="/home/$username/.local/share/keyrings" mkdir -p "$keyring_dir" echo "login" > "$keyring_dir/default" diff --git a/packages/gnome-keyring/0001-daemon-Add-Cinnamon-to-autostart-files.patch b/packages/gnome-keyring/0001-daemon-Add-Cinnamon-to-autostart-files.patch new file mode 100644 index 0000000..980d537 --- /dev/null +++ b/packages/gnome-keyring/0001-daemon-Add-Cinnamon-to-autostart-files.patch @@ -0,0 +1,51 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: "Jan Alexander Steffens (heftig)" <heftig@archlinux.org> +Date: Sat, 21 May 2022 18:47:23 +0000 +Subject: [PATCH] daemon: Add Cinnamon to autostart files + +Cinnamon also wants to use gnome-keyring-daemon. +--- + daemon/gnome-keyring-pkcs11.desktop.in.in | 2 +- + daemon/gnome-keyring-secrets.desktop.in.in | 2 +- + daemon/gnome-keyring-ssh.desktop.in.in | 2 +- + 3 files changed, 3 insertions(+), 3 deletions(-) + +diff --git a/daemon/gnome-keyring-pkcs11.desktop.in.in b/daemon/gnome-keyring-pkcs11.desktop.in.in +index 3e36603fac70..bb729caddfd1 100644 +--- a/daemon/gnome-keyring-pkcs11.desktop.in.in ++++ b/daemon/gnome-keyring-pkcs11.desktop.in.in +@@ -3,7 +3,7 @@ Type=Application + Name=Certificate and Key Storage + Comment=GNOME Keyring: PKCS#11 Component + Exec=@bindir@/gnome-keyring-daemon --start --components=pkcs11 +-OnlyShowIn=GNOME;Unity;MATE; ++OnlyShowIn=GNOME;Unity;MATE;Cinnamon; + NoDisplay=true + X-GNOME-Autostart-Phase=PreDisplayServer + X-GNOME-AutoRestart=false +diff --git a/daemon/gnome-keyring-secrets.desktop.in.in b/daemon/gnome-keyring-secrets.desktop.in.in +index 799caeea4306..6c836a2a7ec9 100644 +--- a/daemon/gnome-keyring-secrets.desktop.in.in ++++ b/daemon/gnome-keyring-secrets.desktop.in.in +@@ -3,7 +3,7 @@ Type=Application + Name=Secret Storage Service + Comment=GNOME Keyring: Secret Service + Exec=@bindir@/gnome-keyring-daemon --start --components=secrets +-OnlyShowIn=GNOME;Unity;MATE; ++OnlyShowIn=GNOME;Unity;MATE;Cinnamon; + NoDisplay=true + X-GNOME-Autostart-Phase=PreDisplayServer + X-GNOME-AutoRestart=false +diff --git a/daemon/gnome-keyring-ssh.desktop.in.in b/daemon/gnome-keyring-ssh.desktop.in.in +index fd7657a4d0cc..8f9847033507 100644 +--- a/daemon/gnome-keyring-ssh.desktop.in.in ++++ b/daemon/gnome-keyring-ssh.desktop.in.in +@@ -3,7 +3,7 @@ Type=Application + Name=SSH Key Agent + Comment=GNOME Keyring: SSH Agent + Exec=@bindir@/gnome-keyring-daemon --start --components=ssh +-OnlyShowIn=GNOME;Unity;MATE; ++OnlyShowIn=GNOME;Unity;MATE;Cinnamon; + X-GNOME-Autostart-Phase=PreDisplayServer + X-GNOME-AutoRestart=false + X-GNOME-Autostart-Notify=true diff --git a/packages/gnome-keyring/0002-secret-service-missing-client.patch b/packages/gnome-keyring/0002-secret-service-missing-client.patch new file mode 100644 index 0000000..9ef298b --- /dev/null +++ b/packages/gnome-keyring/0002-secret-service-missing-client.patch @@ -0,0 +1,275 @@ +diff -ruN a/daemon/dbus/gkd-secret-objects.c b/daemon/dbus/gkd-secret-objects.c +--- a/daemon/dbus/gkd-secret-objects.c 2026-09-17 15:41:00.000000000 -0500 ++++ b/daemon/dbus/gkd-secret-objects.c 2026-10-17 06:47:19.275009074 -0500 +@@ -430,7 +430,11 @@ + + /* The session we're using to access the object */ + session = gkd_secret_service_get_pkcs11_session (self->service, sender); +- g_return_val_if_fail (session, FALSE); ++ if (session == NULL) { ++ g_free (c_ident); ++ g_free (i_ident); ++ goto out; ++ } + + if (i_ident) { + gck_builder_add_ulong (&builder, CKA_CLASS, CKO_SECRET_KEY); +@@ -1055,17 +1059,23 @@ + if (!gkd_secret_util_parse_path (path, &identifier, NULL)) + return NULL; + +- if (g_str_has_prefix (path, SECRET_ALIAS_PREFIX)) +- real_identifier = gkd_secret_service_get_alias (self->service, identifier); +- else +- real_identifier = identifier; +- +- /* The session we're using to access the object */ ++ /* The session we're using to access the object. Resolve it before the ++ * alias: fetching a caller's session may register the caller, which ++ * refreshes the default alias and frees the string the alias table ++ * held, so an alias looked up earlier would dangle here. */ + if (caller == NULL) + session = gkd_secret_service_internal_pkcs11_session (self->service); + else + session = gkd_secret_service_get_pkcs11_session (self->service, caller); +- g_return_val_if_fail (session, NULL); ++ if (session == NULL) { ++ g_free (identifier); ++ return NULL; ++ } ++ ++ if (g_str_has_prefix (path, SECRET_ALIAS_PREFIX)) ++ real_identifier = gkd_secret_service_get_alias (self->service, identifier); ++ else ++ real_identifier = identifier; + + gck_builder_add_ulong (&builder, CKA_CLASS, CKO_G_COLLECTION); + gck_builder_add_string (&builder, CKA_ID, real_identifier); +diff -ruN a/daemon/dbus/gkd-secret-service.c b/daemon/dbus/gkd-secret-service.c +--- a/daemon/dbus/gkd-secret-service.c 2026-09-17 15:41:00.000000000 -0500 ++++ b/daemon/dbus/gkd-secret-service.c 2026-10-17 06:47:19.274715149 -0500 +@@ -137,6 +137,9 @@ + GHashTable *clients; + GkdSecretObjects *objects; + GHashTable *aliases; ++ /* The message filter reads aliases on the GDBus worker thread while ++ * the main thread updates them; every access goes through this lock. */ ++ GMutex aliases_lock; + GckSession *internal_session; + gchar *default_path; + }; +@@ -190,6 +193,18 @@ + return g_build_filename (alias_directory, "default", NULL); + } + ++/* An owned copy of an alias, safe to call from any thread. */ ++static gchar * ++lookup_alias_copy (GkdSecretService *self, const gchar *alias) ++{ ++ gchar *copy; ++ ++ g_mutex_lock (&self->aliases_lock); ++ copy = g_strdup (g_hash_table_lookup (self->aliases, alias)); ++ g_mutex_unlock (&self->aliases_lock); ++ return copy; ++} ++ + static void + update_default (GkdSecretService *self) + { +@@ -206,21 +221,31 @@ + /* Default to to 'login' if no default keyring */ + if (contents == NULL) + contents = g_strdup ("login"); +- g_hash_table_replace (self->aliases, g_strdup ("default"), contents); ++ ++ /* Replace only on a real change: callers on the main thread hold the ++ * previous value borrowed, and the common case (another client ++ * registering) must not free it out from under them. */ ++ g_mutex_lock (&self->aliases_lock); ++ if (g_strcmp0 (g_hash_table_lookup (self->aliases, "default"), contents) == 0) ++ g_free (contents); ++ else ++ g_hash_table_replace (self->aliases, g_strdup ("default"), contents); ++ g_mutex_unlock (&self->aliases_lock); + } + + static void + store_default (GkdSecretService *self) + { + GError *error = NULL; +- const gchar *identifier; ++ gchar *identifier; + +- identifier = g_hash_table_lookup (self->aliases, "default"); ++ identifier = lookup_alias_copy (self, "default"); + if (!identifier) + return; + + if (!g_file_set_contents (self->default_path, identifier, -1, &error)) + g_message ("couldn't store default keyring: %s", egg_error_message (error)); ++ g_free (identifier); + } + + static gboolean +@@ -331,7 +356,7 @@ + GDBusMessage *message) + { + const char *path = g_dbus_message_get_path (message); +- const char *replace; ++ char *replace; + char *collection = NULL, *item = NULL; + char *collection_path, *item_path; + GDBusMessage *rewritten; +@@ -346,7 +371,9 @@ + if (!gkd_secret_util_parse_path (path, &collection, &item)) + return message; + +- replace = gkd_secret_service_get_alias (self, collection); ++ /* This runs on the GDBus worker thread: take a copy under the lock ++ * rather than borrowing from a table the main thread may be updating. */ ++ replace = lookup_alias_copy (self, collection); + if (!replace) { + g_free (item); + g_free (collection); +@@ -356,6 +383,9 @@ + rewritten = g_dbus_message_copy (message, &error); + if (error != NULL) { + g_error_free (error); ++ g_free (replace); ++ g_free (item); ++ g_free (collection); + return message; + } + +@@ -372,6 +402,7 @@ + } + + g_free (collection_path); ++ g_free (replace); + g_free (item); + g_free (collection); + g_object_unref (message); +@@ -424,18 +455,30 @@ + GError *error = NULL; + const gchar *caller; + GVariant *input_payload; ++ gboolean opened; + + caller = g_dbus_method_invocation_get_sender (invocation); + + /* Now we can create a session with this information */ + session = gkd_secret_session_new (self, caller); + input_payload = g_variant_get_variant (input); +- gkd_secret_session_handle_open (session, algorithm, input_payload, +- &output, &result, +- &error); ++ opened = gkd_secret_session_handle_open (session, algorithm, input_payload, ++ &output, &result, ++ &error); + g_variant_unref (input_payload); + +- if (error != NULL) { ++ /* Branch on the return value, not on the error alone: a negotiate helper ++ * that bails out through g_return_val_if_fail() reports failure without ++ * ever setting one, and completing the call with a NULL body makes ++ * g_variant_new() abort the whole daemon. */ ++ if (!opened || output == NULL || result == NULL) { ++ if (error == NULL) ++ g_set_error_literal (&error, G_DBUS_ERROR, ++ G_DBUS_ERROR_FAILED, ++ "Couldn't open a session for this caller."); ++ if (output != NULL) ++ g_variant_unref (g_variant_ref_sink (output)); ++ g_free (result); + g_dbus_method_invocation_take_error (invocation, error); + } else { + gkd_secret_service_publish_dispatch (self, caller, +@@ -986,6 +1029,7 @@ + static void + gkd_secret_service_init_aliases (GkdSecretService *self) + { ++ g_mutex_init (&self->aliases_lock); + self->aliases = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, g_free); + g_hash_table_insert (self->aliases, g_strdup ("session"), g_strdup ("session")); + /* TODO: We should be using CKA_G_LOGIN_COLLECTION */ +@@ -1151,6 +1195,7 @@ + + g_hash_table_destroy (self->aliases); + self->aliases = NULL; ++ g_mutex_clear (&self->aliases_lock); + + g_free (self->default_path); + self->default_path = NULL; +@@ -1283,7 +1328,18 @@ + g_return_val_if_fail (caller, NULL); + + client = g_hash_table_lookup (self->clients, caller); +- g_return_val_if_fail (client, NULL); ++ if (client == NULL) { ++ /* The record is normally created by a high priority idle queued from ++ * the message filter, but that idle is not ordered against the method ++ * call that queued it: a peer which connects and calls straight away ++ * can reach us first. Create it here instead of failing the call -- ++ * it is a hash table insert, not I/O. Callers must not hold borrowed ++ * alias strings across this call: registering a client refreshes the ++ * default alias. */ ++ gkd_secret_service_ensure_client (self, caller); ++ client = g_hash_table_lookup (self->clients, caller); ++ g_return_val_if_fail (client, NULL); ++ } + + /* Open a new session if necessary */ + if (!client->pkcs11_session) { +@@ -1399,7 +1455,9 @@ + g_return_if_fail (GKD_SECRET_IS_SERVICE (self)); + g_return_if_fail (alias); + ++ g_mutex_lock (&self->aliases_lock); + g_hash_table_replace (self->aliases, g_strdup (alias), g_strdup (identifier)); ++ g_mutex_unlock (&self->aliases_lock); + + if (g_str_equal (alias, "default")) + store_default (self); +@@ -1418,6 +1476,10 @@ + + /* Take ownership of the session */ + client = g_hash_table_lookup (self->clients, caller); ++ if (client == NULL) { ++ gkd_secret_service_ensure_client (self, caller); ++ client = g_hash_table_lookup (self->clients, caller); ++ } + g_return_if_fail (client); + path = gkd_secret_dispatch_get_object_path (object); + g_return_if_fail (!g_hash_table_lookup (client->dispatch, path)); +diff -ruN a/daemon/dbus/gkd-secret-session.c b/daemon/dbus/gkd-secret-session.c +--- a/daemon/dbus/gkd-secret-session.c 2026-09-17 15:41:00.000000000 -0500 ++++ b/daemon/dbus/gkd-secret-session.c 2026-10-17 06:47:19.270295281 -0500 +@@ -183,7 +183,12 @@ + gboolean ret; + + session = gkd_secret_service_get_pkcs11_session (self->service, self->caller); +- g_return_val_if_fail (session, FALSE); ++ if (session == NULL) { ++ g_set_error_literal (error_out, G_DBUS_ERROR, ++ G_DBUS_ERROR_FAILED, ++ "Couldn't open a session for this caller."); ++ return FALSE; ++ } + + if (!aes_create_dh_keys (session, "ietf-ike-grp-modp-1024", &pub, &priv)) { + g_set_error_literal (error_out, G_DBUS_ERROR, +@@ -250,7 +255,12 @@ + GckSession *session; + + session = gkd_secret_service_get_pkcs11_session (self->service, self->caller); +- g_return_val_if_fail (session, FALSE); ++ if (session == NULL) { ++ g_set_error_literal (error_out, G_DBUS_ERROR, ++ G_DBUS_ERROR_FAILED, ++ "Couldn't open a session for this caller."); ++ return FALSE; ++ } + + gck_builder_add_ulong (&builder, CKA_CLASS, CKO_SECRET_KEY); + gck_builder_add_ulong (&builder, CKA_KEY_TYPE, CKK_G_NULL); diff --git a/packages/gnome-keyring/PKGBUILD b/packages/gnome-keyring/PKGBUILD new file mode 100644 index 0000000..a8e32a2 --- /dev/null +++ b/packages/gnome-keyring/PKGBUILD @@ -0,0 +1,82 @@ +# Local rebuild of Arch's gnome-keyring 1:51.1-1 with the Secret Service +# missing-client fix applied. I build from GNOME's release tarball (checksum +# published by GNOME) instead of the signed git tag so the build does not need +# the maintainer's key in the keyring of whichever machine builds it. pkgrel +# 1.1 sorts above the stock 1:51.1-1 and below any future 1:51.1-2, so the +# next upstream or Arch fix replaces it on a normal upgrade. +# Maintainer: Jan Alexander Steffens (heftig) <heftig@archlinux.org> +# Contributor: Jan De Groot <jgc@archlinux.org> + +pkgname=gnome-keyring +pkgver=51.1 +pkgrel=1.1 +epoch=1 +pkgdesc="Stores passwords and encryption keys (Secret Service missing-client fix)" +url="https://gitlab.gnome.org/GNOME/gnome-keyring" +arch=(x86_64) +license=( + GPL-2.0-or-later + LGPL-2.1-or-later +) +depends=( + dconf + gcr + gcr-4 + glib2 + glibc + libcap-ng + libgcc + libgcrypt + pam + systemd-libs +) +makedepends=( + docbook-xsl + git + glib2-devel + meson + p11-kit + python +) +provides=(org.freedesktop.secrets) +groups=(gnome) +install=gnome-keyring.install +source=( + "https://download.gnome.org/sources/gnome-keyring/${pkgver%%.*}/gnome-keyring-$pkgver.tar.xz" + 0001-daemon-Add-Cinnamon-to-autostart-files.patch + 0002-secret-service-missing-client.patch +) +sha256sums=('dfe7adc5876ef0670e7eba2907799bdbb8875ce58bb4208e1a52169bd16a47ca' + '138ad9a41dcbf9faede3a8b7fcb61dd5894ca8b66599802e58f8bb81e9607eb2' + '2463bf109af415412bf99e9aca98c51f215cfdcc9de728bbf7155db861bf79d7') + +prepare() { + cd gnome-keyring-$pkgver + + # Autolaunch in Cinnamon (Arch's own patch, unchanged) + patch -Np1 -i ../0001-daemon-Add-Cinnamon-to-autostart-files.patch + # Register Secret Service clients at the point of use, return real errors + # instead of completing OpenSession with a NULL body, and resolve the + # collection alias only after the caller's session exists. + patch -Np1 -i ../0002-secret-service-missing-client.patch +} + +build() { + local meson_options=( + -D selinux=disabled + ) + + arch-meson gnome-keyring-$pkgver build "${meson_options[@]}" + meson compile -C build +} + +check() { + # Secure memory tests fail + dbus-run-session meson test -C build --print-errorlogs || : +} + +package() { + meson install -C build --no-rebuild --destdir "$pkgdir" +} + +# vim:set sw=2 sts=-1 et: diff --git a/packages/gnome-keyring/gnome-keyring.install b/packages/gnome-keyring/gnome-keyring.install new file mode 100644 index 0000000..6143dd1 --- /dev/null +++ b/packages/gnome-keyring/gnome-keyring.install @@ -0,0 +1,14 @@ +post_install() { + # Enable socket by default + systemctl --global enable gnome-keyring-daemon.socket +} + +post_upgrade() { + if (( $(vercmp $2 1:42.0-1) < 0)); then + systemctl --global enable gnome-keyring-daemon.socket + fi +} + +pre_remove() { + systemctl --global disable gnome-keyring-daemon.socket +} diff --git a/tests/installer-steps/test_install_gnome_keyring_fix.py b/tests/installer-steps/test_install_gnome_keyring_fix.py new file mode 100644 index 0000000..1a4115a --- /dev/null +++ b/tests/installer-steps/test_install_gnome_keyring_fix.py @@ -0,0 +1,209 @@ +"""Tests for install_gnome_keyring_fix in the archsetup installer. + +gnome-keyring 51.1 (and 48 through 50) aborts when a Secret Service client's +first OpenSession is dispatched before the daemon has created its per-client +record. Every abort loses the unlocked state, so the next request pops the +keyring password dialog; on ratio this fired three times a day once the +ChatGPT desktop app's codex app-server started bursting keyring reads. The +fix is upstream merge request !112 plus an alias-lifetime correction, carried +as a patched rebuild of Arch's own package under packages/gnome-keyring in +this repo (pkgrel 1.1). install_gnome_keyring_fix builds and installs it with +makepkg as the user when the stock 1:51.1-1 is what is installed. + +Versioning is the whole contract. The rebuild sorts above stock 1:51.1-1 and +below any future 1:51.1-2 or 51.2, so a normal pacman upgrade replaces it the +moment Arch or upstream ships a fix, and the step must never pin or hold the +package. When something newer than the rebuild is already installed, the fix +is assumed upstream and the step skips with a warning so the assumption is +visible rather than silent. When the rebuild itself is installed, the step is +a no-op. + +These tests exercise the REAL function body, extracted from the `archsetup` +script at run time. pacman, vercmp, makepkg and sudo are replaced by +recorders driven by environment switches; the package directory is a temp +copy of the real packages/gnome-keyring tree. + +Run from repo root: + python3 -m unittest tests.installer-steps.test_install_gnome_keyring_fix +""" + +import os +import re +import shutil +import subprocess +import tempfile +import unittest + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") +PACKAGE_DIR = os.path.join(REPO_ROOT, "packages", "gnome-keyring") + +STUBS = r""" +logfile=/dev/null +action="" +username=tester +display() { echo "DISPLAY: $*"; } +pacman_install() { echo "PACMAN_INSTALL: $1" >> "$MARKER"; } +error_warn() { echo "WARN:$1"; return 1; } +# pacman -Q prints the installed version, or fails when INSTALLED is empty. +pacman() { + echo "PACMAN: $*" >> "$MARKER" + case "$1" in + -Q) [ -n "$INSTALLED" ] && echo "gnome-keyring $INSTALLED" ;; + *) return 0 ;; + esac +} +# The real vercmp is used so the ordering under test is pacman's own. +makepkg() { + echo "MAKEPKG: $* (pwd=$PWD)" >> "$MARKER" + [ "$MAKEPKG_FAILS" = 1 ] && return 2 + return 0 +} +# sudo -u user cmd... runs cmd in place; the recorder notes the user. +sudo() { + echo "SUDO: $*" >> "$MARKER" + shift 2 + "$@" +} +safe_rm_rf() { rm -rf "$1"; } +# chown needs root; the tests only care that the copy was staged. +chown() { echo "CHOWN: $*" >> "$MARKER"; } +""" + + +def run(pkg_dir, installed, makepkg_fails=False, source_dir=None): + marker = tempfile.NamedTemporaryFile(prefix="keyring-fix-", delete=False) + marker.close() + source_dir = source_dir or tempfile.mkdtemp(prefix="keyring-src-") + script = ( + STUBS + + "source <(sed -n '/^install_gnome_keyring_fix() {/,/^}/p' \"%s\")\n" + + 'user_archsetup_dir="%s"\n' + + 'source_dir="%s"\n' + + "install_gnome_keyring_fix\n" + + 'echo "RC=$?"\n' + ) % (ARCHSETUP, pkg_dir, source_dir) + env = dict(os.environ, MARKER=marker.name, INSTALLED=installed, + MAKEPKG_FAILS="1" if makepkg_fails else "0") + result = subprocess.run(["bash", "-c", script], capture_output=True, + text=True, timeout=30, env=env) + with open(marker.name) as f: + calls = f.read().splitlines() + os.remove(marker.name) + return result, calls, source_dir + + +class InstallGnomeKeyringFix(unittest.TestCase): + def setUp(self): + # A fake archsetup checkout holding a copy of the real package tree. + self.checkout = tempfile.mkdtemp(prefix="archsetup-checkout-") + self.addCleanup(shutil.rmtree, self.checkout, ignore_errors=True) + shutil.copytree(PACKAGE_DIR, os.path.join(self.checkout, "packages", "gnome-keyring")) + self.source_dir = tempfile.mkdtemp(prefix="keyring-src-") + self.addCleanup(shutil.rmtree, self.source_dir, ignore_errors=True) + + def _run(self, installed, **kw): + return run(self.checkout, installed, source_dir=self.source_dir, **kw) + + @staticmethod + def _makepkg_calls(calls): + return [c for c in calls if c.startswith("MAKEPKG:")] + + # ---------------------------------------------------------- normal ---- + def test_stock_51_1_gets_the_rebuild(self): + result, calls, _ = self._run("1:51.1-1") + self.assertEqual(len(self._makepkg_calls(calls)), 1, calls) + self.assertIn("RC=0", result.stdout) + self.assertNotIn("WARN:", result.stdout) + + def test_builds_as_the_user_with_syncdeps_and_install(self): + _, calls, _ = self._run("1:51.1-1") + self.assertTrue(any(c.startswith("SUDO: -u tester makepkg") for c in calls), calls) + makepkg = self._makepkg_calls(calls)[0] + self.assertIn("--noconfirm", makepkg) + # -s (sync makedepends) and -i (install), spelled separately or combined. + short = "".join(t[1:] for t in makepkg.split() if re.fullmatch(r"-[a-zA-Z]+", t)) + self.assertIn("s", short, makepkg) + self.assertIn("i", short, makepkg) + + def test_builds_from_a_user_owned_copy_not_the_checkout(self): + # makepkg writes into its directory; the checkout must stay clean. + _, calls, _ = self._run("1:51.1-1") + makepkg = self._makepkg_calls(calls)[0] + self.assertIn("pwd=" + self.source_dir, makepkg) + self.assertNotIn("pwd=" + self.checkout, makepkg) + self.assertTrue(os.path.exists(os.path.join(self.source_dir, "gnome-keyring", "PKGBUILD"))) + + def test_older_stock_releases_also_get_the_rebuild(self): + # 50.0 carries the same bug; the rebuild is 51.1 so it is an upgrade. + _, calls, _ = self._run("1:50.0-1") + self.assertEqual(len(self._makepkg_calls(calls)), 1) + + # -------------------------------------------------------- boundary ---- + def test_rebuild_already_installed_is_a_noop(self): + result, calls, _ = self._run("1:51.1-1.1") + self.assertEqual(self._makepkg_calls(calls), []) + self.assertIn("RC=0", result.stdout) + self.assertNotIn("WARN:", result.stdout) + + def test_newer_package_skips_visibly_without_counting_an_error(self): + for newer in ("1:51.1-2", "1:51.2-1", "1:52.0-1"): + with self.subTest(installed=newer): + result, calls, _ = self._run(newer) + self.assertEqual(self._makepkg_calls(calls), [], newer) + self.assertIn("newer than the rebuild", result.stdout, + "a newer package means the fix is assumed upstream; say so") + self.assertNotIn("WARN:", result.stdout, + "a deliberate skip must not count as an installer error") + self.assertIn("RC=0", result.stdout) + + def test_rerun_after_success_does_not_rebuild(self): + _, calls, _ = self._run("1:51.1-1") + self.assertEqual(len(self._makepkg_calls(calls)), 1) + _, calls, _ = self._run("1:51.1-1.1") + self.assertEqual(self._makepkg_calls(calls), []) + + def test_missing_package_installs_stock_first_then_builds(self): + # With no gnome-keyring present, stock is installed as the baseline + # and the rebuild then upgrades it; both happen in that order. + result, calls, _ = self._run("") + self.assertEqual(len(self._makepkg_calls(calls)), 1) + stock = [i for i, c in enumerate(calls) if c.startswith("PACMAN_INSTALL: gnome-keyring")] + build = [i for i, c in enumerate(calls) if c.startswith("MAKEPKG:")] + self.assertEqual(len(stock), 1, calls) + self.assertLess(stock[0], build[0], "stock install must precede the build") + self.assertIn("RC=0", result.stdout) + + def test_rerun_with_rebuild_installed_never_touches_stock(self): + # pacman's --needed only skips an equal version, so a stock install + # ahead of the rebuild would downgrade it on every re-run. The step + # must not call the stock install at all once anything is installed. + for installed in ("1:51.1-1.1", "1:51.1-1", "1:51.2-1"): + with self.subTest(installed=installed): + _, calls, _ = self._run(installed) + self.assertFalse(any(c.startswith("PACMAN_INSTALL:") for c in calls), calls) + + # ----------------------------------------------------------- error ---- + def test_failed_build_warns_and_leaves_stock_in_place(self): + result, calls, _ = self._run("1:51.1-1", makepkg_fails=True) + self.assertIn("WARN:", result.stdout) + self.assertNotIn("RC=0", result.stdout) + self.assertFalse(any(c.startswith("PACMAN: -U") for c in calls), + "nothing may be installed by hand after a failed build") + + def test_missing_recipe_warns_instead_of_building(self): + shutil.rmtree(os.path.join(self.checkout, "packages", "gnome-keyring")) + result, calls, _ = self._run("1:51.1-1") + self.assertEqual(self._makepkg_calls(calls), []) + self.assertIn("WARN:", result.stdout) + + def test_never_pins_or_holds_the_package(self): + # No IgnorePkg edit and no pacman -D hold: the next upstream fix must + # replace the rebuild on a normal upgrade. + _, calls, _ = self._run("1:51.1-1") + self.assertFalse(any("IgnorePkg" in c for c in calls)) + self.assertFalse(any(c.startswith("PACMAN: -D") for c in calls)) + + +if __name__ == "__main__": + unittest.main() diff --git a/working/keyring-crash/deploy-ratio.sh b/working/keyring-crash/deploy-ratio.sh new file mode 100644 index 0000000..f86e290 --- /dev/null +++ b/working/keyring-crash/deploy-ratio.sh @@ -0,0 +1,65 @@ +#!/bin/bash +# Apply the patched gnome-keyring package to a live host (ratio, later velox). +# +# Runs only after the VM gates passed and only when Craig says go. Every step +# is reversible: the encrypted keyring files are backed up with their modes, +# the stock package stays in the pacman cache for a one-command rollback, and +# only the keyring service is restarted (one unlock prompt is expected, to be +# answered in the normal system dialog, never in chat). +# +# deploy-ratio.sh build build the package from the repo recipe (as the user) +# deploy-ratio.sh install back up, install, restart the daemon, verify +# deploy-ratio.sh rollback reinstall the cached stock 1:51.1-1 and restart +# deploy-ratio.sh verify show versions, owner, restart counter, recent crashes +set -euo pipefail + +repo="${ARCHSETUP_DIR:-$HOME/code/archsetup}" +recipe="$repo/packages/gnome-keyring" +build_dir="${BUILD_DIR:-$HOME/.cache/archsetup-build/gnome-keyring}" +stock_pkg="/var/cache/pacman/pkg/gnome-keyring-1:51.1-1-x86_64.pkg.tar.zst" +backup_root="$HOME/.local/share/keyrings-backup" + +verify() { + echo "host: $(uname -n)" + pacman -Q gnome-keyring glib2 + systemctl --user show gnome-keyring-daemon.service -p ActiveState -p NRestarts -p MainPID -p ExecMainStartTimestamp + busctl --user status org.freedesktop.secrets 2>/dev/null | grep -E '^(PID|Comm)=' || echo "no Secret Service owner" + echo "aborts in the last hour: $(journalctl --user -u gnome-keyring-daemon.service --since '1 hour ago' --no-pager 2>/dev/null | grep -c 'Scheduled restart job')" + sha256sum /usr/bin/gnome-keyring-daemon +} + +case "${1:-}" in +build) + [ -f "$recipe/PKGBUILD" ] || { echo "recipe missing: $recipe" >&2; exit 1; } + rm -rf "$build_dir"; mkdir -p "$build_dir"; cp -a "$recipe/." "$build_dir/" + (cd "$build_dir" && makepkg --noconfirm -s) + ls -la "$build_dir"/*.pkg.tar.zst + ;; +install) + pkg=$(ls "$build_dir"/gnome-keyring-1:51.1-1.1-x86_64.pkg.tar.zst) + [ -f "$stock_pkg" ] || { echo "stock package not in cache for rollback: $stock_pkg" >&2; exit 1; } + # Permission-protected backup of the encrypted keyring files, modes kept. + stamp=$(date +%Y%m%d-%H%M%S) + mkdir -p "$backup_root"; chmod 700 "$backup_root" + cp -a "$HOME/.local/share/keyrings" "$backup_root/keyrings-$stamp" + chmod -R go-rwx "$backup_root" + echo "backup: $backup_root/keyrings-$stamp" + sudo pacman -U --noconfirm "$pkg" + # Only the keyring service restarts; the socket unit stays. One unlock + # prompt is expected afterwards. + systemctl --user restart gnome-keyring-daemon.service + sleep 2 + verify + ;; +rollback) + sudo pacman -U --noconfirm "$stock_pkg" + systemctl --user restart gnome-keyring-daemon.service + sleep 2 + verify + ;; +verify) + verify + ;; +*) + sed -n '2,13p' "$0"; exit 2 ;; +esac diff --git a/working/keyring-crash/gnome-keyring-51.1-secret-service-missing-client.patch b/working/keyring-crash/gnome-keyring-51.1-secret-service-missing-client.patch new file mode 100644 index 0000000..9ef298b --- /dev/null +++ b/working/keyring-crash/gnome-keyring-51.1-secret-service-missing-client.patch @@ -0,0 +1,275 @@ +diff -ruN a/daemon/dbus/gkd-secret-objects.c b/daemon/dbus/gkd-secret-objects.c +--- a/daemon/dbus/gkd-secret-objects.c 2026-09-17 15:41:00.000000000 -0500 ++++ b/daemon/dbus/gkd-secret-objects.c 2026-10-17 06:47:19.275009074 -0500 +@@ -430,7 +430,11 @@ + + /* The session we're using to access the object */ + session = gkd_secret_service_get_pkcs11_session (self->service, sender); +- g_return_val_if_fail (session, FALSE); ++ if (session == NULL) { ++ g_free (c_ident); ++ g_free (i_ident); ++ goto out; ++ } + + if (i_ident) { + gck_builder_add_ulong (&builder, CKA_CLASS, CKO_SECRET_KEY); +@@ -1055,17 +1059,23 @@ + if (!gkd_secret_util_parse_path (path, &identifier, NULL)) + return NULL; + +- if (g_str_has_prefix (path, SECRET_ALIAS_PREFIX)) +- real_identifier = gkd_secret_service_get_alias (self->service, identifier); +- else +- real_identifier = identifier; +- +- /* The session we're using to access the object */ ++ /* The session we're using to access the object. Resolve it before the ++ * alias: fetching a caller's session may register the caller, which ++ * refreshes the default alias and frees the string the alias table ++ * held, so an alias looked up earlier would dangle here. */ + if (caller == NULL) + session = gkd_secret_service_internal_pkcs11_session (self->service); + else + session = gkd_secret_service_get_pkcs11_session (self->service, caller); +- g_return_val_if_fail (session, NULL); ++ if (session == NULL) { ++ g_free (identifier); ++ return NULL; ++ } ++ ++ if (g_str_has_prefix (path, SECRET_ALIAS_PREFIX)) ++ real_identifier = gkd_secret_service_get_alias (self->service, identifier); ++ else ++ real_identifier = identifier; + + gck_builder_add_ulong (&builder, CKA_CLASS, CKO_G_COLLECTION); + gck_builder_add_string (&builder, CKA_ID, real_identifier); +diff -ruN a/daemon/dbus/gkd-secret-service.c b/daemon/dbus/gkd-secret-service.c +--- a/daemon/dbus/gkd-secret-service.c 2026-09-17 15:41:00.000000000 -0500 ++++ b/daemon/dbus/gkd-secret-service.c 2026-10-17 06:47:19.274715149 -0500 +@@ -137,6 +137,9 @@ + GHashTable *clients; + GkdSecretObjects *objects; + GHashTable *aliases; ++ /* The message filter reads aliases on the GDBus worker thread while ++ * the main thread updates them; every access goes through this lock. */ ++ GMutex aliases_lock; + GckSession *internal_session; + gchar *default_path; + }; +@@ -190,6 +193,18 @@ + return g_build_filename (alias_directory, "default", NULL); + } + ++/* An owned copy of an alias, safe to call from any thread. */ ++static gchar * ++lookup_alias_copy (GkdSecretService *self, const gchar *alias) ++{ ++ gchar *copy; ++ ++ g_mutex_lock (&self->aliases_lock); ++ copy = g_strdup (g_hash_table_lookup (self->aliases, alias)); ++ g_mutex_unlock (&self->aliases_lock); ++ return copy; ++} ++ + static void + update_default (GkdSecretService *self) + { +@@ -206,21 +221,31 @@ + /* Default to to 'login' if no default keyring */ + if (contents == NULL) + contents = g_strdup ("login"); +- g_hash_table_replace (self->aliases, g_strdup ("default"), contents); ++ ++ /* Replace only on a real change: callers on the main thread hold the ++ * previous value borrowed, and the common case (another client ++ * registering) must not free it out from under them. */ ++ g_mutex_lock (&self->aliases_lock); ++ if (g_strcmp0 (g_hash_table_lookup (self->aliases, "default"), contents) == 0) ++ g_free (contents); ++ else ++ g_hash_table_replace (self->aliases, g_strdup ("default"), contents); ++ g_mutex_unlock (&self->aliases_lock); + } + + static void + store_default (GkdSecretService *self) + { + GError *error = NULL; +- const gchar *identifier; ++ gchar *identifier; + +- identifier = g_hash_table_lookup (self->aliases, "default"); ++ identifier = lookup_alias_copy (self, "default"); + if (!identifier) + return; + + if (!g_file_set_contents (self->default_path, identifier, -1, &error)) + g_message ("couldn't store default keyring: %s", egg_error_message (error)); ++ g_free (identifier); + } + + static gboolean +@@ -331,7 +356,7 @@ + GDBusMessage *message) + { + const char *path = g_dbus_message_get_path (message); +- const char *replace; ++ char *replace; + char *collection = NULL, *item = NULL; + char *collection_path, *item_path; + GDBusMessage *rewritten; +@@ -346,7 +371,9 @@ + if (!gkd_secret_util_parse_path (path, &collection, &item)) + return message; + +- replace = gkd_secret_service_get_alias (self, collection); ++ /* This runs on the GDBus worker thread: take a copy under the lock ++ * rather than borrowing from a table the main thread may be updating. */ ++ replace = lookup_alias_copy (self, collection); + if (!replace) { + g_free (item); + g_free (collection); +@@ -356,6 +383,9 @@ + rewritten = g_dbus_message_copy (message, &error); + if (error != NULL) { + g_error_free (error); ++ g_free (replace); ++ g_free (item); ++ g_free (collection); + return message; + } + +@@ -372,6 +402,7 @@ + } + + g_free (collection_path); ++ g_free (replace); + g_free (item); + g_free (collection); + g_object_unref (message); +@@ -424,18 +455,30 @@ + GError *error = NULL; + const gchar *caller; + GVariant *input_payload; ++ gboolean opened; + + caller = g_dbus_method_invocation_get_sender (invocation); + + /* Now we can create a session with this information */ + session = gkd_secret_session_new (self, caller); + input_payload = g_variant_get_variant (input); +- gkd_secret_session_handle_open (session, algorithm, input_payload, +- &output, &result, +- &error); ++ opened = gkd_secret_session_handle_open (session, algorithm, input_payload, ++ &output, &result, ++ &error); + g_variant_unref (input_payload); + +- if (error != NULL) { ++ /* Branch on the return value, not on the error alone: a negotiate helper ++ * that bails out through g_return_val_if_fail() reports failure without ++ * ever setting one, and completing the call with a NULL body makes ++ * g_variant_new() abort the whole daemon. */ ++ if (!opened || output == NULL || result == NULL) { ++ if (error == NULL) ++ g_set_error_literal (&error, G_DBUS_ERROR, ++ G_DBUS_ERROR_FAILED, ++ "Couldn't open a session for this caller."); ++ if (output != NULL) ++ g_variant_unref (g_variant_ref_sink (output)); ++ g_free (result); + g_dbus_method_invocation_take_error (invocation, error); + } else { + gkd_secret_service_publish_dispatch (self, caller, +@@ -986,6 +1029,7 @@ + static void + gkd_secret_service_init_aliases (GkdSecretService *self) + { ++ g_mutex_init (&self->aliases_lock); + self->aliases = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, g_free); + g_hash_table_insert (self->aliases, g_strdup ("session"), g_strdup ("session")); + /* TODO: We should be using CKA_G_LOGIN_COLLECTION */ +@@ -1151,6 +1195,7 @@ + + g_hash_table_destroy (self->aliases); + self->aliases = NULL; ++ g_mutex_clear (&self->aliases_lock); + + g_free (self->default_path); + self->default_path = NULL; +@@ -1283,7 +1328,18 @@ + g_return_val_if_fail (caller, NULL); + + client = g_hash_table_lookup (self->clients, caller); +- g_return_val_if_fail (client, NULL); ++ if (client == NULL) { ++ /* The record is normally created by a high priority idle queued from ++ * the message filter, but that idle is not ordered against the method ++ * call that queued it: a peer which connects and calls straight away ++ * can reach us first. Create it here instead of failing the call -- ++ * it is a hash table insert, not I/O. Callers must not hold borrowed ++ * alias strings across this call: registering a client refreshes the ++ * default alias. */ ++ gkd_secret_service_ensure_client (self, caller); ++ client = g_hash_table_lookup (self->clients, caller); ++ g_return_val_if_fail (client, NULL); ++ } + + /* Open a new session if necessary */ + if (!client->pkcs11_session) { +@@ -1399,7 +1455,9 @@ + g_return_if_fail (GKD_SECRET_IS_SERVICE (self)); + g_return_if_fail (alias); + ++ g_mutex_lock (&self->aliases_lock); + g_hash_table_replace (self->aliases, g_strdup (alias), g_strdup (identifier)); ++ g_mutex_unlock (&self->aliases_lock); + + if (g_str_equal (alias, "default")) + store_default (self); +@@ -1418,6 +1476,10 @@ + + /* Take ownership of the session */ + client = g_hash_table_lookup (self->clients, caller); ++ if (client == NULL) { ++ gkd_secret_service_ensure_client (self, caller); ++ client = g_hash_table_lookup (self->clients, caller); ++ } + g_return_if_fail (client); + path = gkd_secret_dispatch_get_object_path (object); + g_return_if_fail (!g_hash_table_lookup (client->dispatch, path)); +diff -ruN a/daemon/dbus/gkd-secret-session.c b/daemon/dbus/gkd-secret-session.c +--- a/daemon/dbus/gkd-secret-session.c 2026-09-17 15:41:00.000000000 -0500 ++++ b/daemon/dbus/gkd-secret-session.c 2026-10-17 06:47:19.270295281 -0500 +@@ -183,7 +183,12 @@ + gboolean ret; + + session = gkd_secret_service_get_pkcs11_session (self->service, self->caller); +- g_return_val_if_fail (session, FALSE); ++ if (session == NULL) { ++ g_set_error_literal (error_out, G_DBUS_ERROR, ++ G_DBUS_ERROR_FAILED, ++ "Couldn't open a session for this caller."); ++ return FALSE; ++ } + + if (!aes_create_dh_keys (session, "ietf-ike-grp-modp-1024", &pub, &priv)) { + g_set_error_literal (error_out, G_DBUS_ERROR, +@@ -250,7 +255,12 @@ + GckSession *session; + + session = gkd_secret_service_get_pkcs11_session (self->service, self->caller); +- g_return_val_if_fail (session, FALSE); ++ if (session == NULL) { ++ g_set_error_literal (error_out, G_DBUS_ERROR, ++ G_DBUS_ERROR_FAILED, ++ "Couldn't open a session for this caller."); ++ return FALSE; ++ } + + gck_builder_add_ulong (&builder, CKA_CLASS, CKO_SECRET_KEY); + gck_builder_add_ulong (&builder, CKA_KEY_TYPE, CKK_G_NULL); diff --git a/working/keyring-crash/probe.py b/working/keyring-crash/probe.py new file mode 100644 index 0000000..b18f7a7 --- /dev/null +++ b/working/keyring-crash/probe.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +"""Secret Service stress probe for the disposable keyring guest only. + +Every mode drives real GIO D-Bus clients against org.freedesktop.secrets and +reports what the daemon actually answered, not just that messages were sent. +The guard at the top refuses to run anywhere but the disposable guest. + +Modes: + plain OpenSession(plain) then Close, synchronous, one per client + dh OpenSession(dh-ietf1024-sha256-aes128-cbc-pkcs7) then Close + invalid OpenSession with an unsupported algorithm; expects NotSupported + disconnect fire --burst OpenSession calls and drop the connection, no reply read + pipeline fire --burst OpenSession calls, await and validate every reply, + then Close every session + alias a fresh client's first calls are Unlock, Lock and ReadAlias on the + default alias path (the lazy client registration happens inside + these calls; exercises the alias-string lifetime) + isolation client A opens a session; client B must not be able to Close it; + A then closes it and B's own session still works + +Each batch of --batch clients is followed by a daemon round trip (ReadAlias plus +the collection's Locked property), so the counters reflect requests the daemon +actually processed. Exit status is 1 on any error or on a change of bus owner. +""" +import argparse +import collections +import concurrent.futures +import json +import os +import socket +import sys +import time + +import gi + +gi.require_version("Gio", "2.0") +from gi.repository import Gio, GLib # noqa: E402 + +SERVICE = "org.freedesktop.secrets" +SERVICE_PATH = "/org/freedesktop/secrets" +SERVICE_IFACE = "org.freedesktop.Secret.Service" +SESSION_IFACE = "org.freedesktop.Secret.Session" +COLLECTION_IFACE = "org.freedesktop.Secret.Collection" +ALIAS_PATH = "/org/freedesktop/secrets/aliases/default" +LOGIN_PATH = "/org/freedesktop/secrets/collection/login" +DH_ALGORITHM = "dh-ietf1024-sha256-aes128-cbc-pkcs7" +TIMEOUT_MS = 10000 + + +def connect(): + conn = Gio.DBusConnection.new_for_address_sync( + os.environ["DBUS_SESSION_BUS_ADDRESS"], + Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT + | Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION, + None, + None, + ) + conn.set_exit_on_close(False) + return conn + + +def call(conn, path, iface, method, args, reply_type): + return conn.call_sync( + SERVICE, path, iface, method, args, + GLib.VariantType.new(reply_type) if reply_type else None, + Gio.DBusCallFlags.NONE, TIMEOUT_MS, None, + ) + + +def owner(conn): + return conn.call_sync( + "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", + "GetNameOwner", GLib.Variant("(s)", (SERVICE,)), + GLib.VariantType.new("(s)"), Gio.DBusCallFlags.NONE, 5000, None, + ).unpack()[0] + + +def barrier(conn): + """Await the daemon itself, not only the message bus, after each batch.""" + path = call(conn, SERVICE_PATH, SERVICE_IFACE, "ReadAlias", + GLib.Variant("(s)", ("default",)), "(o)").unpack()[0] + if path != LOGIN_PATH: + raise RuntimeError("Synthetic login collection disappeared") + locked = conn.call_sync( + SERVICE, path, "org.freedesktop.DBus.Properties", "Get", + GLib.Variant("(ss)", (COLLECTION_IFACE, "Locked")), + GLib.VariantType.new("(v)"), Gio.DBusCallFlags.NONE, TIMEOUT_MS, None, + ).unpack()[0] + if locked: + raise RuntimeError("Synthetic login collection became locked") + + +def open_session_body(mode): + algorithm = "plain" if mode in ("plain", "disconnect", "pipeline", "isolation") else DH_ALGORITHM + payload = GLib.Variant("s", "") if algorithm == "plain" else GLib.Variant("ay", [2]) + if mode == "invalid": + algorithm = "intentionally-unsupported" + return GLib.Variant("(sv)", (algorithm, payload)) + + +def check_open_reply(reply): + """An OpenSession reply must carry a session object path under the service.""" + _output, path = reply.unpack() + if not isinstance(path, str) or not path.startswith(SERVICE_PATH + "/session/"): + raise RuntimeError(f"OpenSession returned an invalid session path: {path!r}") + return path + + +def close_session(conn, path): + call(conn, path, SESSION_IFACE, "Close", None, None) + + +def run_sync_client(conn, mode): + try: + reply = call(conn, SERVICE_PATH, SERVICE_IFACE, "OpenSession", + open_session_body(mode), "(vo)") + except GLib.Error as exc: + if mode == "invalid" and Gio.DBusError.get_remote_error(exc) == "org.freedesktop.DBus.Error.NotSupported": + return "expected-not-supported" + raise + if mode == "invalid": + raise RuntimeError("Unsupported algorithm unexpectedly accepted") + close_session(conn, check_open_reply(reply)) + return "ok" + + +def run_disconnect_client(conn, burst): + for _ in range(burst): + message = Gio.DBusMessage.new_method_call(SERVICE, SERVICE_PATH, SERVICE_IFACE, "OpenSession") + message.set_body(open_session_body("disconnect")) + conn.send_message(message, Gio.DBusSendMessageFlags.NONE) + conn.flush_sync(None) + return "ok" + + +def run_pipeline_client(conn, burst): + """Queue --burst calls without waiting, then collect and validate every reply.""" + loop = GLib.MainLoop() + pending = {"left": burst, "paths": [], "errors": []} + + def done(source, result): + try: + pending["paths"].append(check_open_reply(source.call_finish(result))) + except Exception as exc: # noqa: BLE001 - recorded, then raised below + pending["errors"].append(str(exc)) + pending["left"] -= 1 + if pending["left"] == 0: + loop.quit() + + for _ in range(burst): + conn.call(SERVICE, SERVICE_PATH, SERVICE_IFACE, "OpenSession", + open_session_body("pipeline"), GLib.VariantType.new("(vo)"), + Gio.DBusCallFlags.NONE, TIMEOUT_MS, None, done) + GLib.timeout_add(TIMEOUT_MS + 2000, loop.quit) + loop.run() + if pending["left"]: + raise RuntimeError(f"{pending['left']} pipelined OpenSession calls never answered") + if pending["errors"]: + raise RuntimeError("pipelined OpenSession failed: " + pending["errors"][0]) + if len(set(pending["paths"])) != burst: + raise RuntimeError("pipelined sessions did not get distinct paths") + for path in pending["paths"]: + close_session(conn, path) + return "ok" + + +def run_alias_client(conn): + """First calls on a fresh connection all go through the alias path. + + Unlock on an already-unlocked collection is a no-op that still resolves + the alias inside the daemon (the alias-in-arguments path the message + filter does not rewrite). Lock is deliberately not used: it would lock the + fixture's login collection, and unlocking again needs a password prompt + the headless guest cannot show. + """ + unlocked, prompt = call(conn, SERVICE_PATH, SERVICE_IFACE, "Unlock", + GLib.Variant("(ao)", ([ALIAS_PATH],)), "(aoo)").unpack() + if unlocked != [ALIAS_PATH] or prompt != "/": + raise RuntimeError(f"Unlock via alias answered {unlocked!r}, prompt {prompt!r}") + path = call(conn, SERVICE_PATH, SERVICE_IFACE, "ReadAlias", + GLib.Variant("(s)", ("default",)), "(o)").unpack()[0] + if path != LOGIN_PATH: + raise RuntimeError(f"ReadAlias answered {path!r}") + # A property read addressed to the alias path: the filter rewrites the + # object path, then the property getter looks the collection up. + locked = conn.call_sync( + SERVICE, ALIAS_PATH, "org.freedesktop.DBus.Properties", "Get", + GLib.Variant("(ss)", (COLLECTION_IFACE, "Locked")), + GLib.VariantType.new("(v)"), Gio.DBusCallFlags.NONE, TIMEOUT_MS, None, + ).unpack()[0] + if locked: + raise RuntimeError("login collection reported locked through the alias path") + return "ok" + + +def run_isolation_client(conn_a): + conn_b = connect() + try: + path_a = check_open_reply(call(conn_a, SERVICE_PATH, SERVICE_IFACE, "OpenSession", + open_session_body("isolation"), "(vo)")) + path_b = check_open_reply(call(conn_b, SERVICE_PATH, SERVICE_IFACE, "OpenSession", + open_session_body("isolation"), "(vo)")) + if path_a == path_b: + raise RuntimeError("two clients received the same session path") + try: + close_session(conn_b, path_a) + except GLib.Error: + pass + else: + raise RuntimeError("client B was allowed to Close client A's session") + # A's session must still be alive after B's attempt: a second Close by A + # must succeed once (the session exists), and B's own session still closes. + close_session(conn_a, path_a) + close_session(conn_b, path_b) + return "ok" + finally: + conn_b.close_sync(None) + + +def run_client(mode, burst): + conn = connect() + try: + if mode in ("plain", "dh", "invalid"): + return run_sync_client(conn, mode) + if mode == "disconnect": + return run_disconnect_client(conn, burst) + if mode == "pipeline": + return run_pipeline_client(conn, burst) + if mode == "alias": + return run_alias_client(conn) + if mode == "isolation": + return run_isolation_client(conn) + raise ValueError(mode) + finally: + conn.close_sync(None) + + +def daemon_rss_kib(conn): + """RSS of the current bus owner, read via its PID; None when unavailable.""" + try: + pid = conn.call_sync( + "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", + "GetConnectionUnixProcessID", GLib.Variant("(s)", (SERVICE,)), + GLib.VariantType.new("(u)"), Gio.DBusCallFlags.NONE, 5000, None, + ).unpack()[0] + with open(f"/proc/{pid}/status") as status: + for line in status: + if line.startswith("VmRSS:"): + return int(line.split()[1]) + except (GLib.Error, OSError): + return None + return None + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("mode", choices=("plain", "dh", "invalid", "disconnect", "pipeline", "alias", "isolation")) + parser.add_argument("--clients", type=int, default=200) + parser.add_argument("--workers", type=int, default=16) + parser.add_argument("--burst", type=int, default=1) + parser.add_argument("--batch", type=int, default=100) + args = parser.parse_args() + if socket.gethostname() != "archsetup-test" or os.getuid() != 1000: + raise SystemExit("Refusing to run outside the disposable archsetup-test guest as UID 1000") + monitor = connect() + initial_owner = owner(monitor) + rss_before = daemon_rss_kib(monitor) + results = collections.Counter() + examples = [] + start = time.monotonic() + with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as pool: + for offset in range(0, args.clients, args.batch): + futures = [pool.submit(run_client, args.mode, args.burst) + for _ in range(min(args.batch, args.clients - offset))] + for future in concurrent.futures.as_completed(futures): + try: + results[future.result()] += 1 + except Exception as exc: # noqa: BLE001 - counted and reported + results[type(exc).__name__] += 1 + if len(examples) < 5: + examples.append(str(exc)) + barrier(monitor) + try: + final_owner = owner(monitor) + except GLib.Error as exc: + final_owner = str(exc) + rss_after = daemon_rss_kib(monitor) + monitor.close_sync(None) + print(json.dumps({ + "mode": args.mode, "clients": args.clients, "workers": args.workers, + "burst": args.burst, "seconds": round(time.monotonic() - start, 2), + "initial_owner": initial_owner, "final_owner": final_owner, + "rss_kib_before": rss_before, "rss_kib_after": rss_after, + "results": dict(results), "errors": examples, + }), flush=True) + return 1 if examples or initial_owner != final_owner else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/working/keyring-crash/run-alias-only.sh b/working/keyring-crash/run-alias-only.sh new file mode 100755 index 0000000..7816066 --- /dev/null +++ b/working/keyring-crash/run-alias-only.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# The sshpass password below is the disposable test guest's throwaway root +# password (scripts/testing/archsetup-test.conf), reachable only on loopback. +# It is not a credential for anything real. +# Alias-path focus run: reset the fixture with the given daemon and drive only +# the alias mode hard (the lazy-registration race under Lock/Unlock via alias). +set -uo pipefail +label="$1"; daemon="$2"; asan="${3:-}"; clients="${4:-8000}" +here="$(cd "$(dirname "$0")" && pwd)" +ssho=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -o LogLevel=ERROR -o PreferredAuthentications=password -o PubkeyAuthentication=no) +g() { sshpass -p archsetup ssh "${ssho[@]}" -p 2237 root@127.0.0.1 "$@"; } +gcp() { sshpass -p archsetup scp "${ssho[@]}" -P 2237 "$@"; } +as_user='runuser -u keyring-test -- env XDG_RUNTIME_DIR=/run/user/1000 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus' +echo "=== $label alias-only daemon=$daemon asan=${asan:-none} $(date -Is)" +gcp "$here/probe.py" root@127.0.0.1:/opt/keyring-vm/probe.py >/dev/null +g "rm -f /tmp/asan.log*; KEYRING_DAEMON_BINARY='$daemon' ASAN_OPTIONS='$asan' /root/setup-guest.sh" >/dev/null || { echo "FIXTURE FAILED"; exit 1; } +since=$(g 'date "+%Y-%m-%d %H:%M:%S"') +g "$as_user python3 /opt/keyring-vm/probe.py alias --clients $clients --workers 32 --batch 100" 2>&1 | tail -2; rc=${PIPESTATUS[0]} +echo "alias rc=$rc" +secret=$(g "$as_user secret-tool lookup purpose keyring-vm" 2>/dev/null); echo "secret after alias: ${secret:-empty}" +echo "--- journal"; g "journalctl --since '$since' --no-pager -o short-iso 2>/dev/null | grep -E 'gnome-keyring' | grep -iE 'assert|abort|critical|sanitizer|dumped' | head -20" +[ -n "$asan" ] && { echo "--- asan"; g 'cat /tmp/asan.log* 2>/dev/null | head -60 || echo "no asan log"'; } +echo "=== $label alias-only result: $([ "$rc" = 0 ] && [ "$secret" = synthetic-vm-secret ] && echo PASS || echo FAIL)" diff --git a/working/keyring-crash/run-package-test.sh b/working/keyring-crash/run-package-test.sh new file mode 100755 index 0000000..06a661c --- /dev/null +++ b/working/keyring-crash/run-package-test.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# The sshpass password below is the disposable test guest's throwaway root +# password (scripts/testing/archsetup-test.conf), reachable only on loopback. +# It is not a credential for anything real. +# Build the repo's gnome-keyring package in the guest with makepkg, install it +# with pacman, and run the regression through the installed daemon. +set -uo pipefail +here="$(cd "$(dirname "$0")" && pwd)" +ssho=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -o LogLevel=ERROR -o PreferredAuthentications=password -o PubkeyAuthentication=no) +g() { sshpass -p archsetup ssh "${ssho[@]}" -p 2237 root@127.0.0.1 "$@"; } +gcp() { sshpass -p archsetup scp "${ssho[@]}" -P 2237 "$@"; } +echo "=== package build $(date -Is)" +g 'rm -rf /home/keyring-test/pkgbuild; mkdir -p /home/keyring-test/pkgbuild' +gcp -r "$here/../../packages/gnome-keyring/." root@127.0.0.1:/home/keyring-test/pkgbuild/ >/dev/null +# the tarball is already on the guest; makepkg verifies its checksum anyway +g 'cp /root/gnome-keyring-51.1.tar.xz /home/keyring-test/pkgbuild/; chown -R keyring-test: /home/keyring-test/pkgbuild +pacman -S --noconfirm --needed base-devel docbook-xsl git glib2-devel meson p11-kit python >/tmp/deps.log 2>&1 || { echo "DEPS FAILED"; tail -5 /tmp/deps.log; exit 1; } +cd /home/keyring-test/pkgbuild && runuser -u keyring-test -- makepkg --noconfirm --skippgpcheck >/tmp/makepkg.log 2>&1 || { echo "MAKEPKG FAILED"; tail -30 /tmp/makepkg.log; exit 1; } +ls -la /home/keyring-test/pkgbuild/*.pkg.tar.zst +grep -E "^(Ok|Fail|Timeout|Expected Fail|Unexpected Pass|Skipped):" /tmp/makepkg.log | tr "\n" " "; echo +pacman -U --noconfirm /home/keyring-test/pkgbuild/gnome-keyring-1:51.1-1.1-x86_64.pkg.tar.zst >/tmp/pacman-u.log 2>&1 || { echo "PACMAN -U FAILED"; tail -5 /tmp/pacman-u.log; exit 1; } +pacman -Q gnome-keyring; sha256sum /usr/bin/gnome-keyring-daemon' +echo "=== regression via installed daemon" +"$here/run-regression.sh" installed /usr/bin/gnome-keyring-daemon diff --git a/working/keyring-crash/run-regression.sh b/working/keyring-crash/run-regression.sh new file mode 100755 index 0000000..a853e40 --- /dev/null +++ b/working/keyring-crash/run-regression.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# The sshpass password below is the disposable test guest's throwaway root +# password (scripts/testing/archsetup-test.conf), reachable only on loopback. +# It is not a credential for anything real. +# Host-side regression driver for the disposable keyring guest. +# run-regression.sh <label> <daemon-binary-in-guest> [ASAN_OPTIONS] +# Resets the guest fixture with that daemon, runs every probe mode, verifies +# the synthetic secret after each mode, and prints a summary. Output goes to +# stdout; the caller redirects it to a log. Exit 1 on any failure. +set -uo pipefail +label="$1"; daemon="$2"; asan="${3:-}" +here="$(cd "$(dirname "$0")" && pwd)" +PORT="${PORT:-2237}" +ssho=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -o LogLevel=ERROR -o PreferredAuthentications=password -o PubkeyAuthentication=no) +g() { sshpass -p archsetup ssh "${ssho[@]}" -p "$PORT" root@127.0.0.1 "$@"; } +gcp() { sshpass -p archsetup scp "${ssho[@]}" -P "$PORT" "$@"; } +as_user='runuser -u keyring-test -- env XDG_RUNTIME_DIR=/run/user/1000 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus' +fail=0 +echo "=== $label: daemon=$daemon asan=${asan:-none} $(date -Is)" +gcp "$here/probe.py" root@127.0.0.1:/opt/keyring-vm/probe.py >/dev/null +gcp "$here/setup-guest.sh" root@127.0.0.1:/root/setup-guest.sh >/dev/null +g "chmod +x /root/setup-guest.sh; KEYRING_DAEMON_BINARY='$daemon' ASAN_OPTIONS='$asan' /root/setup-guest.sh" || { echo "FIXTURE FAILED"; exit 1; } +since=$(g 'date "+%Y-%m-%d %H:%M:%S"') +owner0=$(g "$as_user busctl --user status org.freedesktop.secrets 2>/dev/null | grep -E '^(PID|Comm)='" | tr '\n' ' ') +echo "owner before: $owner0" +run_mode() { # mode clients workers burst + local out + out=$(g "$as_user python3 /opt/keyring-vm/probe.py $1 --clients $2 --workers $3 --burst $4 --batch 100" 2>&1) + local rc=$? + echo "$out" | tail -3 + [ $rc -eq 0 ] || { echo "MODE $1 FAILED (rc=$rc)"; fail=1; } + local secret + secret=$(g "$as_user secret-tool lookup purpose keyring-vm" 2>/dev/null) + if [ "$secret" = "synthetic-vm-secret" ]; then echo "secret after $1: ok"; else echo "secret after $1: FAILED (${secret:-empty})"; fail=1; fi +} +run_mode plain 4000 32 8 +run_mode dh 4000 32 8 +run_mode invalid 4000 32 8 +run_mode disconnect 4000 32 8 +run_mode pipeline 4000 32 8 +run_mode alias 2000 32 1 +run_mode isolation 2000 32 1 +owner1=$(g "$as_user busctl --user status org.freedesktop.secrets 2>/dev/null | grep -E '^(PID|Comm)='" | tr '\n' ' ') +echo "owner after: $owner1" +[ "$owner0" = "$owner1" ] || { echo "OWNER CHANGED"; fail=1; } +echo "--- daemon journal since $since (assertions/aborts/criticals)" +g "journalctl --since '$since' --no-pager -o short-iso 2>/dev/null | grep -E 'gnome-keyring|secrets|asan|AddressSanitizer' | grep -iE 'assert|abort|SIGABRT|critical|sanitizer|ERROR|dumped' | head -40" +if [ -n "$asan" ]; then echo "--- asan logs"; g 'ls /tmp/asan.log* 2>/dev/null && cat /tmp/asan.log* | head -80 || echo "no asan log files"'; fi +echo "=== $label result: $([ $fail -eq 0 ] && echo PASS || echo FAIL) $(date -Is)" +exit $fail diff --git a/working/keyring-crash/run-upstream-tests.sh b/working/keyring-crash/run-upstream-tests.sh new file mode 100755 index 0000000..055dc7b --- /dev/null +++ b/working/keyring-crash/run-upstream-tests.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# The sshpass password below is the disposable test guest's throwaway root +# password (scripts/testing/archsetup-test.conf), reachable only on loopback. +# It is not a credential for anything real. +# Upstream meson test suites, stock vs patched, identical options, each in its +# own private session bus. Prints the summary lines and the failing test names. +set -uo pipefail +ssho=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -o LogLevel=ERROR -o PreferredAuthentications=password -o PubkeyAuthentication=no) +g() { sshpass -p archsetup ssh "${ssho[@]}" -p 2237 root@127.0.0.1 "$@"; } +g 'set -e; cd /root; if [ ! -d build-stock-cmp ]; then rm -rf src-stock-cmp; mkdir src-stock-cmp; tar -xJf gnome-keyring-51.1.tar.xz -C src-stock-cmp; meson setup build-stock-cmp src-stock-cmp/gnome-keyring-51.1 -Dbuildtype=debugoptimized -Dprefix=/usr -Dlibdir=lib -Ddebug-mode=false -Dmanpage=false >/tmp/stock-cmp-setup.log 2>&1; ninja -C build-stock-cmp >/tmp/stock-cmp-build.log 2>&1; fi +set +e; for b in build-stock-cmp build-v6; do echo "=== $b"; dbus-run-session -- meson test -C $b --no-rebuild --print-errorlogs >/tmp/mtest-$b.log 2>&1; grep -E "^(Ok|Fail|Timeout|Expected Fail|Unexpected Pass|Skipped):" /tmp/mtest-$b.log | tr "\n" " "; echo; grep -E "(FAIL|TIMEOUT) " /tmp/mtest-$b.log | awk "{print \$2, \$3, \$4, \$5}" | sort -u; done' diff --git a/working/keyring-crash/setup-guest.sh b/working/keyring-crash/setup-guest.sh new file mode 100644 index 0000000..0da7c69 --- /dev/null +++ b/working/keyring-crash/setup-guest.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# Exploration fixture: run as root only in the disposable VM. +set -euo pipefail +[[ $(uname -n) == archsetup-test && $EUID == 0 ]] || exit 2 +if ! id keyring-test >/dev/null 2>&1; then + useradd -m -u 1000 -s /bin/bash keyring-test + printf '%s\n' 'keyring-test:synthetic-vm-password' | chpasswd +fi +loginctl enable-linger keyring-test +systemctl start user@1000.service +runuser -u keyring-test -- env \ + XDG_RUNTIME_DIR=/run/user/1000 \ + DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus \ + systemctl --user stop gnome-keyring-daemon.service gnome-keyring-daemon.socket +pkill -KILL -u 1000 -x gnome-keyring-d || true +runuser -u keyring-test -- env \ + XDG_RUNTIME_DIR=/run/user/1000 \ + DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus \ + systemctl --user enable gnome-keyring-daemon.socket +printf '%s\n' 'synthetic-vm-password' | runuser -u keyring-test -- env \ + XDG_RUNTIME_DIR=/run/user/1000 \ + DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus \ + LD_LIBRARY_PATH="${KEYRING_DAEMON_LIBRARY_PATH:-}" \ + ASAN_OPTIONS="${ASAN_OPTIONS:-}" \ + "${KEYRING_DAEMON_BINARY:-gnome-keyring-daemon}" --unlock +printf '%s\n' 'synthetic-vm-secret' | runuser -u keyring-test -- env \ + XDG_RUNTIME_DIR=/run/user/1000 \ + DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus \ + secret-tool store --label='Disposable VM fixture' purpose keyring-vm +observed=$(runuser -u keyring-test -- env \ + XDG_RUNTIME_DIR=/run/user/1000 \ + DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus \ + secret-tool lookup purpose keyring-vm) +[[ $observed == synthetic-vm-secret ]] +echo 'Synthetic encrypted-secret round trip passed.' +pacman -Q gnome-keyring glib2 libsecret dbus dbus-broker dbus-broker-units systemd +runuser -u keyring-test -- env \ + XDG_RUNTIME_DIR=/run/user/1000 \ + DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus \ + systemctl --user show gnome-keyring-daemon.service -p MainPID -p NRestarts diff --git a/working/keyring-crash/status.org b/working/keyring-crash/status.org new file mode 100644 index 0000000..aa0a952 --- /dev/null +++ b/working/keyring-crash/status.org @@ -0,0 +1,118 @@ +#+TITLE: Keyring crash: cause, fix, validation, deployment +#+AUTHOR: Craig Jennings +#+DATE: 2026-10-17 +#+STARTUP: showall + +Supersedes the status in the main checkout's status-and-plan.org from 10-16. +Everything below was done in a disposable VM and this worktree; the host +keyring, its packages and services were untouched until the deployment step. + +* Cause + +gnome-keyring aborts when a Secret Service method call is dispatched before +the daemon has created the caller's per-client record. The record is created +by a G_PRIORITY_HIGH idle queued from a GDBus message filter on the worker +thread; that idle is not reliably ordered against the method dispatch, so +under concurrent bursts a call occasionally arrives first, get_pkcs11_session +asserts, the negotiate helper returns FALSE without a GError, OpenSession +completes with a NULL body, and g_variant_new aborts the daemon. systemd +restarts it, the unlocked state is gone, and the next request shows the +keyring password dialog. + +This is upstream bug 190 (also 194, 195, 196, 199), present in 48 through +51.1, unmerged fix in merge request !112 since 2026-08-25. On ratio the first +abort was 2026-10-11 19:03:59, the evening the ChatGPT desktop app was +installed; openai/codex issue 51694 documents its bundled app-server opening +a fresh Secret Service connection per keyring read in bursts and crashing +gnome-keyring with exactly this signature. The same-day glib and keyring +upgrades were a coincidence: the VM reproduced the crash on 50.0 as well. + +A second, pre-existing defect surfaced while testing the fix: the filter's +alias rewrite reads the alias table on the worker thread while the main +thread replaces and frees its entries on every client registration. +AddressSanitizer confirmed it as a heap-use-after-free read in +gkd_secret_util_build_path from rewrite_default_alias, freed by +g_hash_table_replace in update_default. Stock and !112 both carry it. + +* Fix (packages/gnome-keyring, patch 0002) + +- Register the client at the point of use in get_pkcs11_session and + publish_dispatch (as !112). +- aes_negotiate and plain_negotiate return G_DBUS_ERROR_FAILED instead of + asserting; OpenSession branches on the return value and never completes + with a NULL body (as !112). +- lookup_collection takes the caller's PKCS#11 session before resolving an + alias, so the lazy registration cannot free a borrowed alias string (the + earlier reviewer's use-after-free finding). +- The property-path lookup returns NO_SUCH_OBJECT instead of asserting when + there is no session (upstream 195). +- A mutex guards the alias table; the worker-thread filter and store_default + take owned copies under it; the default alias is replaced only when its + value changes so main-thread borrowers stay valid. + +Packaged as Arch's own recipe for 1:51.1-1 (Cinnamon patch kept, arch-meson, +selinux disabled) built from GNOME's release tarball (published sha256 +dfe7adc5876ef0670e7eba2907799bdbb8875ce58bb4208e1a52169bd16a47ca) plus the +patch, at pkgrel 1.1: above stock, below any future 1:51.1-2 or 51.2, so a +normal upgrade replaces it when a real fix lands. Nothing pins or holds it. + +* Validation + +Probe (working/keyring-crash/probe.py): real GIO clients, seven modes, every +reply validated, a daemon round trip after each batch of 100, the synthetic +secret read back after each mode, bus owner and RSS recorded. Counts: 4000 +clients at 32 workers with bursts of 8 for plain, dh, invalid, disconnect and +pipeline; 2000 for alias and isolation; alias also at 8000. + +| Daemon | Result | +|-----------------------------+-----------------------------------------------------| +| stock 51.1 | aborts on the first plain batch, collection locked, | +| | ratio's exact journal signature (three runs) | +|-----------------------------+-----------------------------------------------------| +| v4 (lazy registration only) | passes all modes; alias at 8000 returns 9 bad paths | +| | and the filter asserts on a malformed object path | +|-----------------------------+-----------------------------------------------------| +| v4 under ASan | heap-use-after-free in rewrite_default_alias | +|-----------------------------+-----------------------------------------------------| +| v5 and v6, normal and ASan | all modes pass, alias at 8000 passes, no sanitizer | +| | report, stable owner, RSS flat, secret intact | +|-----------------------------+-----------------------------------------------------| +| v6 package via pacman -U | all modes pass through the installed /usr/bin daemon| +|-----------------------------+-----------------------------------------------------| + +Independent review of v5 (patch, pristine and patched trees, prior reviewer's +requirements, no rationale): approve, no memory-safety defect after checking +every caller. Two minor notes taken in v6 (store_default under the lock, plain +NULL return); two declined as pre-existing and bounded (a late dispatch after +NameOwnerChanged leaks one PKCS#11 session per vanished peer, unique names +never recur; the remove_filter shutdown race). + +Upstream meson suites, identical options and a private bus, run back to back: +stock and v6 both 56 ok, 1 fail, 5 timeouts, the same names in both +(gnome2-store import fails intermittently; stock scored 57 ok and 0 fail on +its first run; the five dbus fixture tests time out identically). No +regression attributable to the patch. Arch's own recipe ignores this suite's +failures, which is why it is a comparison here and not a gate. + +Installer: install_gnome_keyring_fix runs after pacman_install gnome-keyring, +builds and installs the recipe with makepkg as the user when stock 51.1 (or +older) is installed, is a no-op on the rebuild, and skips with a visible +warning when something newer is installed. Eleven tests in +tests/installer-steps/test_install_gnome_keyring_fix.py; the fast suite (619 +Python plus 17 ERT tests) passes. The false autologin comment on the +keyring-dir block is corrected. + +* Deployment + +working/keyring-crash/deploy-ratio.sh: build (done on ratio 06:53, package at +~/.cache/archsetup-build/gnome-keyring/), install (permission-protected copy +of ~/.local/share/keyrings, pacman -U, restart only +gnome-keyring-daemon.service, verify), rollback (pacman -U of the cached stock +1:51.1-1, restart), verify. One unlock prompt is expected after the restart; +answer it in the system dialog. The same script applies to velox once it is +online, after confirming its versions match. + +Observation period: the stock daemon aborted about three times a day on +ratio. A restart counter that stays flat for several days of normal ChatGPT +and Claude desktop use is the evidence of lasting stability; a short check +after install is not. diff --git a/working/keyring-crash/vm-run.sh b/working/keyring-crash/vm-run.sh new file mode 100755 index 0000000..482ab3d --- /dev/null +++ b/working/keyring-crash/vm-run.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# Boot the retained disposable keyring guest headlessly. SSH is forwarded on +# loopback only. Runtime files (pid, monitor socket, serial log) live under +# the caller's RUN dir; the overlay and firmware file are the retained ones. +set -euo pipefail +OVERLAY="${OVERLAY:?path to guest.qcow2}" +VARS="${VARS:?path to OVMF_VARS.fd}" +RUN="${RUN:?runtime dir}" +PORT="${PORT:-2237}" +mkdir -p "$RUN" +exec qemu-system-x86_64 -name keyring-vm -machine q35,accel=kvm -cpu host \ + -smp 2 -m 4096 \ + -drive if=pflash,format=raw,readonly=on,file=/usr/share/edk2/x64/OVMF_CODE.4m.fd \ + -drive "if=pflash,format=raw,file=$VARS" \ + -drive "file=$OVERLAY,format=qcow2,if=virtio" \ + -netdev "user,id=net0,hostfwd=tcp:127.0.0.1:${PORT}-:22" -device virtio-net-pci,netdev=net0 \ + -monitor "unix:$RUN/monitor.sock,server,nowait" -pidfile "$RUN/qemu.pid" \ + -serial "file:$RUN/serial.log" -display none -boot c |
