For AI/LLM: site guide at /llms.txt
rbw:替代官方bw客户端和secret-tool
Outline

rbw:替代官方bw客户端和secret-tool


󰈈 8min 2026/7/10󰚰 2026/7/10

bw官方的桌面端样式适配有限,相对于Web端除了一个SSH Agent,没啥新功能。bw-cli的认证方式复杂,根本不适合写脚本。这时候我偶然发现了rbw。 这个文章将会介绍rbw,以及介绍些我的踩坑经验。

Bitwarden 官方的桌面在Hyprland上有一个丑陋的顶栏,同时作为SSH Agent,输入完密码也并不会自动退出窗口,设计并不符合作为SSH Agent后台托管程序。

bw-cli的session管理则非常复杂,要使用bw-cli,根本不可能像secret-tool一样一行命令拉出来密码,使得这个cli根本没有写小脚本的潜力。

这时候我问AI问出来了一个叫rbw的软件。

rbw
2026/8/25
1.5k96161
Jesse Luehrs
avatar
unofficial bitwarden cli
1.5k96161

这是一个旨在创建一个易用的第三方Bitwardencli客户端。它确实做到了易用,它的工作方式类似ssh-agent,有登录状态,将密码写入到内存中,登录一次可以无障碍查询各种内容。 同时它还提供了SSH Agent功能。相对于bitwarden-desktoprbw的行为逻辑更像一个后台守护工具,也更适合承担SSH Agent这种后台任务。

当然rbw现在也有问题,它只支持主密码登录,不支持设置PIN。尽管有添加支持PINPR,但已经几个月没有音信了,合并时间感觉是遥遥无期了。

GitHub
antotocar34
avatarFeature Implementation: PIN#308

不过幸运的是它有个pinentry配置项,可以自行设置输入密码工具。我让AI Vibe来了一个支持PINpinentry,需要使用的话可以见下文支持PIN的pinentry

Nautilus等应用不一定使用设置到wm配置文件里的环境变量,这会导致它们无法密钥登录SFTP服务器。 在wm的配置文件设置完全局设置完环境变量后可以使用下面命令来更新环境变量,使这些应用可以使用SSH Agent

Shell
dbus-update-activation-environment --systemd --all

WARNING

有些wm配置文件不支持递归解析环境变量,直接复制文档的$XDG_RUNTIME_DIR/rbw/ssh-agent-socket可能不生效

这是一个简单的pinentry包装器。存储使用PIN值加密后的密码。 用法开头注释写的有,我不再说了

多亏有AI,不然300+行的代码,不写类型注释,我都不知道怎么写

WARNING

在录入完密码后你应该清除对应的shell历史记录,避免泄露密码

依赖:

  • Python 3.5+
  • openssl
  • pinentry

把下面的文件写进~/.local/bin/pinentry-vault,然后再加上运行权限。

Python
#!/usr/bin/env python3
"""
pinentry-vault -- PIN-protected credential store for pinentry.

Store passphrases encrypted with a short PIN.  When used as a pinentry
wrapper, gpg-agent calls pinentry-vault; the user enters a PIN instead
of the real passphrase, and pinentry-vault decrypts and returns the
actual passphrase.

USAGE:
  pinentry-vault --ls-key                     List all keys
  pinentry-vault <key> --delete               Delete a key
  pinentry-vault <key> --add <passwd> <pin>   Create/update a key
  pinentry-vault <key> [pinentry-args ...]    Pinentry wrapper mode

In wrapper mode the script is meant to be set as pinentry-program in
gpg-agent.conf:

  pinentry-program /home/USER/.local/bin/pinentry-vault mykey
"""

import argparse
import os
import subprocess
import sys


VAULT_DIR = os.path.expanduser("~/.local/share/pinentry-vault")
DEFAULT_PINENTRY = "pinentry"


# ---------------------------------------------------------------------------
#  Percent encoding / decoding  (Assuan protocol)
# ---------------------------------------------------------------------------

def percent_encode(s: str) -> str:
    """Percent-encode a string for use in an Assuan D-line."""
    out = []
    for ch in s:
        b = ord(ch)
        if ch == "%":
            out.append("%25")
        elif b < 0x20 or b > 0x7E:
            out.append("%{:02X}".format(b))
        else:
            out.append(ch)
    return "".join(out)


def percent_decode(s: str) -> str:
    """Decode an Assuan percent-encoded string."""
    out = []
    i = 0
    n = len(s)
    while i < n:
        if s[i] == "%" and i + 2 < n:
            try:
                out.append(chr(int(s[i + 1 : i + 3], 16)))
                i += 3
                continue
            except ValueError:
                pass
        out.append(s[i])
        i += 1
    return "".join(out)


# ---------------------------------------------------------------------------
#  Encryption / Decryption  (openssl)
# ---------------------------------------------------------------------------

def _run_openssl(args, input_data, pin):
    """Run openssl subprocess, passing PIN via environment variable."""
    env = os.environ.copy()
    env["VAULT_PIN"] = pin
    proc = subprocess.run(
        ["openssl"] + args,
        input=input_data.encode(),
        capture_output=True,
        env=env,
    )
    if proc.returncode != 0:
        return None
    return proc.stdout.decode()


_ENCRYPT_ARGS = ["enc", "-aes-256-cbc", "-pbkdf2", "-iter", "100000",
                 "-salt", "-a", "-pass", "env:VAULT_PIN"]
_DECRYPT_ARGS = ["enc", "-d", "-aes-256-cbc", "-pbkdf2", "-iter", "100000",
                 "-salt", "-a", "-pass", "env:VAULT_PIN"]


def encrypt(passwd: str, pin: str) -> str:
    """Encrypt *passwd* with *pin*, return base64-encoded ciphertext."""
    result = _run_openssl(_ENCRYPT_ARGS, passwd, pin)
    if result is None:
        sys.exit("error: encryption failed")
    return result


def decrypt(ciphertext: str, pin: str):
    """Decrypt *ciphertext* with *pin*, return passwd or None on failure."""
    return _run_openssl(_DECRYPT_ARGS, ciphertext, pin)


# ---------------------------------------------------------------------------
#  Key storage helpers
# ---------------------------------------------------------------------------

def key_path(key: str) -> str:
    return os.path.join(VAULT_DIR, key + ".enc")


def ensure_vault_dir():
    os.makedirs(VAULT_DIR, mode=0o700, exist_ok=True)


# ---------------------------------------------------------------------------
#  Commands
# ---------------------------------------------------------------------------

def cmd_ls_key():
    if not os.path.isdir(VAULT_DIR):
        return
    for name in sorted(os.listdir(VAULT_DIR)):
        if name.endswith(".enc"):
            print(name[:-4])


def cmd_delete(key: str):
    path = key_path(key)
    if os.path.isfile(path):
        os.remove(path)
        print("Deleted:", key)
    else:
        print("error: key not found:", key, file=sys.stderr)
        sys.exit(1)


def cmd_add(key: str, passwd: str, pin: str):
    ensure_vault_dir()
    encrypted = encrypt(passwd, pin)
    path = key_path(key)
    old_umask = os.umask(0o077)
    try:
        with open(path, "w") as f:
            f.write(encrypted)
    finally:
        os.umask(old_umask)
    print("Added:", key)


def cmd_wrapper(key: str, pinentry_args):
    """Pinentry wrapper mode: forward protocol, intercept GETPIN."""
    path = key_path(key)
    if not os.path.isfile(path):
        _assuan_err("83886179 No vault entry for '%s'" % key)
        sys.exit(0)

    with open(path) as f:
        encrypted = f.read()

    pinentry_bin = os.environ.get("VAULT_PINENTRY", DEFAULT_PINENTRY)
    proc = subprocess.Popen(
        [pinentry_bin] + pinentry_args,
        stdin=subprocess.PIPE,
        stdout=subprocess.PIPE,
        stderr=sys.stderr,
        text=True,
        bufsize=1,
    )

    try:
        _forward_greeting(proc)

        for line in sys.stdin:
            cmd = line.strip()
            if not cmd:
                continue

            parts = cmd.split(None, 1)
            cmd_name = parts[0].upper() if parts else ""

            if cmd_name == "GETPIN":
                _handle_getpin(proc, key, encrypted)
            elif cmd_name == "SETDESC":
                _forward_line(proc, cmd + " [vault: %s]" % key)
                _forward_response(proc)
            elif cmd_name == "BYE":
                _forward_line(proc, "BYE")
                _forward_response(proc)
                break
            else:
                _forward_line(proc, cmd)
                _forward_response(proc)
    except (BrokenPipeError, KeyboardInterrupt):
        pass
    finally:
        _kill_proc(proc)


# ---------------------------------------------------------------------------
#  Pinentry protocol helpers
# ---------------------------------------------------------------------------

def _forward_greeting(proc):
    """Read and forward pinentry's initial greeting line."""
    greeting = proc.stdout.readline()
    if greeting:
        sys.stdout.write(greeting)
        sys.stdout.flush()


def _forward_line(proc, text):
    """Send a single line to pinentry."""
    proc.stdin.write(text + "\n")
    proc.stdin.flush()


def _forward_response(proc):
    """Read pinentry response lines and forward them to stdout."""
    while True:
        line = proc.stdout.readline()
        if not line:
            break
        line = line.rstrip("\n")
        if line:
            sys.stdout.write(line + "\n")
            sys.stdout.flush()
        if line == "OK" or line.startswith("ERR"):
            break


def _handle_getpin(proc, key, encrypted):
    """Intercept GETPIN.

    Forward GETPIN to the real pinentry so the user can type their PIN.
    Then decrypt the vault entry with that PIN and return the real passwd.
    """
    _forward_line(proc, "GETPIN")

    pin = None
    status_lines = []

    while True:
        line = proc.stdout.readline()
        if not line:
            return
        line = line.rstrip("\n")

        if line.startswith("S "):
            status_lines.append(line)
        elif line.startswith("D "):
            pin = percent_decode(line[2:])
        elif line == "OK":
            break
        elif line.startswith("ERR"):
            sys.stdout.write(line + "\n")
            sys.stdout.flush()
            return

    if pin is None or pin == "":
        _assuan_err("83886179 No PIN entered")
        return

    passwd = decrypt(encrypted, pin)
    if passwd is None:
        _assuan_err("83886179 Bad PIN")
        return

    for s in status_lines:
        sys.stdout.write(s + "\n")

    sys.stdout.write("D " + percent_encode(passwd) + "\n")
    sys.stdout.write("OK\n")
    sys.stdout.flush()


def _assuan_err(msg):
    """Write an Assuan ERR response."""
    sys.stdout.write("ERR %s <Pinentry>\n" % msg)
    sys.stdout.flush()


def _kill_proc(proc):
    """Terminate pinentry subprocess cleanly."""
    try:
        proc.terminate()
        proc.wait(timeout=3)
    except Exception:
        try:
            proc.kill()
            proc.wait(timeout=3)
        except Exception:
            pass


# ---------------------------------------------------------------------------
#  Argument parsing
# ---------------------------------------------------------------------------

def main():
    parser = argparse.ArgumentParser(
        description="PIN-protected credential store for pinentry",
        add_help=False,
    )
    parser.add_argument("--help", action="store_true")
    parser.add_argument("--ls-key", action="store_true")
    parser.add_argument("--delete", action="store_true")
    parser.add_argument("--add", nargs=2, metavar=("PASSWD", "PIN"))
    parser.add_argument("key", nargs="?")

    args, unknown = parser.parse_known_args()

    if args.help:
        print(__doc__.strip())
        return

    if args.ls_key:
        cmd_ls_key()
        return

    if args.delete:
        if not args.key:
            sys.exit("error: KEY required for --delete")
        cmd_delete(args.key)
        return

    if args.add:
        if not args.key:
            sys.exit("error: KEY required for --add")
        passwd, pin = args.add
        cmd_add(args.key, passwd, pin)
        return

    if args.key:
        cmd_wrapper(args.key, unknown)
    else:
        print(__doc__.strip(), file=sys.stderr)


if __name__ == "__main__":
    main()

由于rbw内部启动pinentry用的是Command::new(pinentry_string),不使用shell,所以无法传参。 因此单纯上面脚本不能使用,还需要一个辅助脚本

把下面这个脚本写进~/.local/bin/rbw-pinentry

Shell
#!/bin/bash
exec /home/lee/.local/bin/pinentry-vault 你的key值 "$@"

然后设置把rbw-pinentry设置为pinentry就行了

INFO

本文章采用     CC BY-NC-SA 4.0 协议进行许可。如需转载,请注明出处。
查看原始Markdown
加载ing
c9287fa
Post.vue
utf-8
TOP
1:1