testsAndMisc/linux_configuration/installers/migrate_helium_passwords_to_basic.py
Krzysztof kuhy Rudnicki c193408ccd helium: full uBlock Origin (MV2) via Helium, profile cloned, keyring prompt fix
Chromium 151 removed the last Manifest V2 code paths and the Web Store
purged uBlock Origin on 2026-08-31, so ungoogled-chromium 153 cannot load
the full blocker any more. Helium (GPL-3, ungoogled-chromium base) bundles
uBO 1.75.0 as a component extension and keeps MV2 alive.

- installers/install_helium.sh: pacman deps, AUR via yay or makepkg
  (+validpgpkeys import), /usr/local/bin/helium-browser ->
  browser-preexec-wrapper so LeechBlock NG and the hosts refresh still
  apply, --clone-profile with a downgrade check and running-browser
  refusal, then the verifier. Passed in vmbox from scratch, then host.
- installers/verify_helium.py: Xvfb + DevTools exit-code gate — uBO
  background_page + manifest_version 2, every /etc/chromium/policies key
  on chrome://policy (Helium reads that dir natively), adblock-tester >= 95.
- installers/migrate_helium_passwords_to_basic.py: lightdm autologin
  leaves gnome-keyring locked, so the 5 v11 logins make Helium prompt on
  every launch; this re-encrypts them as v10 and only then enables
  --password-store=basic.
- register helium-browser in aur_packages.txt and the periodic browser
  wrapper/service lists; pylint hook gets websockets + cryptography;
  codespell learns the vendor name; anthropic 1.6.0 -> 1.7.0 (gate red);
  oldest evidence artifact pruned to stay under the 60-file cap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8YGXVZdNUG79q5DGsdMm3
2026-09-20 18:07:34 +02:00

184 lines
6.5 KiB
Python
Executable File

#!/usr/bin/env python3
"""Re-encrypt keyring-bound (v11) Chromium passwords as basic-store (v10).
Why: lightdm autologin leaves gnome-keyring locked, so a Chromium-family
browser that finds its "Safe Storage" key in the keyring prompts to unlock it
on every launch. Running Helium with ``--password-store=basic`` stops that,
but ``v11`` rows (encrypted with the keyring key) become unreadable — and
Chromium deletes undecryptable logins on read. This converts them first.
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.
"""
from __future__ import annotations
import argparse
import hashlib
from pathlib import Path
import shutil
import sqlite3
import subprocess
import sys
import time
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
DEFAULT_PROFILE = Path.home() / ".config" / "net.imput.helium" / "Default"
KEYRING_SCHEMA = "chrome_libsecret_os_crypt_password_v2"
SALT = b"saltysalt"
IV = b" " * 16
KEY_LEN = 16
BASIC_SECRET = b"peanuts"
V10 = b"v10"
V11 = b"v11"
HELIUM_PROCESS_MARK = "/opt/helium-browser-bin"
FLAGS_FILE = Path.home() / ".config" / "helium-browser-flags.conf"
BASIC_FLAG = "--password-store=basic"
def say(msg: str, *, err: bool = False) -> None:
"""Write one line to stdout (or stderr); explicit so lint autofix cannot drop it."""
(sys.stderr if err else sys.stdout).write(msg + "\n")
def derive_key(secret: bytes) -> bytes:
"""Chromium's os_crypt Linux KDF."""
return hashlib.pbkdf2_hmac("sha1", secret, SALT, 1, KEY_LEN)
def decrypt(key: bytes, blob: bytes) -> bytes:
"""AES-128-CBC + PKCS#7 strip; ``blob`` is the payload after the 3-byte tag."""
dec = Cipher(algorithms.AES(key), modes.CBC(IV)).decryptor()
padded = dec.update(blob) + dec.finalize()
unpad = padding.PKCS7(algorithms.AES.block_size).unpadder()
return unpad.update(padded) + unpad.finalize()
def encrypt(key: bytes, plain: bytes) -> bytes:
"""Inverse of :func:`decrypt`, without the tag."""
pad = padding.PKCS7(algorithms.AES.block_size).padder()
padded = pad.update(plain) + pad.finalize()
enc = Cipher(algorithms.AES(key), modes.CBC(IV)).encryptor()
return enc.update(padded) + enc.finalize()
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?)"
raise SystemExit(msg)
def browser_running() -> bool:
"""SQLite writes under a live browser are lost or corrupt the DB."""
res = subprocess.run(
["/usr/bin/pgrep", "-f", HELIUM_PROCESS_MARK], capture_output=True, check=False
)
return res.returncode == 0
def migrate(
db: Path, ring_key: bytes, basic_key: bytes, *, dry_run: bool
) -> tuple[int, int]:
"""Rewrite every v11 row in ``logins`` as v10; return (converted, already_v10)."""
conn = sqlite3.connect(db)
try:
rows = conn.execute("SELECT id, password_value FROM logins").fetchall()
converted = already = 0
for row_id, blob in rows:
if blob[:3] == V10:
already += 1
continue
if blob[:3] != V11:
say(f" row {row_id}: unknown tag {blob[:3]!r}, left alone", err=True)
continue
plain = decrypt(ring_key, blob[3:])
new_blob = V10 + encrypt(basic_key, plain)
if decrypt(basic_key, new_blob[3:]) != plain:
msg = f"row {row_id}: round-trip mismatch, aborting before any write"
raise SystemExit(msg)
if not dry_run:
conn.execute(
"UPDATE logins SET password_value = ? WHERE id = ?",
(new_blob, row_id),
)
converted += 1
if not dry_run:
conn.commit()
finally:
conn.close()
return converted, already
def enable_basic_store() -> None:
"""Append the flag Helium's wrapper reads — only after the rows are converted.
Written here rather than by the installer so a restart can never happen
between "flag on" and "rows converted": with the flag on and v11 rows
still present, Chromium would treat them as undecryptable and delete them.
"""
existing = FLAGS_FILE.read_text() if FLAGS_FILE.exists() else ""
if BASIC_FLAG in existing.split():
return
with FLAGS_FILE.open("a") as fh:
fh.write(f"{BASIC_FLAG}\n")
say(f"added {BASIC_FLAG} to {FLAGS_FILE}")
def parse_args() -> argparse.Namespace:
"""CLI flags."""
parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0])
parser.add_argument(
"--profile",
type=Path,
default=DEFAULT_PROFILE,
help="profile dir holding 'Login Data'",
)
parser.add_argument(
"--application",
default="chromium",
help="keyring item's 'application' attribute",
)
parser.add_argument(
"--dry-run", action="store_true", help="decrypt + verify only, write nothing"
)
return parser.parse_args()
def main() -> int:
"""Back up, convert, report counts."""
args = parse_args()
db = args.profile / "Login Data"
if not db.is_file():
say(f"no such file: {db}", err=True)
return 2
if browser_running():
say("Helium is running — close it first, then rerun.", err=True)
return 3
ring_key = derive_key(keyring_secret(args.application))
basic_key = derive_key(BASIC_SECRET)
if not args.dry_run:
backup = db.with_name(f"Login Data.pre-basic-{time.strftime('%Y%m%d-%H%M%S')}")
shutil.copy2(db, backup)
say(f"backup: {backup}")
converted, already = migrate(db, ring_key, basic_key, dry_run=args.dry_run)
verb = "would convert" if args.dry_run else "converted"
say(f"{verb} {converted} v11 -> v10 rows; {already} already v10")
if not args.dry_run:
enable_basic_store()
return 0
if __name__ == "__main__":
sys.exit(main())