Vaultwarden密码库加固实战:审计与备份

适用场景

Vaultwarden 密码库加固这件事,通常是在团队把 Bitwarden 官方云换成自建之后才被认真对待的。典型场景有三类:一是数据合规要求凭据不能出境或不能放在第三方;二是运维团队需要在内网隔离环境里共享服务器密码、数据库口令、API Key;三是小团队想省掉按人订阅的费用,用一台 2C2G 的机器自建。

自建之后暴露面立刻变了。公网云服务商替你扛掉的部分——注册风控、暴力破解防护、审计留痕、备份——全都要自己补。默认配置的 Vaultwarden 允许任何人注册,管理后台只有一个弱口令,数据库就是一个 SQLite 文件躺在磁盘上。下面按「注册收敛、后台加固、审计留痕、一致性备份」四条线给出一套可以照着复制的配置。

前置条件

  • 一台 2 核 2G 以上 Linux 主机,Docker 与 Compose v2 已安装;
  • 一个已解析到该主机的域名,并配好 TLS 证书。浏览器 WebCrypto 只在 HTTPS 或 localhost 下可用,纯 HTTP 访问会直接报错;
  • Nginx 或 Caddy 作为反向代理,负责 443 终止与限速;
  • 一块独立备份空间,本地目录加对象存储各一份;
  • 管理员能拿到 docker 与 sqlite3 的执行权限。

原理说明

先明确一件事:Vaultwarden 服务端看不到你的明文密码。客户端用主密码经 KDF 派生出对称密钥,条目在浏览器或 App 里加密后才上传,服务端存的只是密文和主密码的验证哈希。这意味着加固重点不在「防服务端解密」,而在另外四件事:防止陌生人注册占用资源、防止账号被接管、防止数据库被整库拖走后离线爆破、以及出事后能说清谁在什么时候登录过。

数据库被拖走的风险常被低估。攻击者拿到 db.sqlite3 后,可以对每个账号的主密码做离线字典攻击,成本只取决于 KDF 迭代次数和主密码强度。Vaultwarden 的 PASSWORD_ITERATIONS 决定新账号默认的 PBKDF2 迭代次数,默认值约 60 万次,调到 60 万以上(或客户端选 Argon2id)能显著抬高爆破成本。注意服务端只能影响新建账号,老账号需要在客户端里手动调整 KDF 设置。

备份的口径也要说清。SQLite 默认开 WAL 模式,直接 cp db.sqlite3 可能拿到一个缺了最新事务的半成品,恢复时表现为「部分条目丢失」或直接报损坏。正确做法是用 SQLite 自带的备份 API,也就是 sqlite3 db ".backup 目标文件",它会加锁并产出一致性快照。

操作步骤

第一步:用 compose 固定版本并收敛监听

镜像必须写死版本号,不要用 latest。密码库这类服务一旦自动升级踩到不兼容变更,回滚成本很高。端口只绑回环,对外由 Nginx 终止 TLS。

# /opt/vaultwarden/docker-compose.yml
services:
  vaultwarden:
    image: vaultwarden/server:1.32.7
    container_name: vaultwarden
    restart: unless-stopped
    env_file: /opt/vaultwarden/vaultwarden.env
    volumes:
      - /opt/vaultwarden/data:/data
    ports:
      - "127.0.0.1:8080:80"
    logging:
      driver: json-file
      options:
        max-size: "20m"
        max-file: "5"

第二步:生成 argon2 管理令牌并写环境变量

ADMIN_TOKEN 支持明文,也支持 argon2 哈希串。用哈希的好处是配置文件泄露后攻击者拿不到后台口令。用官方镜像自带的 hash 子命令生成:

docker run --rm -it vaultwarden/server:1.32.7 /vaultwarden hash
# 按提示输入两次管理员密码,输出形如:
# $argon2id$v=19$m=65540,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG
# 把整行原样写入 ADMIN_TOKEN

环境变量文件内容如下。注意 $ 在 env_file 中不会被 Compose 插值,可以直接写;如果写在 compose 的 environment: 块里,则必须写成 $$,否则哈希串会被截断成空值,表现为后台一直提示令牌错误。

# /opt/vaultwarden/vaultwarden.env
DOMAIN=https://vault.example.com
SIGNUPS_ALLOWED=false
INVITATIONS_ALLOWED=true
SHOW_PASSWORD_HINT=false
PASSWORD_ITERATIONS=600000
ADMIN_TOKEN=$argon2id$v=19$m=65540,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG
LOG_LEVEL=warn
LOG_FILE=/data/vaultwarden.log
EXTENDED_LOGGING=true
IP_HEADER=X-Real-IP
SENDS_ALLOWED=false
WEB_VAULT_ENABLED=true

逐项说明几个关键开关。SIGNUPS_ALLOWED=false 关注册,这是自建密码库最重要的一条,开着等于给全网提供免费密码托管;INVITATIONS_ALLOWED=true 保留邀请通道,新同事仍能加入;SHOW_PASSWORD_HINT=false 关掉主密码提示,提示本身就是情报;IP_HEADER=X-Real-IP 让日志记录真实客户端 IP 而不是反代地址,审计时没有这一条等于白记。

第三步:Nginx 反代与后台路径限制

管理后台只需要内网访问,不要在公网开放 /admin。用两条 location 把边界划清,同时补上安全响应头并关掉 WebSocket 之外的无关方法。

server {
    listen 443 ssl http2;
    server_name vault.example.com;

    ssl_certificate     /etc/nginx/ssl/vault.crt;
    ssl_certificate_key /etc/nginx/ssl/vault.key;
    ssl_protocols       TLSv1.2 TLSv1.3;

    add_header Strict-Transport-Security "max-age=31536000" always;
    add_header X-Content-Type-Options nosniff always;
    add_header X-Frame-Options DENY always;
    add_header Referrer-Policy no-referrer always;

    client_max_body_size 525m;      # 与附件上限保持一致

    # 管理后台仅内网可访问
    location /admin {
        allow 10.0.0.0/8;
        allow 192.168.0.0/16;
        deny all;
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # 登录接口单独限速,压制撞库
    location /identity/connect/token {
        limit_req zone=vw_login burst=5 nodelay;
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

limit_req 依赖 http 块里的共享内存声明,没有它 Nginx 启动会报 unknown limit_req zone:

# /etc/nginx/nginx.conf 的 http 块内
limit_req_zone $binary_remote_addr zone=vw_login:10m rate=10r/m;

第四步:操作审计

审计分两头看:应用侧看谁在什么时候登录、新设备接入;代理侧看登录接口的请求来源分布。应用侧最直接的是查询数据库,新设备接入会写进 devices 表:

sqlite3 /opt/vaultwarden/data/db.sqlite3 \
  "select u.email,
          datetime(d.created_at,'localtime') as login_time,
          d.name,
          d.atype
     from devices d join users u on u.uuid = d.user_uuid
    order by d.created_at desc
    limit 30;"

账号与 KDF 现状也一并查出来,用来找出迭代次数偏低的老账号:

sqlite3 /opt/vaultwarden/data/db.sqlite3 \
  "select email,
          datetime(created_at,'localtime') as created,
          client_kdf_iter
     from users
    order by created_at desc;"

代理侧统计登录来源,突然冒出的境外 IP 或单一 IP 的高频失败就是信号:

# 成功登录的来源分布
awk '$7 ~ /identity\/connect\/token/ && $9 == 200 {print $1}' \
  /var/log/nginx/vault.access.log | sort | uniq -c | sort -rn | head -20

# 401 次数最多的来源,撞库特征
awk '$7 ~ /identity\/connect\/token/ && $9 == 401 {print $1}' \
  /var/log/nginx/vault.access.log | sort | uniq -c | sort -rn | head -20

新设备邮件通知也建议开启,Vaultwarden 在检测到新设备登录时会给账号邮箱发提醒。前提是配好 SMTP,否则通知发不出去,等于没开。

第五步:一致性备份与恢复演练

备份三件套:数据库用备份 API、附件用归档、产物同步到对象存储。附件目录与数据库必须同一次快照,否则恢复后会出现条目存在但附件 404。

#!/usr/bin/env bash
set -euo pipefail
D=/opt/vaultwarden/data
B=/backup/vaultwarden
TS=$(date +%F)

mkdir -p "$B"
sqlite3 "$D/db.sqlite3" ".backup '$B/db-$TS.sqlite3'"
tar czf "$B/attachments-$TS.tar.gz" -C "$D" attachments sends 2>/dev/null || true
find "$B" -name 'db-*.sqlite3' -mtime +30 -delete

rclone copy "$B" remote:vaultwarden-backup --include "*-$TS.*"
echo "backup done: $TS"

备份做完必须演练。把备份文件拷到一台测试机,起一个临时容器挂上去,能正常登录并看到条目才算合格:

mkdir -p /tmp/vwtest/data
cp /backup/vaultwarden/db-2026-11-05.sqlite3 /tmp/vwtest/data/db.sqlite3
docker run --rm -d --name vwtest -p 127.0.0.1:8081:80 \
  -v /tmp/vwtest/data:/data vaultwarden/server:1.32.7
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8081/
# 期望 200,再用浏览器登录确认条目数量与最近修改时间对得上

配置验证

# 1. 容器状态与版本
docker compose -f /opt/vaultwarden/docker-compose.yml ps

# 2. 注册已被关闭:应返回 400 或明确提示注册关闭
curl -s -o /dev/null -w "%{http_code}\n" -X POST \
  https://vault.example.com/identity/accounts/register

# 3. 管理后台从公网应 403
curl -s -o /dev/null -w "%{http_code}\n" https://vault.example.com/admin/

# 4. 安全响应头齐全
curl -sI https://vault.example.com/ | grep -Ei 'strict-transport|x-content-type|x-frame|referrer'

# 5. 真实 IP 已透传:日志里不应全是 127.0.0.1
tail -5 /opt/vaultwarden/data/vaultwarden.log

常见问题

Q1:自建之后,服务端管理员能看到用户的密码吗?

看不到。条目在客户端用主密码派生的密钥加密后才上传,服务端只存密文。但有三类元数据是可见的:账号邮箱、条目数量与类型、登录 IP 与设备信息。另外如果启用了 Send 的文本类型并选择「不加密」,那段文本会以明文落库,敏感内容不要走这条路。

Q2:数据库被整库拖走,风险到底有多大?

取决于主密码强度和 KDF 迭代次数,两者是乘法关系。迭代 60 万次的 PBKDF2 下,单个候选口令的验证成本约零点几秒,弱口令几小时内就会被跑出来,12 位以上的随机口令则基本不可行。所以三件事要同时做:PASSWORD_ITERATIONS 不低于 60 万、强制用户开二次验证、数据库文件权限收紧到 600 并禁止任何公网可达路径。

Q3:为什么不能直接复制 db.sqlite3 做备份?

SQLite 默认开启 WAL 模式,最新写入先落在 db.sqlite3-wal 里。文件拷贝的过程中如果有写入,拷到的可能是缺事务的中间状态,恢复后表现为部分条目丢失或直接报 malformed。用 .backup 子命令或备份 API,它会加读锁并把 WAL 合并进快照,产出的文件才是自洽的。

Q4:关掉注册后,新同事怎么开账号?

管理员在后台生成邀请,或者直接发邀请链接。这要求配好 SMTP,否则邀请邮件发不出去。临时开放注册时不要只改 SIGNUPS_ALLOWED,同时设置 SIGNUPS_DOMAINS_WHITELIST=yourcompany.com,把范围压到企业邮箱域,开完立刻改回来。千万别长期挂着开放注册,扫描器对这类实例的探测是持续性的。

总结

自建密码库的安全水位不由软件决定,而由四件运营动作决定:关注册、护后台、留审计、做可恢复备份。落地顺序建议是先关注册再改后台令牌,最后补备份与演练,因为前两步几分钟就能完成,收益最大。版本号务必写死,升级前先在测试实例上验证客户端兼容性。最后一句提醒:备份文件本身就是最高价值目标,它和数据库一样需要加密存放与访问控制,别把备份放在同一台机器同一个磁盘上。