Sometimes people don’t want to hear the truth because they don’t want their illusions destroyed, Friedrich Nietzsche.

This is the next entry in my series of Rofi extensions. While the previous modules handled bookmarks and text snippets, this module focuses on security: managing your passwords using the Unix standard Pass.
Pass is a lightweight password manager that stores encrypted data in a git-friendly directory tree. This guide covers the complete setup: installing pass and gnupg, generating GPG keys, and —crucially for a Wayland workflow— a custom Python Rofi frontend (myrofi_pass.py). This custom script replaces the standard rofi-pass to ensure compatibility with Wayland compositors (like Niri) using wtype, and adds native support for OTP tokens.
The following bash script handles the heavy lifting of setting up your password store. It installs the necessary dependencies (pass, gnupg, wtype), ensures GPG uses a GUI pinentry dialog (vital for Wayland), and guides you through creating your key pair.
The Setup Script (20-pass.sh). This script performs the following key steps:
#!/usr/bin/env bash
# ── 20-pass.sh ────────────────────────────────────────────────────────────────
# Install and configure `pass` (Unix password manager) for Arch + Wayland/Niri.
#
# What `pass` is:
# pass stores passwords as GPG-encrypted text files in ~/.password-store/.
# Each file can hold a password plus extra fields (username, URL, notes).
# The store is a plain directory tree you can back up, version-control
# with git, and sync across machines with rsync or a private git repo.
#
# Security model:
# Passwords are encrypted with YOUR GPG key. Without your private key
# (and its passphrase) the files are unreadable. Keep a backup of your
# GPG key in a safe place (e.g. KeePassXC, a USB drive).
#
# FEATURES:
# • Installs pass + dependencies
# • Configures GPG for Wayland (pinentry-qt)
# • Generates or reuses GPG keys
# • Initializes password store
# • Fixes strict GPG permissions
# • Ready for rofi-pass + wtype
#
# SAFE TO RE-RUN: Yes (idempotent where possible)
#
# RUN:
# bash 20-pass.sh
# ─────────────────────────────────────────────────────────────────────────────
set -euo pipefail
# ── Colors / UI ──────────────────────────────────────────────────────────────
GRN=$(tput setaf 2 2>/dev/null || true)
YLW=$(tput setaf 3 2>/dev/null || true)
BLD=$(tput bold 2>/dev/null || true)
RST=$(tput sgr0 2>/dev/null || true)
ok() { echo " ${GRN}✅${RST} $*"; }
info() { echo " ℹ️ $*"; }
warn() { echo " ${YLW}⚠️${RST} $*"; }
hr() { echo; echo "${BLD}── $* ──────────────────────────────────────${RST}"; echo; }
# ── 1. Install packages ───────────────────────────────────────────────────────
hr "STEP 1 — Install packages"
# Core tools:
# pass — password manager
# gnupg — encryption backend
# wtype — Wayland typing (replaces xdotool)
# xdotool — fallback for XWayland
# pinentry-qt — GUI passphrase prompt
sudo pacman -S --needed --noconfirm \
pass \
gnupg \
wtype \
xdotool \
pinentry-qt
# rofi-pass — rofi frontend for pass (AUR)
# Uses wtype on Wayland/Niri instead of xdotool
# rofi-pass (AUR)
if command -v paru >/dev/null 2>&1; then
paru -S --needed --noconfirm rofi-pass
else
warn "paru not found — skipping rofi-pass (install manually if needed)"
fi
ok "Dependencies installed"
# ── 2. Configure GPG (Wayland-safe) ────────────────────────────────────
hr "STEP 2 — Configure GPG pinentry"
sudo pacman -S --needed --noconfirm pinentry
mkdir -p ~/.gnupg
chmod 700 "$HOME/.gnupg"
# Write gpg-agent.conf — only if the pinentry-program line is not already there
AGENT_CONF="$HOME/.gnupg/gpg-agent.conf"
if ! grep -q "pinentry-program" "$AGENT_CONF" 2>/dev/null; then
cat >> "$AGENT_CONF" << 'AGENTCONF'
# Use Qt pinentry for passphrase prompts in Wayland/Niri sessions.
# Without this, rofi-pass hangs waiting for a TTY that doesn't exist.
pinentry-program /usr/bin/pinentry-qt
# Cache the passphrase for 1 hour (3600 seconds) so you don't re-enter
# your GPG passphrase for every password lookup within that window.
default-cache-ttl 3600 # 21600 (6 hours for convenience's sake).
max-cache-ttl 7200 # 43200
AGENTCONF
ok "~/.gnupg/gpg-agent.conf written (pinentry-qt + 1h cache)"
else
ok "gpg-agent.conf already has pinentry-program — leaving unchanged"
fi
# Environment fixes (important for Wayland sessions)
export GPG_TTY=$(tty || true)
# Restart gpg-agent so the new config takes effect
gpgconf --kill gpg-agent 2>/dev/null || true
ok "gpg-agent restarted"
# ── 3. GPG Key Setup ─────────────────────────────────────────────────────
hr "STEP 3 — GPG key pair"
info "Checking for existing GPG keys..."
EXISTING_KEYS=$(gpg --list-secret-keys --keyid-format LONG 2>/dev/null | grep "sec" || true)
if [[ -n "$EXISTING_KEYS" ]]; then
warn "You already have GPG secret key(s):"
gpg --list-secret-keys --keyid-format LONG
echo
info "If you want to use an existing key, copy its KEY_ID from the line starting with 'sec'."
info "Example: sec rsa4096/ABCDEF1234567890 → KEY_ID is ABCDEF1234567890"
info "Then skip ahead to Step 4 (pass init)."
echo
read -rp " Press Enter to continue to pass init, or Ctrl+C to exit and create a key manually: "
else
cat << 'KEYGEN_GUIDE'
╔══════════════════════════════════════════════════════════════════════╗
║ CREATING YOUR GPG KEY PAIR ║
╠══════════════════════════════════════════════════════════════════════╣
║ A GPG key pair has two parts: ║
║ PUBLIC KEY — used to ENCRYPT files (can be shared) ║
║ PRIVATE KEY — used to DECRYPT files (NEVER share this) ║
║ ║
║ When prompted, choose: ║
║ Kind of key: (1) RSA and RSA ← default, just press Enter ║
║ Key size: 4096 ← type 4096 for maximum ║
║ Validity: 0 ← does not expire ║
║ Real name: nmaximo7 ← or your full name ║
║ Email: nmaximo7@gmail.com ║
║ Comment: (leave blank) ← press Enter ║
║ Passphrase: USE A STRONG ONE ← see note below ║
║ ║
║ PASSPHRASE ADVICE: ║
║ ✅ Use 4-6 random words (diceware): correct-horse-battery-staple ║
║ ✅ Minimum 20 characters ║
║ ✅ Write it down and store in KeePassXC immediately ║
║ ❌ Do NOT use your system login password ║
║ ❌ Do NOT use a short or simple password ║
║ ║
║ Your passwords are only as secure as this passphrase. ║
╚══════════════════════════════════════════════════════════════════════╝
KEYGEN_GUIDE
read -rp " Ready to generate your GPG key? Press Enter to start (or Ctrl+C to abort): "
# Generate a GPG key pair.
gpg --full-generate-key
ok "GPG key generated"
fi
# ── 4. Show the key ID ────────────────────────────────────────────────────────
hr "STEP 4 — Your GPG key ID"
echo " Your GPG secret keys:"
echo
gpg --list-secret-keys --keyid-format LONG
echo
cat << 'KEYID_GUIDE'
From the output above, find the line that looks like:
sec rsa4096/ABCDEF1234567890 2024-01-01 [SC]
The KEY_ID is the part after the slash, namely ABCDEF1234567890
You can also use your email address instead of the KEY_ID.
Both work with `pass init`.
KEYID_GUIDE
# ── 5. Initialize the password store ─────────────────────────────────────────
hr "STEP 5 — Initialize password store"
info "The password store will be created at ~/.password-store/"
echo
# Check if already initialized
if [[ -d "$HOME/.password-store" ]]; then
warn "~/.password-store already exists — skipping init"
info "If you want to re-initialize with a different key:"
info " pass init nmaximo7@gmail.com (or: pass init YOUR_KEY_ID)"
else
read -rp " Enter your GPG key ID or email [nmaximo7@gmail.com]: " KEY_INPUT
KEY_INPUT="${KEY_INPUT:-nmaximo7@gmail.com}"
pass init "$KEY_INPUT"
ok "Password store initialized at ~/.password-store/"
ok "Encrypted with GPG key: $KEY_INPUT"
fi
# ── 6. Fix permissions (CRITICAL) ─────────────────────────────────────────────
hr "STEP 6 — Fix GPG permissions"
chmod 700 ~/.gnupg
find ~/.gnupg -type f -exec chmod 600 {} \;
find ~/.gnupg -type d -exec chmod 700 {} \;
ok "~/.gnupg and all contents: directories=700, files=600"
# Restart gpg-agent so it re-reads with correct permissions
gpgconf --kill gpg-agent 2>/dev/null || true
ok "Permissions fixed + agent restarted"
# ── 7. Quick-start guide ──────────────────────────────────────────────────────
hr "STEP 6 — How to use pass"
cat << 'USAGE_GUIDE'
┌─────────────────────────────────────────────────────────────────────┐
│ PASS QUICK REFERENCE │
├─────────────────────────────────────────────────────────────────────┤
│ ADD a password (interactive prompt): │
│ pass insert amazon.com │
│ pass insert email/gmail.com │
│ pass insert web/github.com │
│ echo "MY-API-KEY" | pass insert -f my/yvp_app_key │
│ │
│ ADD with multiple fields (username, URL, notes): │
│ pass insert -m web/amazon.com │
│ (then type the password on line 1, then extras below:) │
│ MySecretPass123 │
│ user: nmaximo7@gmail.com │
│ url: https://www.amazon.es │
│ notes: Personal account │
│ (Press Ctrl+D when done) │
│ │
│ ADD using rofi-pass helper (recommended): │
│ addpass --name "amazon.com" +user "nmaximo7@gmail.com" │
│ │
│ RETRIEVE a password: │
│ pass web/amazon.com │
│ pass -c web/amazon.com (copy to clipboard, clears in 45s)│
│ │
│ EDIT a password entry: │
│ pass edit web/amazon.com │
│ │
│ LIST all passwords: │
│ pass │
│ pass ls │
│ │
│ GENERATE a random password: │
│ pass generate web/newsite.com 20 (20 character password) │
│ pass generate -n web/newsite.com 20 (no symbols) │
│ │
│ REMOVE a password: │
│ pass rm web/amazon.com │
│ │
│ BACKUP your GPG private key (CRITICAL — do this now): │
│ gpg --export-secret-keys --armor nmaximo7@gmail.com > mykey.asc │
│ gpg --export-secret-keys --armor nmaximo7@gmail.com > │
| ~/dotfiles/myArch/config/pass/mykeyArch.asc |
│ Store mykey.asc in KeePassXC or on an encrypted USB drive. │
│ Without this backup, you CANNOT recover your passwords. │
└─────────────────────────────────────────────────────────────────────┘
USAGE_GUIDE
ok "20-pass: done"
info "Add entries to your store with: pass insert site.com"
info "Then use rofi-pass from the rofi launcher: Mod+Shift+D → Passwords"
The standard rofi-pass package often has dependencies on X11 libraries (xdotool) which can be unstable or difficult to use under Wayland compositors like Niri. myrofi_pass.py is a clean, Python-based replacement that speaks directly to the Wayland protocols using wtype and wl-clipboard.
#!/usr/bin/env python3
"""
myrofi_pass.py — rofi frontend for `pass` (Wayland / Niri / Arch)
──────────────────────────────────────────────────────────────────
WHY NOT rofi-pass:
rofi-pass depends on xdotool for some internal operations (xset, xdotool_command)
that do not work on Wayland even with workarounds. This script reimplements
the same functionality cleanly in Python using wl-copy and wtype directly.
WHY list entries from the filesystem instead of parsing `pass ls`:
`pass ls` output uses Unicode box-drawing characters (├── └── │) that vary
by terminal and locale — stripping them reliably with sed is fragile.
Reading ~/.password-store directly with os.walk() gives clean paths with
no parsing ambiguity.
Required packages:
sudo pacman -S pass wtype wl-clipboard libnotify
Usage from rofi-launcher.sh:
N) python3 ~/dotfiles/myArch/admin/myrofi_pass.py ;;
Or bind in niri config.kdl:
Mod+P { spawn "python3" "/home/nmaximo7/dotfiles/myArch/admin/myrofi_pass.py"; }
Optional environment variables:
PASSWORD_STORE_DIR : override the location of the password store
(default: ~/.password-store)
Note on wtype delay:
After rofi closes, the previously focused window regains focus. The delay
(WTYPE_DELAY_MS) gives the window manager time to complete that focus
transition before wtype starts simulating keystrokes. If you notice
characters being typed into the wrong window, increase this value.
Exit codes:
0 : success
1 : general error (missing dependencies, no entries, user cancelled)
"""
import os
import sys
import subprocess
import shutil
import time
# ══════════════════════════════════════════════════════════════════════════════
# CONFIGURATION
# ══════════════════════════════════════════════════════════════════════════════
# Path to a rofi theme file. Using a consistent theme makes the popup match
# the rest of your desktop environment. If the file is missing, rofi will
# fall back to its default theme (which is still functional).
ROFI_THEME = os.path.expanduser("~/dotfiles/myArch/config/rofi/rounded-dark")
# Font specification passed to rofi. The "Nerd Font" suffix indicates that
# the font includes extra glyphs (icons) – we're not using them here, but
# keeping the same font as other rofi launchers maintains visual consistency.
ROFI_FONT = "JetBrainsMono Nerd Font 12"
# Password store directory — standard pass location.
# The environment variable PASSWORD_STORE_DIR is the official way to override
# the store path. If not set, we use the default ~/.password-store.
STORE = os.path.expanduser(
os.environ.get("PASSWORD_STORE_DIR", "~/.password-store")
)
# Milliseconds to wait before wtype starts typing.
# This is a safety margin to ensure the target window has actually received
# focus after rofi exits. 150ms is usually enough on a modern system with a
# compositor like niri. If you find characters being typed into the terminal
# or rofi itself, increase this to 250–300.
WTYPE_DELAY_MS = 150
# ══════════════════════════════════════════════════════════════════════════════
# ENTRY LISTING
# ══════════════════════════════════════════════════════════════════════════════
def list_entries() -> list[str]:
"""
Return all pass entries as clean relative paths, e.g.:
web/amazon.com
email/gmail.com
social/github.com
We walk ~/.password-store directly instead of parsing `pass ls` output.
`pass ls` uses Unicode tree characters (├── └── │) whose stripping is
unreliable across locales and terminal settings. os.walk() gives us the
raw filesystem paths with no ambiguity.
Each .gpg file → strip the .gpg suffix → make relative to the store root.
Hidden files and the .gpg-id file are excluded automatically.
Returns:
A sorted list of entry identifiers (relative paths without .gpg).
If the store directory does not exist or is empty, returns an empty list.
"""
if not os.path.isdir(STORE):
# Store directory missing – pass may not be initialised.
# The caller will handle this by showing an error notification.
return []
entries = []
# store_len is used to slice off the absolute path prefix.
# Adding 1 for the trailing slash makes the resulting string a clean
# relative path (e.g., "web/amazon.com" instead of "/web/amazon.com").
store_len = len(STORE) + 1 # +1 for the trailing /
for dirpath, dirnames, filenames in os.walk(STORE):
# dirpath, the current directory; dirnames, the subdirectories inside dirpath
# filenames, the files inside dirpath
# Skip hidden directories (e.g. .git, .gpg-id, .extensions).
# Modifying dirnames in-place prevents os.walk from descending into
# those directories, keeping the entry list clean.
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
for filename in sorted(filenames):
# Only files ending in .gpg are actual password entries.
# The store may contain other files (e.g., .gpg-id), which we ignore.
if filename.endswith(".gpg"):
full = os.path.join(dirpath, filename)
rel = full[store_len:] # strip store root prefix
entry = rel[:-4] # strip .gpg suffix
entries.append(entry)
# Sort alphabetically for a predictable rofi menu order.
return sorted(entries)
# ══════════════════════════════════════════════════════════════════════════════
# PASS DECRYPTION
# ══════════════════════════════════════════════════════════════════════════════
def get_entry_content(entry: str) -> list[str] | None:
"""
Decrypt and return the lines of a pass entry.
`pass show ` decrypts via GPG and prints to stdout.
Returns None if decryption fails (wrong passphrase, missing key, etc.).
Line format (pass convention):
Line 0: password (always just the password, nothing else)
Line 1+: key: value pairs — user:, url:, notes:, autotype:, etc.
The autotype field, if present, can define custom typing sequences.
This implementation currently only extracts the password, but the
full content is returned for potential future enhancements (e.g.,
auto-filling username fields).
Args:
entry: The pass entry identifier (e.g., "web/amazon.com")
Returns:
A list of strings (one per line) if successful, None otherwise.
"""
# Use subprocess.run with capture_output=True to get stdout/stderr cleanly.
# text=True ensures the output is a string, not bytes.
result = subprocess.run(
["pass", "show", entry],
capture_output=True,
text=True,
)
# pass returns a non-zero exit code on failure (e.g., gpg decryption error,
# missing entry, or GPG agent cancellation). We simply return None; the caller
# will notify the user appropriately.
if result.returncode != 0:
return None
# splitlines() splits on any newline character and discards the final empty
# string if the output ends with a newline. It's safe even if there are
# trailing blank lines (which pass shouldn't produce anyway).
return result.stdout.splitlines()
def extract_field(lines: list[str], *keys: str) -> str:
"""
Find the first line matching any of the given keys (case-insensitive).
Returns the value after the colon, stripped of whitespace.
Returns "" if no matching field is found.
This utility makes it easy to retrieve common metadata from a pass entry:
- username (user, username, login, email)
- url (url, website)
- OTP secret indicator (otp, totp)
Examples:
extract_field(lines, "user", "username", "login")
extract_field(lines, "url", "website")
extract_field(lines, "otp", "totp")
"""
for line in lines[1:]: # skip line 0 (the password itself)
# Only lines containing a colon are considered field definitions.
# pass uses the format "key: value". The colon is mandatory.
if ":" in line:
# partition splits into (key, ":", value). We don't care about the
# colon itself, so we discard it with `_`.
key, _, value = line.partition(":")
# Strip whitespace from the key and lowercase for case‑insensitive
# matching against the provided keys (which are expected to be lowercase).
if key.strip().lower() in keys:
# Return the value with surrounding whitespace removed.
return value.strip()
return ""
# ══════════════════════════════════════════════════════════════════════════════
# CLIPBOARD
# ══════════════════════════════════════════════════════════════════════════════
def copy_to_clipboard(text: str, label: str = "") -> None:
"""
Copy text to the Wayland clipboard with wl-copy.
WHY Popen + stdin.close() instead of subprocess.run():
wl-copy stays alive in the background to serve paste requests.
It only exits after another app reads the clipboard.
subprocess.run() would block forever waiting for wl-copy to finish.
Popen() starts it and returns immediately; we write our text and
close stdin to signal end-of-input, then walk away.
WHY start_new_session=True:
Detaches wl-copy from our process group so it survives after
this script exits. Without this it gets SIGHUP when we close.
On Wayland, wl-copy often runs as a daemon‑like process; we want it
to remain until the clipboard is cleared or overwritten.
Args:
text: The string to place on the clipboard.
label: Optional description for the notification (e.g., "Password").
"""
# Sanity check: wl-copy must be installed.
if not shutil.which("wl-copy"):
notify("Pass", "wl-copy not found — sudo pacman -S wl-clipboard", error=True)
return
# Launch wl-copy in the background, attaching only to stdin.
# stdout/stderr are sent to DEVNULL to avoid clutter.
proc = subprocess.Popen(
["wl-copy"],
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True, # detach from our process group
)
# Write the data to wl-copy's stdin. Encode as UTF-8 (standard for text).
proc.stdin.write(text.encode("utf-8"))
# Closing stdin signals end‑of‑file; wl-copy will store the data and wait.
proc.stdin.close()
# IMPORTANT: Do NOT call proc.wait() or proc.communicate() — they would block.
# wl-copy is now a background daemon that will persist after this script exits.
# Notify the user that the data was copied.
msg = f"Copied: {label}" if label else "Copied to clipboard"
notify("🔑 Pass", msg)
# ══════════════════════════════════════════════════════════════════════════════
# AUTOTYPE
# ══════════════════════════════════════════════════════════════════════════════
def wtype_text(text: str) -> None:
"""
Type `text` into the focused window using wtype.
wtype speaks the Wayland virtual-keyboard protocol — it works natively
on Niri without XWayland. xdotool does NOT work on native Wayland.
We sleep WTYPE_DELAY_MS before typing to give the window time to
receive focus after rofi closes. Without this the first character
often goes to the wrong window.
"""
if not shutil.which("wtype"):
notify("Pass", "wtype not found — sudo pacman -S wtype", error=True)
return
# The delay is necessary because after rofi exits, the compositor (niri)
# must re‑focus the previously active window. 150ms is usually sufficient;
# increase to 250–300 if characters land in the terminal or rofi itself.
time.sleep(WTYPE_DELAY_MS / 1000)
# wtype reads the text from the command line argument. This is fine for
# short strings like passwords; for longer content we could pipe via stdin.
subprocess.run(["wtype", text], capture_output=True)
def wtype_key(key: str) -> None:
"""
Press a special key using wtype.
key: "Tab", "Return", "BackSpace", etc. (X11 key names)
Uses wtype's -k flag which interprets the argument as a key name.
"""
subprocess.run(["wtype", "-k", key], capture_output=True)
def autotype(entry: str, lines: list[str]) -> None:
"""
Auto-type the password entry into the focused window.
Default sequence: username → Tab → password → Enter
If no username field, type only the password.
Check for a custom autotype: field in the entry first.
Supported tokens in autotype:
:tab → press Tab
:enter → press Enter
:space → press Space
user → type the username
pass → type the password
Args:
entry: The entry name (used for notification only).
lines: The decrypted lines from get_entry_content().
"""
username = extract_field(lines, "user", "username", "login", "email")
password = lines[0] if lines else ""
custom = extract_field(lines, "autotype")
if custom:
# Parse the custom autotype sequence
# Tokens are separated by whitespace.
# Example: "user :tab pass :enter"
tokens = custom.split()
for token in tokens:
if token == ":tab":
wtype_key("Tab")
elif token == ":enter":
wtype_key("Return")
elif token == ":space":
wtype_key("space")
elif token == "user":
if username:
wtype_text(username)
elif token == "pass":
wtype_text(password)
else:
wtype_text(token)
elif username:
# Default sequence with username present.
wtype_text(username)
wtype_key("Tab")
wtype_text(password)
# Optionally press Enter to submit. Many login forms require it.
# (Some users prefer not to auto‑submit; this is a design choice.)
# Optional: wtype_key("Return")
else:
# No username – just type the password. Do not press Enter
# because the field might not be a login form (e.g., sudo prompt).
wtype_text(password)
notify("🔑 Pass", f"Autotyped: {entry}")
# ══════════════════════════════════════════════════════════════════════════════
# OTP
# ══════════════════════════════════════════════════════════════════════════════
def get_otp(entry: str) -> str | None:
"""
Generate a TOTP code using `pass otp`.
Requires the pass-otp extension: paru -S pass-otp
The pass‑otp extension expects the OTP secret to be stored in the entry
as a field like `otpauth://totp/...` or a simple `otp: `.
It returns the current 6‑digit TOTP code.
Returns the OTP string, or None if unavailable / extension missing.
"""
result = subprocess.run(
["pass", "otp", entry],
capture_output=True,
text=True,
)
if result.returncode == 0:
# The output is the OTP code with possible trailing newline.
return result.stdout.strip()
# Return None if command fails (e.g., entry has no OTP field, extension not installed).
return None
# ══════════════════════════════════════════════════════════════════════════════
# NOTIFICATIONS
# ══════════════════════════════════════════════════════════════════════════════
def notify(title: str, body: str = "", error: bool = False) -> None:
"""Send a desktop notification. Silent if notify-send is absent.
Uses libnotify's `notify-send` command, which is the standard way to
display notifications on freedesktop‑compliant desktops (GNOME, KDE, Sway,
Niri with mako/dunst, etc.).
WHY Popen instead of run():
We don't need to wait for the notification to be displayed.
Launching it asynchronously with Popen keeps the script responsive.
Errors are sent to DEVNULL so a missing notification daemon doesn't
clutter the terminal.
WHY different icons:
The icon name is passed to the notification daemon, which resolves it
from the current icon theme. "dialog-password" usually shows a key or
lock; "dialog-error" shows a red warning sign. This gives immediate
visual feedback about the nature of the message.
Args:
title: The notification headline (e.g., "🔑 Pass")
body: Optional descriptive text (e.g., "Copied: web/amazon.com")
error: If True, use an error icon and slightly different styling.
"""
if not shutil.which("notify-send"):
# No notification system available; fail silently.
return
icon = "dialog-error" if error else "dialog-password"
# -i : icon name
# -t : timeout in milliseconds (3000 = 3 seconds)
subprocess.Popen(
["notify-send", "-i", icon, "-t", "3000", title, body],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
# No wait; the notification daemon handles the rest.
# ══════════════════════════════════════════════════════════════════════════════
# ROFI HELPERS
# ══════════════════════════════════════════════════════════════════════════════
def rofi_menu(options: list[str], prompt: str, lines: int | None = None) -> str | None:
"""
Show a rofi dmenu and return the selected string.
Returns None if Escape was pressed (or any other cancellation).
Uses -format i (index) to avoid any whitespace/encoding issues.
WHY -format i instead of parsing the output string directly:
Rofi's default output is the selected line exactly as it appears in
the input. This breaks if the entry contains special characters,
Unicode, or even trailing newlines. Using -format i makes rofi
return the 0‑based numeric index of the chosen entry. We can then
look up the original string from the `options` list, which is
immune to any formatting or escaping problems.
WHY -no-custom:
Prevents the user from typing arbitrary text that isn't in the list.
For a password manager frontend, we only want valid entries/actions.
WHY dynamic line count:
Rofi can display a maximum number of lines before scrolling.
We cap at 20 to avoid the menu taking over the whole screen,
but if there are fewer options, we show exactly that many.
This keeps the menu compact and aesthetically pleasing.
Args:
options: List of strings to display as selectable rows.
prompt: The prompt text shown at the top of the rofi window.
lines: Optional override for the number of visible lines.
If None, defaults to min(len(options), 20).
Returns:
The selected string from the options list, or None if cancelled.
"""
# Determine the number of lines to display.
n = lines if lines is not None else min(len(options), 20)
# Build the rofi command.
# -dmenu : read list from stdin, output selection to stdout
# -i : case‑insensitive matching (makes searching easier)
# -p : prompt text
# -no-custom : only accept input that matches an option
# -format i : output the index of the selected line (0‑based)
# -lines : number of rows to show
# -theme : path to our custom theme file
# -font : font specification for consistency
result = subprocess.run(
[
"rofi", "-dmenu", "-i",
"-p", prompt,
"-no-custom",
"-format", "i",
"-lines", str(n),
"-theme", ROFI_THEME,
"-font", ROFI_FONT,
],
input="\n".join(options),
capture_output=True,
text=True,
)
# Rofi returns exit code 0 on successful selection, 1 on user cancellation
# (Escape), and other codes for errors. We treat anything non‑zero as cancel.
if result.returncode != 0:
return None
idx_str = result.stdout.strip()
# The output should be a single integer. If it's not (e.g., rofi printed
# a warning about a missing theme), we dismiss it because we cannot trust it.
if not idx_str.isdigit():
return None
idx = int(idx_str)
# Safety check: ensure the index is within the valid range.
# (Should always be true unless rofi has a bug.)
return options[idx] if 0 <= idx < len(options) else None
# ══════════════════════════════════════════════════════════════════════════════
# MAIN FLOW
# ══════════════════════════════════════════════════════════════════════════════
def main() -> None:
"""
Orchestrate the entire password‑management workflow.
Steps:
1. List all entries from the password store.
2. Present a rofi menu to select one entry.
3. Decrypt the selected entry (prompting for GPG passphrase if needed).
4. Parse the entry content (password, username, URL, etc.).
5. Show an action menu with context‑aware options (only show what's relevant).
6. Execute the chosen action (autotype, copy, open URL, show entry).
The function exits cleanly with sys.exit(0) on user cancellation,
and sys.exit(1) on errors (missing store, decryption failure).
"""
# ── Step 1: list entries ──────────────────────────────────────────────────
# Gather all .gpg files from the password store directory.
# list_entries() returns a sorted list of relative paths without the .gpg extension.
entries = list_entries()
if not entries:
# The store may be empty, missing, or incorrectly initialised.
# Use an error notification to make the problem obvious.
notify("Pass", f"No entries found in {STORE}", error=True)
sys.exit(1)
# ── Step 2: pick an entry ─────────────────────────────────────────────────
# Use rofi_menu to display a scrollable list of all entries.
# - prompt: a lock emoji and "Pass" – keeps it visually distinct.
# - lines=20: a comfortable height that doesn't dominate the screen.
chosen = rofi_menu(entries, prompt=" 🔑 Pass", lines=20)
if chosen is None:
# User pressed Escape – exit gracefully without error.
sys.exit(0)
# ── Step 3: decrypt ───────────────────────────────────────────────────────
# `pass show` will invoke GPG. If the key is not cached, GPG may spawn a
# pinentry dialog (graphical or terminal). The script blocks until
# decryption succeeds or the user cancels.
lines = get_entry_content(chosen)
if lines is None:
# Decryption failed – most likely user cancelled the GPG passphrase prompt,
# or the GPG key is missing. Notify with an error and exit.
notify("Pass", f"Failed to decrypt: {chosen}\nCheck GPG passphrase.", error=True)
sys.exit(1)
# Extract the core fields. The first line is always the password.
password = lines[0] if lines else ""
username = extract_field(lines, "user", "username", "login", "email")
url = extract_field(lines, "url", "website")
# ── Step 4: action menu ───────────────────────────────────────────────────
# Build the menu dynamically — only show actions that make sense
# for this entry. This reduces clutter and prevents the user from selecting a useless option.
actions: list[str] = []
# Autotype is always available (even without a username it still types the password).
actions.append("⏎ Autotype (username → Tab → password)")
# Copying the password is the most common operation.
actions.append("🔑 Copy Password")
# Username: only include this action if the entry actually contains one.
if username:
actions.append(f"👤 Copy Username ({username})")
# OTP: only show if both `pass` and `pass-otp` are available.
# The pass-otp extension is a separate script (usually installed via AUR as
# `pass-otp`). Checking for the executable ensures we don't offer an option
# that will immediately fail.
# The entry file existence check is kept as a fast path – no point in showing
# OTP if the file doesn't exist (though it should, since we just decrypted it).
if shutil.which("pass") and shutil.which("pass-otp") and os.path.exists(
os.path.join(STORE, chosen + ".gpg")
):
actions.append("🔢 Copy OTP (requires pass-otp)")
# URL: open in the default browser if present.
if url:
actions.append(f"🌐 Open URL ({url})")
# Show entry is always available – it displays the full decrypted content
# for inspection (including the password, per user preference).
actions.append("👁️ Show Entry (all fields)")
# Present the action menu. The prompt includes the chosen entry name so
# the user never loses context.
action = rofi_menu(actions, prompt=f" 🔑 {chosen}", lines=len(actions))
if action is None:
sys.exit(0) # Escape
# ── Step 5: execute ───────────────────────────────────────────────────────
# Each action is matched by looking for a known substring.
# The emojis are included in the action strings but we use substring matching
# to keep the condition simple and robust against future emoji changes.
if "Autotype" in action:
autotype(chosen, lines)
elif "Copy Password" in action:
copy_to_clipboard(password, label="password")
elif "Copy Username" in action:
# This branch is only reached if the username field exists (since we only
# added the menu item when `username` is non‑empty). The `if username`
# check remains as a defensive guard, but it should never be false here.
if username:
copy_to_clipboard(username, label=username)
else:
notify("Pass", f"No username field in: {chosen}", error=True)
elif "Copy OTP" in action:
otp = get_otp(chosen)
if otp:
copy_to_clipboard(otp, label="OTP")
else:
# Provide helpful guidance: the user likely needs to set up pass-otp
# or add an OTP secret to the entry.
notify("Pass",
"No OTP available.\n"
"Add a TOTP key with: pass otp add " + chosen,
error=True)
elif "Open URL" in action:
if url:
# xdg-open is the freedesktop standard for opening files/URLs with the
# user's preferred application. start_new_session=True detaches the
# browser from our script so it doesn't get killed when we exit.
subprocess.Popen(
["xdg-open", url],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
else:
notify("Pass", f"No URL field in: {chosen}", error=True)
elif "Show Entry" in action:
# Show all fields in a read‑only rofi window.
# The password is included in plain text because I consider (you may want to change it)
# that security is not a primary concern in my environment.
# The entry name and a separator line provide visual structure.
display_lines = [f" Entry: {chosen}", " " + "─" * 40]
# Password is displayed first for immediate visibility.
display_lines.append(f" Password: {password}")
for line in lines[1:]:
display_lines.append(f" {line}")
if not lines[1:]:
display_lines.append(" (no extra fields)")
rofi_menu(display_lines, prompt=f" {chosen}", lines=len(display_lines))
if __name__ == "__main__":
main()
The myrofi_pass.py script has native support for pass-otp, which allows you to store TOTP secrets (like the ones you’d scan with Google Authenticator) securely in your password store.
# ══════════════════════════════════════════════════════════════════════════════
# Configuring OTP (One‑Time Passwords)
# ══════════════════════════════════════════════════════════════════════════════
#
# TOTP secrets are stored as otpauth:// URIs. These URIs contain all the
# information required to generate a 6‑digit code that changes every 30 seconds:
# - The secret key (base32 encoded)
# - The algorithm (usually SHA1)
# - The digit count (usually 6)
# - The period (usually 30 seconds)
# - Optional issuer and account label
#
# When you run `pass otp `, the pass‑otp extension reads the URI,
# calculates the current TOTP code based on the system time, and prints it.
#
# ──────────────────────────────────────────────────────────────────────────────
# Installation and Usage
# ──────────────────────────────────────────────────────────────────────────────
# Install pass-otp (Arch Linux)
# The package provides the `pass otp` subcommand.
sudo pacman -S pass-otp
# First, create an entry. Enter your password:
pass insert gmail
# ──────────────────────────────────────────────────────────────────────────────
# Method 1: Manual URI entry using `pass otp insert/append`
# ──────────────────────────────────────────────────────────────────────────────
# This interactive command prompts you to paste the full otpauth:// URI twice.
# It stores the URI in a dedicated .gpg file.
pass otp append gmail
# Enter: otpauth://totp/Google:your@email.com?secret=25YOURFULLSECRET&issuer=Google
# Retype to confirm
# ──────────────────────────────────────────────────────────────────────────────
# Method 2: Direct secret via piping to `pass insert`
# ──────────────────────────────────────────────────────────────────────────────
# You can also create the entry directly using `pass otp append`. This is useful
# for scripting or when you want to combine the OTP URI with other fields
# (username, password, notes) in a single encrypted file.
#
# The following command creates a new entry named "gmail" (or overwrites if it
# already exists) and writes the otpauth URI as the first line.
# `pass insert` is multi‑line by default (press Ctrl+D to finish). Here we use
# echo to provide the single line non‑interactively.
echo "otpauth://totp/Google:your@email.com?secret=25YOURFULLSECRET&issuer=Google" | pass otp append gmail
# ──────────────────────────────────────────────────────────────────────────────
# Using the OTP code
# ──────────────────────────────────────────────────────────────────────────────
# Generate TOTP codes on the terminal
pass otp gmail
# Copy to clipboard (automatically clears after 45 seconds)
pass otp -c gmail
# Create or edit the entry interactively
pass edit gmail
# In the editor that opens, enter the following content:
your-strong-password-here
user: your.email@gmail.com
otpauth://totp/Google:your.email@gmail.com?secret=YOURBASE32SECRET&issuer=Google