pass(1), reimagined. The same storage format and the same command surface,
but everything people install a dozen bash extensions for is built in and works
the same way on Linux, macOS and Windows.
- Same store layout as pass: a directory tree,
.gpg-id,.age-recipients. - Same stdout, byte for byte, so
passmenu,rofi-pass,browserpassand QtPass keep working. - GPG and age side by side in one tree, chosen per entry by file extension.
- Hardware keys through the standard age-plugin protocol.
- A single static binary,
CGO_ENABLED=0.
Compatibility is not a claim, it is a test: the suite runs the real pass
against binpass on an identical store and compares stdout, exit codes and the
resulting tree. See Compatibility.
Implemented so far: the complete pass command surface, age and GPG backends, one-time passwords, the built-in picker, plugins, the encrypted tomb, import and export, auditing, the full-screen TUI, and the Secret Service provider that lets it stand in for gnome-keyring.
| Working | Command |
|---|---|
| yes | init ls show find grep insert edit generate rm mv cp git version |
| yes | otp (pass-otp), menu (passmenu / rofi-pass), generate --words (diceware) |
| yes | plugin — any executable named binpass-* on PATH becomes a subcommand |
| yes | tomb (pass-tomb), doctor |
| yes | import (pass-import, 9 formats), export (CSV), audit (pass-audit), binary (pass-file) |
| yes | sync remote conflicts fsck |
| yes | tui (full-screen browser), history (git revisions of an entry) |
| yes | ss — serve the store as the system keyring (org.freedesktop.secrets), Linux |
| yes | keychain — import from and export to the macOS Keychain |
Drop an executable named binpass-foo on your PATH and binpass foo runs
it, the same way kubectl and git work. Arguments and flags pass through
untouched, and the plugin's exit status becomes binpass's.
printf '#!/bin/sh\necho "hello, $*"\n' > ~/.local/bin/binpass-hello
chmod +x ~/.local/bin/binpass-hello
binpass hello world # hello, world
binpass plugin list # what binpass can see, and what will not runPlugins call back into binpass through $BINPASS_BIN with --format=json,
so they never parse output meant for humans. See docs/plugins.md,
which includes an honest account of what the plugin boundary does and does
not protect.
go install github.com/71g3pf4c3/binpass/cmd/binpass@latestOr with Nix:
nix run github:71g3pf4c3/binpass
nix develop # dev shell with go, gpg, pass, age, rofi, fzfThe flake ships an overlay and a module for each.
{
inputs.binpass.url = "github:71g3pf4c3/binpass";
# NixOS
outputs = { nixpkgs, binpass, ... }: {
nixosConfigurations.laptop = nixpkgs.lib.nixosSystem {
modules = [
binpass.nixosModules.default
{
nixpkgs.overlays = [ binpass.overlays.default ];
programs.binpass = {
enable = true;
replacePass = true; # install it as `pass` too
extraPackages = [ pkgs.rclone ]; # transports are external binaries
};
}
];
};
};
}# home-manager
{
imports = [ binpass.homeModules.default ];
programs.binpass = {
enable = true;
replacePass = true;
storeDir = "~/.password-store";
identityFile = "~/.local/share/binpass/identities.age";
settings = {
crypto.default = "age";
generate.length = 25;
sync = {
default_remote = "origin";
remotes.origin = {
type = "git";
url = "git@github.com:you/password-store.git";
};
};
};
tomb = {
enable = true; # a user service that closes the tomb on logout
timer = "1h";
};
};
}replacePass installs binpass under the name pass, with completions and
man pages generated for that name. The binary answers to whichever name it
was invoked under, so pass --help says pass, tab completion completes
pass, and man pass documents pass — scripts and browser extensions
calling pass keep working against the same store. It is off by default,
because shadowing a command nobody asked to have shadowed is not a decision
a module should make quietly.
settings is written to the Nix store, which every user on the machine can
read. Keep secrets out of it: for a restic repository password use
sync.remotes.<name>.password_command and have it read from somewhere else.
# A new store, encrypted with age.
age-keygen -o ~/.config/binpass/identities.age
binpass init --age age1qz...
# Or drop in next to an existing pass store; nothing needs converting.
binpass init --gpg you@example.com
binpass insert github.com/alice
binpass generate -c bank/tinkoff 32
binpass show github.com/alice
binpass otp github.com/aliceMove passwords from another manager into binpass. Auto-detection means you don't
need to know the format — just point binpass import at the export file.
# Import a CSV or KDBX file (format detected automatically).
binpass import bitwarden_export.csv
binpass import keepass.kdbx # prompts for database password
# See what would be imported without writing anything.
binpass import --dry-run bitwarden_export.csv
# Force-overwrite entries that already exist.
binpass import --force bitwarden_export.csv
# Specify the format explicitly when auto-detection fails.
binpass import --format=1password export.csv
# Handle non-UTF-8 exports (e.g. Russian LastPass).
binpass import --encoding=windows-1251 lastpass.csvSupported formats: Bitwarden, 1Password, LastPass, Chrome, Firefox, Enpass, KeePass (KDBX), pass, gopass.
Export the entire store to CSV. The output contains decrypted passwords in plain text — delete the file after use and never commit it.
binpass export # to stdout
binpass export backup.csv # to fileCheck the store for weak, reused, expired, and breached passwords.
# Full audit (includes HIBP breach check, requires network).
binpass audit
# Offline audit (skip the HIBP check).
binpass audit --no-hibp
# JSON output for scripting.
binpass audit --format=json
# Parallel decryption (faster, but bad for hardware tokens).
binpass audit --parallel=4The HIBP check uses the k-anonymity protocol: only the first 5 characters of the SHA-1 hash are sent to the API. The full password hash never leaves the machine. Passwords are never printed in the output — only entry names and verdicts.
binpass binary replaces pass-file. Binary entries are stored as base64-encoded
.b64 entries (gopass convention), so they are versioned, synced, and re-encrypted
alongside text secrets.
# Store a binary file in the password store.
binpass binary copy photo.b64 photo.jpg
# Decode and write back to disk.
binpass binary cat photo.b64 > photo.jpg
# Check the SHA-256 of the decoded content.
binpass binary sum photo.b64
# Store and delete the original (like git mv).
binpass binary move key.b64 /path/to/key.pembinpass reads every PASSWORD_STORE_* variable pass understands. The
matching BINPASS_* variable wins where both are set.
binpass menu replaces passmenu, rofi-pass and fzf-pass without a
wrapper script. It detects rofi, wofi, dmenu, wmenu or fzf, and never decrypts
anything until an entry is actually chosen.
binpass menu # pick, copy the password
binpass menu --field=username # copy a field instead
binpass menu --type # type it into the focused window
binpass menu --launcher=fzf # force a picker
binpass menu --type --tool=ydotool # force the typing backend too
binpass menu -- -theme solarized # arguments after -- go to the launcherBind it to a key:
# sway
bindsym $mod+p exec binpass menu
bindsym $mod+Shift+p exec binpass menu --type
# i3
bindsym $mod+p exec --no-startup-id binpass menu
--format=plain and --format=json exist precisely so launcher scripts never
have to parse the tree output:
binpass ls --format=plain | rofi -dmenu -i -p passThat one line is the core of rofi-pass. If binpass menu does not do what
you want, a complete rofi-pass equivalent is short enough to keep in your
dotfiles. Name it binpass-rofi, put it on your PATH, and it becomes
binpass rofi:
#!/usr/bin/env bash
set -euo pipefail
entry=$(binpass ls --format=plain | rofi -dmenu -i -p pass) || exit 0
action=$(printf 'copy\ntype\nautofill\notp\n' | rofi -dmenu -i -p "$entry") || exit 0
case $action in
copy) binpass show --clip "$entry" ;;
type) binpass show --field=password "$entry" | tr -d '\n' | wtype - ;;
otp) binpass otp --clip "$entry" ;;
autofill)
user=$(binpass show --field=username "$entry")
pass=$(binpass show --field=password "$entry")
wtype "$user" -k Tab "$pass" -k Return
;;
esacThe upstream rofi-pass needs about 900 lines to do this, because pass gives
it nothing to build on: it walks the store itself, parses human-readable
output and drives gpg by hand.
binpass ships no launcher scripts of its own. binpass menu covers what they
did, and anything it does not cover is a plugin you write in a dozen lines
rather than a script this repository has to keep working on four desktops.
Copying always restores the clipboard's previous contents afterwards, and only if the secret is still there, so it never clobbers something you copied in the meantime.
--type picks its backend per session (wtype on Wayland, xdotool on X11,
ydotool otherwise). When several are installed, or the session variables
lie about the desktop, --tool=wtype|xdotool|ydotool on binpass type,
binpass menu and binpass otp menu forces one; BINPASS_TYPER_TOOL and
the typer.tool config setting do the same for every invocation.
docs/typing.md covers tool selection, the --field and
--delay flags, the otp menu ranking, and why the secret never appears in
ps(1).
Encryption hides what an entry contains. It does not hide that
bank/savings exists, and file names travel to whatever you sync with.
binpass tomb packs the whole store into one encrypted archive:
binpass tomb init # pack the store; nothing is removed yet
binpass tomb close # hide everything
ls -a ~/.password-store
# . .. .age-recipients store.coffin.age
binpass tomb open --timer=1h # work with it; closes itself after an hourThe archive is encrypted to the store's own recipients, so a hardware token that already unlocks your entries unlocks the tomb, with no second password to manage. On Linux, locking the screen or suspending closes it.
An open tomb is an ordinary directory, and closing it cannot truly erase the plaintext on flash storage. docs/tomb.md is explicit about both, and covers auto-close, crash recovery, and syncing a closed tomb.
binpass tui is a full-screen session for browsing and editing the store.
binpass tui▾ bank/
savings
▾ github.com/
alice
binpass q:quit /:search n:new enter:open h:collapse E:expand-all C:collapse-all j/k:nav
Navigating the tree decrypts nothing: entries are opened on demand, so browsing never triggers a hardware-token touch. An opened entry masks its password until you ask for it:
▸ github.com/alice
pass: ••••••••
url: https://github.com
username: alice
binpass p:toggle c:copy o:otp d:delete r:rename g:generate y:history esc:back
It runs on the alternate screen, so nothing that was displayed survives in scrollback, and the session locks itself after five minutes of inactivity, clearing decrypted secrets from memory. Editing, new entries with generation, binary attachments, grep, the audit report and store status are all in there too — docs/tui.md walks the whole key map.
The TUI follows your terminal's own palette by default. To dress it in a named one:
binpass tui --theme=gruvboxThe same choice persists in the config file:
# ~/.config/binpass/config.yaml
ui:
theme: gruvbox # default | gruvbox | gruvbox-light | nord | draculaBINPASS_THEME=gruvbox covers the environment, and NO_COLOR still wins
over all of them. Themes colour the TUI only — command output stays
byte-identical to pass, so scripts and the golden compatibility suite never
see a difference. A theme assumes the matching terminal background:
gruvbox colours on a stock black terminal work, but they look right on a
gruvbox one.
On Nix / home-manager the theme is an option, so a typo fails at evaluation rather than at startup:
programs.binpass.theme = "gruvbox";binpass history ENTRY lists the git revisions that touched an entry. It
reads commit metadata only and decrypts nothing, so no password can be
revealed by it.
Chrome, VS Code, NetworkManager and Evolution keep their secrets in the
system keyring rather than asking you. binpass ss serves that keyring —
org.freedesktop.secrets on Linux — from your password store, so those
programs read from the store you already back up and hold the keys to.
binpass ss doctor # who owns the keyring now, and how to take it over
binpass ss serveprintf 'hunter2' | secret-tool store --label='GitHub' server github.com username alice
secret-tool lookup server github.com username alice
# hunter2
binpass show secret-service/login/4f3c…
# hunter2
# label: GitHub
# attr.server: github.com
# attr.username: aliceItems are ordinary pass entries, so anything a browser writes stays readable by hand.
The attributes — server, username, application — are a complete map of
your accounts, and pass-secret-service stores them in plaintext beside the
ciphertext. Here they live inside the encrypted file, with lookup going
through an index of keyed hashes, so the list of what you have accounts with
never reaches whatever you sync to. docs/secret-service.md
explains how that works and what it still leaks.
Identical to pass. The first line is the password, everything after it is free text:
hunter2
otpauth://totp/GitHub:alice?secret=JBSWY3DPEHPK3PXP&issuer=GitHub
url: https://github.com
username: alice
key: value lines are exposed through --field, and any otpauth:// URI is
picked up by binpass otp, but neither is required. A secret round-trips
through binpass byte for byte, including entries with no trailing newline.
GPG and age entries coexist in one tree, selected by extension. A new entry
uses the store's default backend, unless the destination only has recipients
for the other one: a GPG-only store keeps receiving .gpg files even when age
is configured as the default, so your existing pass never stops working.
binpass synchronises your password store across devices without a server. The sync engine is transport-agnostic: it works identically over git, restic, Google Drive, Yandex.Disk, WebDAV, and S3. Conflict resolution is automatic and conservative — nothing is ever lost silently.
If your store is already a git repository (most pass users' stores are),
binpass sync works out of the box:
# First sync: detects the git remote in the store automatically.
binpass sync
# Dry run: show what would happen without making changes.
binpass sync --dry-run
# Push and pull are implicit; the engine figures out the direction.The commit messages are identical to pass (Add given password for X to store.), so the history is indistinguishable from a native pass repository.
Your existing pass git log output stays clean.
# The store directory becomes a git repository with a remote.
binpass git init
binpass git remote add origin git@github.com:you/password-store.git
binpass syncRestic provides encrypted, deduplicated, versioned snapshots of the entire store. Unlike git, the remote storage sees only opaque encrypted blobs — entry names are never exposed.
# Local encrypted backup.
restic init --repo /mnt/backup/binpass
binpass remote add restic local /mnt/backup/binpass
binpass sync --remote=local
# S3 (restic speaks S3 directly, no rclone needed).
restic init --repo s3:s3.amazonaws.com/my-bucket/binpass
binpass remote add restic s3-backup s3:s3.amazonaws.com/my-bucket/binpass
binpass sync --remote=s3-backupThe repository string is handed to restic untouched, so every restic
backend works: sftp:, b2:, azure:, gs:, swift:, rest:, and
rclone: — which reaches Google Drive, Dropbox, OneDrive and the rest of
rclone's list with deduplication and snapshots on top of them.
binpass remote add restic b2 "b2:mybucket:binpass"
binpass remote add restic drive "rclone:gdrive:binpass"For production, store the restic password securely:
# ~/.config/binpass/config.yaml
sync:
remotes:
s3-backup:
type: restic
url: s3:s3.amazonaws.com/my-bucket/binpass
password_command: "pass show restic/binpass"Each binpass sync creates a new restic snapshot. Roll back with
restic restore or binpass sync from an earlier snapshot.
binpass uses rclone under the hood for cloud storage. Install rclone first, then:
# S3 (MinIO, AWS, Garage, etc.)
binpass remote add s3 mybucket mybucket:password-store
# Google Drive
binpass remote add gdrive gdrive binpass-store
# Yandex.Disk
binpass remote add yandex yandex password-store
# WebDAV (Nextcloud, ownCloud, Synology, etc.)
binpass remote add webdav nextcloud nextcloud:password-store
# Sync with a specific remote.
binpass sync --remote=mybucketrclone configuration is shared with ~/.config/rclone/rclone.conf. If you
already use rclone, your existing remotes work without any extra setup.
When two devices edit the same entry while offline, binpass keeps both copies — it never silently picks a winner:
github.com/alice.gpg ← remote version
github.com/alice.conflict-thinkpad-20260808T142233.gpg ← local version
The original path always receives the remote version; the local version is
saved with a .conflict-<device>-<timestamp> suffix. Both files are encrypted
with the same recipients as the original entry.
HOTP counters are auto-merged. If the only difference between two versions is the HOTP counter (which diverges by design), binpass takes the maximum and increments both version vectors. This is the only place where the sync engine looks inside a secret; everything else operates on ciphertext.
Delete vs edit: edit wins. If one device deletes an entry while another edits it, the edited version is preserved with a warning. A deleted entry that still has a live version on another device is not lost.
# List conflict files in the store.
binpass conflicts list
# Show the diff between a conflict file and the current version.
# The password line is masked by default.
binpass conflicts diff github.com/alice.gpg
# Show the full diff including the password.
binpass conflicts diff github.com/alice.gpg --show-secrets
# Resolve: keep the local version (delete conflict file).
binpass conflicts resolve github.com/alice.gpg --strategy=local
# Resolve: keep the remote version (overwrite local with conflict file).
binpass conflicts resolve github.com/alice.gpg --strategy=remote
# Resolve: keep both (the default — conflict file stays).
binpass conflicts resolve github.com/alice.gpg --strategy=bothRemotes can be configured in the YAML config file at
~/.config/binpass/config.yaml:
sync:
auto: off # off | on-change | interval
conflict: keep-both # keep-both | interactive | prefer-local | prefer-remote
default_remote: origin # used when --remote is not specified
remotes:
origin:
type: git
url: git@github.com:you/password-store.git
s3-backup:
type: s3
url: mybucket:password-store
gdrive:
type: gdrive
url: gdrive
folder: binpass-store
yandex:
type: yandex
url: yandex
folder: password-store
webdav:
type: webdav
url: nextcloud:password-storeEnvironment variable overrides (take priority over the config file):
| Variable | Config key | Example |
|---|---|---|
BINPASS_SYNC_AUTO |
sync.auto |
on-change |
BINPASS_SYNC_CONFLICT |
sync.conflict |
prefer-local |
BINPASS_SYNC_DEFAULT_REMOTE |
sync.default_remote |
s3-backup |
The sync state database lives outside the password store, at
$XDG_STATE_HOME/binpass/ (default ~/.local/state/binpass/). This is
critical: if state.db were inside the store, it would end up in git history
and on cloud drives, and pass git status would show noise.
Override with BINPASS_STATE_DIR:
export BINPASS_STATE_DIR=~/.binpass-state# Check the store against the state database.
binpass fsckfsck reports:
- Untracked files — on disk but not in state.db (e.g. created outside binpass).
- Orphaned state — in state.db but not on disk (e.g. deleted outside binpass).
- Size drift — file size differs from what state.db recorded.
- State in store — state.db is inside the password store (critical leak risk).
- Missing recipients — no
.gpg-idor.age-recipientsfile found.
Errors cause a non-zero exit code; warnings are informational.
The sync engine uses version vectors (one counter per device) to detect divergence without a central clock:
- Scan the local store:
(size, mtime)for quick detection, blake3 hash of the ciphertext for confirmation. - List the remote: build a remote snapshot from the transport.
- Load the base snapshot: the state at the last successful sync, stored in state.db.
- Merge: compare local, remote, and base. Version vectors determine the action (push, pull, conflict, merge, or nothing).
- Apply: upload, download, or create conflict files. Each action updates state.db independently, so a failure halfway through is resumable.
- Record: persist the new base snapshot.
All state mutations go through a write-ahead log (bbolt). If the process is killed mid-sync, the WAL is replayed on the next run and the state is recovered automatically.
The golden suite installs the real pass and runs both programs over identical
stores:
TestListingMatchesPass tree output, byte for byte
TestShowPreservesExactBytes entries without a trailing newline
TestTreeLayoutIsByteIdentical every connector and continuation glyph
TestStoreWrittenByBinpassIsReadableByPass
TestStoreWrittenByPassIsReadableByBinpass
tree(1) picks its glyphs from the locale codeset and its colours from TERM;
binpass reproduces both decisions rather than hardcoding one, which is why the
output matches inside a container as well as on a desktop.
Known deviation. For rm, mv and cp, pass shells out to rm -v,
mv -v and cp -v and passes coreutils' progress chatter straight through,
absolute paths and all. binpass performs the same operations and produces the
same tree, but does not reproduce that output. Nothing in the ecosystem parses
it.
Not supported. pass extensions written in bash that source pass's internals. Everything they do is available natively; see ARCHITECTURE.md §5.
| Guide | Covers |
|---|---|
| docs/sync-remotes.md | Setting up every transport: git, restic, S3, Google Drive, Yandex.Disk, WebDAV. A two-machine walkthrough, conflict resolution, what each provider can see. |
| docs/tomb.md | Hiding the store: backends, auto-close, crash recovery, syncing a closed tomb, and what the tomb does not protect. |
| docs/plugins.md | Writing plugins, the environment they receive, manifests and capabilities, and where the security boundary actually is. |
| docs/secret-service.md | Standing in for gnome-keyring: replacing it, how items are stored, the blind attribute index, and the access policy. |
| docs/macos.md | What works on macOS and what does not: the sparse bundle tomb, screen-lock auto-close, Keychain import and export, launchd, and what has never been run on a Mac. |
| docs/typing.md | Typing secrets instead of pasting them: tool selection per session, forcing a backend, menu --type and otp menu, the stdin-not-argv security model, and what the Nix package ships. |
| docs/tui.md | The TUI at CLI parity: editing and creating entries, binary attachments, generate/copy/type, OTP views, grep, audit, store status, themes, and the full key map. |
| ARCHITECTURE.md | The specification the implementation follows, including the parts not built yet. |
nix develop # go, gpg, pass, age, tree, rofi, fzf, linters
make test
make coverThe dev shell unsets any PASSWORD_STORE_* your own session exports, so tests
measure the code rather than the machine.
Run the suite against a plain Debian userland with the distro's own pass:
docker build -f Dockerfile.test -t binpass-test .
docker run --rm binpass-testMIT. See LICENSE.