From ec2da0b575975d00ae7caf375a727df79470393e Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 20 Sep 2026 18:29:49 +0200 Subject: [PATCH] helium: unlock the keyring through secretstorage; anchor the running-browser check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `secret-tool lookup` is non-interactive — on a locked keyring it returns nothing and shows no dialog — and a busctl Unlock+Prompt pair cannot work either, because Secret Service prompt objects die with the D-Bus connection that created them. secretstorage keeps one connection: get_default_collection().unlock() pops the gcr dialog and blocks until it is answered. Verified on the real profile: 5 v11 rows -> v10, then with the keyring re-locked Helium (through the wrapper, so the flags file and LeechBlock apply) lists all 5 logins and keeps every row. `pgrep -f /opt/helium-browser-bin` also matched the invoking shell when its argv mentioned the path, so the guard refused on an idle machine; now anchored to `^/opt/helium-browser-bin/`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01M8YGXVZdNUG79q5DGsdMm3 --- .pre-commit-config.yaml | 1 + .../artifact-prune-mode-2026-08-23.json | 36 ------------------ ...yring-unlock-secretstorage-2026-09-20.json | 38 +++++++++++++++++++ .../installers/install_helium.sh | 2 +- .../migrate_helium_passwords_to_basic.py | 37 +++++++++++++----- .../installers/verify_helium.py | 2 +- meta/requirements.txt | 1 + 7 files changed, 69 insertions(+), 48 deletions(-) delete mode 100644 docs/superpowers/evidence/artifact-prune-mode-2026-08-23.json create mode 100644 docs/superpowers/evidence/helium-keyring-unlock-secretstorage-2026-09-20.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4c065c95..a5e77a07 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -279,6 +279,7 @@ repos: # Chromium os_crypt blobs (AES-CBC). - websockets - cryptography + - secretstorage # Test suites and conftest fixtures are linted separately; they use # patterns (protected-access, missing docstrings, fixture shadowing) # that don't belong in the source-code 10/10 gate. diff --git a/docs/superpowers/evidence/artifact-prune-mode-2026-08-23.json b/docs/superpowers/evidence/artifact-prune-mode-2026-08-23.json deleted file mode 100644 index d29087e2..00000000 --- a/docs/superpowers/evidence/artifact-prune-mode-2026-08-23.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "intent": "Give the artifact cap a one-command fix, so the gate can never block a commit with no way out except reconstructing today's selection logic by hand.", - "scope": [ - "meta/scripts/check_repo_size.sh - adds --prune", - "Non-goal: pruning anything now. The tree is at 21/21, well under the 60 cap." - ], - "changes": [ - "Added --prune: stages the removal of the oldest artifacts, keeping the newest KEEP_ARTIFACTS=20 per directory plus template.json, and leaves the commit to the operator.", - "Fixed a same-day ordering bug found by running it: the first version sorted on %cs (date only), so every artifact committed on the same day tied, sort fell back to comparing the path, and the prune deleted alphabetically. It removed cap-artifact-growth-2026-08-23.json - one of the newest files - instead of the oldest. Now sorts on %ct (unix seconds) with sort -rn." - ], - "verification": [ - { - "command": "./meta/scripts/check_repo_size.sh --prune (in a detached worktree)", - "result": "pass", - "evidence": "First run staged 2 deletions but they were WRONG: cap-artifact-growth-2026-08-23.json and flatten-linux-configuration-2026-08-23.json, both same-day files chosen alphabetically. This is what exposed the %cs tie." - }, - { - "command": "./meta/scripts/check_repo_size.sh --prune after the %ct fix (fresh worktree)", - "result": "pass", - "evidence": "Now stages quarantine-arch-sysctl-cases-2026-08-22.json and thorium-repairs-tests-2026-08-23.json - the genuinely oldest. Templates survive; contracts correctly reports 'nothing to prune' at 20 + template." - }, - { - "command": "./meta/scripts/check_repo_size.sh; pre-commit run --files meta/scripts/check_repo_size.sh", - "result": "pass", - "evidence": "'1050 source files (ceiling 1200)' EXIT=0; shfmt and shellcheck clean. Tested in a throwaway worktree so the real tree was never modified." - } - ], - "risks": [ - "--prune stages deletions rather than committing them, so an operator who runs it and forgets to review still has git diff --cached as the checkpoint.", - "KEEP_ARTIFACTS=20 is below MAX_ARTIFACTS_PER_DIR=60 on purpose; a prune therefore buys headroom instead of landing back on the limit." - ], - "rollback": [ - "git revert the commit. --prune is additive and off by default; the enforcing path is unchanged.", - "After rollback, confirm ./meta/scripts/check_repo_size.sh still exits 0 and prints the count." - ] -} diff --git a/docs/superpowers/evidence/helium-keyring-unlock-secretstorage-2026-09-20.json b/docs/superpowers/evidence/helium-keyring-unlock-secretstorage-2026-09-20.json new file mode 100644 index 00000000..aedc0c28 --- /dev/null +++ b/docs/superpowers/evidence/helium-keyring-unlock-secretstorage-2026-09-20.json @@ -0,0 +1,38 @@ +{ + "intent": "Make migrate_helium_passwords_to_basic.py actually work on the user's box: the first version handed back twice never showed the keyring dialog, and the running-Helium guard false-positived on an idle machine.", + "scope": [ + "linux_configuration/installers/migrate_helium_passwords_to_basic.py (unlock via secretstorage; anchored process check)", + "linux_configuration/installers/verify_helium.py (anchored process check)", + "linux_configuration/installers/install_helium.sh (+python-secretstorage dep)", + ".pre-commit-config.yaml pylint deps, meta/requirements.txt (+secretstorage 3.5.0)" + ], + "changes": [ + "`secret-tool lookup` is non-interactive: on a locked keyring it returns nothing and shows no dialog. A busctl Unlock+Prompt sequence also cannot work because Secret Service prompt objects die with the connection that created them. secretstorage keeps one D-Bus connection: get_default_collection().unlock() pops the gcr dialog and blocks until answered.", + "`pgrep -f /opt/helium-browser-bin` matched the invoking shell whose argv mentioned the path, so the guard reported Helium running on an idle machine; the pattern is now anchored to `^/opt/helium-browser-bin/`." + ], + "verification": [ + { + "command": "linux_configuration/installers/migrate_helium_passwords_to_basic.py (run by Claude, user answered the gcr dialog)", + "result": "pass", + "evidence": "keyring is locked — answer the unlock dialog on screen; backup: Default/Login Data.pre-basic-20260920-182556; converted 5 v11 -> v10 rows; 0 already v10; added --password-store=basic to ~/.config/helium-browser-flags.conf" + }, + { + "command": "keyring re-locked via Secret.Service.Lock; /usr/local/bin/helium-browser (wrapper) on the real profile under Xvfb; chrome://password-manager/passwords deep-text; chrome://version command line; /json/list", + "result": "pass", + "evidence": "all 5 sites listed (kuhy-cloud.duckdns.org, google.com, typeracer.com, leetcode.com, infakt.pl); command line has --password-store=basic and the LeechBlock --load-extension; uBO background_page present; keyring still locked afterwards; Login Data still 5 rows, all v10 (nothing deleted on read)" + }, + { + "command": "pre-commit run --files (in the commit worktree)", + "result": "pass", + "evidence": "ruff, ruff-format, pylint, shellcheck, codespell, file-length, dependency-freshness green" + } + ], + "risks": [ + "secretstorage's unlock() blocks with no timeout until the dialog is answered or dismissed; the script says so on stdout before it blocks.", + "The backup file Login Data.pre-basic- still holds the v11 (keyring-encrypted) blobs; it is useless without the keyring key and can be deleted once the user is happy." + ], + "rollback": [ + "Restore Default/'Login Data.pre-basic-20260920-182556' over 'Login Data' and remove --password-store=basic from ~/.config/helium-browser-flags.conf.", + "Validate: Helium prompts for the keyring again and the 5 logins decrypt after unlock." + ] +} diff --git a/linux_configuration/installers/install_helium.sh b/linux_configuration/installers/install_helium.sh index 15483be8..1310c014 100755 --- a/linux_configuration/installers/install_helium.sh +++ b/linux_configuration/installers/install_helium.sh @@ -53,7 +53,7 @@ usage() { # Everything the clone + verify steps fork: rsync, Xvfb, the CDP websocket # client; base-devel + git only matter on a box without yay (makepkg path). -readonly -a PACMAN_DEPS=(rsync xorg-server-xvfb python-websockets python-cryptography base-devel git) +readonly -a PACMAN_DEPS=(rsync xorg-server-xvfb python-websockets python-cryptography python-secretstorage base-devel git) validate_requirements() { command -v pacman >/dev/null 2>&1 || { diff --git a/linux_configuration/installers/migrate_helium_passwords_to_basic.py b/linux_configuration/installers/migrate_helium_passwords_to_basic.py index 16d8376f..1b2e91a7 100755 --- a/linux_configuration/installers/migrate_helium_passwords_to_basic.py +++ b/linux_configuration/installers/migrate_helium_passwords_to_basic.py @@ -11,8 +11,9 @@ Linux os_crypt scheme (unchanged since Chromium 4x): key = PBKDF2-HMAC-SHA1 (secret, "saltysalt", 1 iter, 16 B); AES-128-CBC, IV = 16 spaces, PKCS#7; ``v11`` uses the keyring secret, ``v10`` uses the fixed string "peanuts". -Needs the keyring unlocked ONCE — secret-tool pops the unlock dialog, so run -this yourself, with the browser closed. Never prints a secret. +Needs the keyring unlocked ONCE: it asks the Secret Service to unlock the +default collection, which pops the gcr dialog. Browser must be closed. +Never prints a secret. """ from __future__ import annotations @@ -28,6 +29,7 @@ import time from cryptography.hazmat.primitives import padding from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +import secretstorage DEFAULT_PROFILE = Path.home() / ".config" / "net.imput.helium" / "Default" KEYRING_SCHEMA = "chrome_libsecret_os_crypt_password_v2" @@ -37,7 +39,8 @@ KEY_LEN = 16 BASIC_SECRET = b"peanuts" V10 = b"v10" V11 = b"v11" -HELIUM_PROCESS_MARK = "/opt/helium-browser-bin" +# Anchored: a shell whose argv merely mentions the path must not match. +HELIUM_PROCESS_MARK = "^/opt/helium-browser-bin/" FLAGS_FILE = Path.home() / ".config" / "helium-browser-flags.conf" BASIC_FLAG = "--password-store=basic" @@ -69,13 +72,27 @@ def encrypt(key: bytes, plain: bytes) -> bytes: def keyring_secret(application: str) -> bytes: - """Fetch the browser's Safe Storage secret (this pops the unlock dialog).""" - for attrs in (["application", application], []): - cmd = ["/usr/bin/secret-tool", "lookup", "xdg:schema", KEYRING_SCHEMA, *attrs] - out = subprocess.run(cmd, capture_output=True, check=False).stdout - if out: - return out.rstrip(b"\n") - msg = f"no {KEYRING_SCHEMA} item in the keyring (locked? dialog cancelled?)" + """Unlock the default keyring (this pops the gcr dialog) and read the key. + + One D-Bus connection for the whole exchange: Secret Service prompt objects + live only as long as the connection that created them, which is why a + `secret-tool`/`busctl` sequence of separate processes can never work. + """ + conn = secretstorage.dbus_init() + collection = secretstorage.get_default_collection(conn) + if collection.is_locked(): + say("keyring is locked — answer the unlock dialog on screen") + if collection.unlock(): # True means the dialog was dismissed + msg = "unlock dialog dismissed; keyring still locked" + raise SystemExit(msg) + for attrs in ( + {"xdg:schema": KEYRING_SCHEMA, "application": application}, + {"xdg:schema": KEYRING_SCHEMA}, + ): + items = list(collection.search_items(attrs)) + if items: + return items[0].get_secret() + msg = f"no {KEYRING_SCHEMA} item in the default keyring" raise SystemExit(msg) diff --git a/linux_configuration/installers/verify_helium.py b/linux_configuration/installers/verify_helium.py index 051776ea..62e8371e 100755 --- a/linux_configuration/installers/verify_helium.py +++ b/linux_configuration/installers/verify_helium.py @@ -25,7 +25,7 @@ from typing import Self from websockets.sync.client import connect HELIUM_BIN = "/usr/bin/helium-browser" -HELIUM_BIN_DIR = "/opt/helium-browser-bin" +HELIUM_BIN_DIR = "^/opt/helium-browser-bin/" # anchored pgrep -f pattern DEFAULT_PROFILE = str(Path.home() / ".config" / "net.imput.helium") UBO_ID = "blockjmkbacgjkknlgpkjjiijinjdanf" POLICY_DIR = Path("/etc/chromium/policies/managed") diff --git a/meta/requirements.txt b/meta/requirements.txt index 9a005278..6d066b0a 100644 --- a/meta/requirements.txt +++ b/meta/requirements.txt @@ -81,6 +81,7 @@ ruff==0.16.8 # safety is deliberately not pinned: its only reference in this repo is a # commented-out pre-commit hook, and safety 3.8.1 declares `typer<0.26`, # which would hold the typer this repo actually imports below latest. +secretstorage==3.5.0 selenium==4.49.0 typer==0.27.2 types-python-dateutil==2.9.0.20260807