From cd6e11b46ecf2b56bf63d6c11886dccc70875e10 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sat, 17 Oct 2026 07:07:25 -0500 Subject: feat(keyring): ship a patched gnome-keyring and install it from the repo gnome-keyring 48 through 51.1 aborts when a client's first OpenSession is dispatched before the daemon has created its per-client record. Each abort drops the unlocked state and the next request pops the password dialog. On ratio that happened about three times a day from 2026-10-11, when the ChatGPT desktop app's codex app-server started bursting keyring reads. The same-day glib and keyring upgrades were a coincidence, since 50.0 reproduces it too. Upstream's fix (merge request !112) has sat unmerged since August. packages/gnome-keyring is Arch's recipe for 1:51.1-1 built from GNOME's tarball plus one patch. It registers the client at the point of use and returns a real D-Bus error instead of completing OpenSession with a NULL body. It resolves a collection alias only after the caller's session exists. It also locks the alias table so the worker-thread filter can't read an entry the main thread is freeing, a pre-existing defect that AddressSanitizer confirmed. I chose pkgrel 1.1 so stock sorts below the rebuild and any future Arch or upstream fix replaces it on a normal upgrade. Nothing pins the package. install_gnome_keyring_fix replaces the plain stock install, which pacman's --needed would otherwise use to downgrade the rebuild on every re-run. It installs stock only when no gnome-keyring is present, compares versions with vercmp, then stages the recipe into the source dir and builds it with makepkg as the user. The rebuild is a no-op, anything newer skips with a visible note, and a failed build leaves stock in place. I also corrected the keyring-dir comment: the default alias only picks the collection and doesn't unlock anything under autologin. working/keyring-crash holds the probe, the guest fixture and runners, and the deployment script with backup and rollback. status.org there records the cause and results. Stock aborts on the probe's first batch. The patched daemon, its sanitizer build and the installed package pass every mode, and the upstream suite scores the same for both. --- working/keyring-crash/deploy-ratio.sh | 65 +++++ ...eyring-51.1-secret-service-missing-client.patch | 275 +++++++++++++++++++ working/keyring-crash/probe.py | 300 +++++++++++++++++++++ working/keyring-crash/run-alias-only.sh | 23 ++ working/keyring-crash/run-package-test.sh | 24 ++ working/keyring-crash/run-regression.sh | 50 ++++ working/keyring-crash/run-upstream-tests.sh | 11 + working/keyring-crash/setup-guest.sh | 40 +++ working/keyring-crash/status.org | 118 ++++++++ working/keyring-crash/vm-run.sh | 18 ++ 10 files changed, 924 insertions(+) create mode 100644 working/keyring-crash/deploy-ratio.sh create mode 100644 working/keyring-crash/gnome-keyring-51.1-secret-service-missing-client.patch create mode 100644 working/keyring-crash/probe.py create mode 100755 working/keyring-crash/run-alias-only.sh create mode 100755 working/keyring-crash/run-package-test.sh create mode 100755 working/keyring-crash/run-regression.sh create mode 100755 working/keyring-crash/run-upstream-tests.sh create mode 100644 working/keyring-crash/setup-guest.sh create mode 100644 working/keyring-crash/status.org create mode 100755 working/keyring-crash/vm-run.sh (limited to 'working/keyring-crash') 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