编程 Vaultwarden 深度解剖:用 Rust 把 Bitwarden 服务端压进十几 MB 内存——从零知识加密模型、Rocket 架构到 Diesel 多数据库落地的工程真相

2026-07-26 06:44:19 +0800 CST views 10

Vaultwarden 深度解剖:用 Rust 把 Bitwarden 服务端压进十几 MB 内存——从零知识加密模型、Rocket 架构到 Diesel 多数据库落地的工程真相

官方 Bitwarden 自建一套要跑 SQL Server、Identity、Api、Admin、Notifications、Icons 一大堆容器,内存动辄两三个 G。而 Vaultwarden 用一个 Rust 单体二进制,把整套 Bitwarden 协议重写了一遍,空载内存十几 MB,一个树莓派就能扛全家人的密码库。这篇文章不讲怎么 docker run,而是把它的加密模型、Web 框架选型、ORM 抽象、通知推送和管理后台一层层扒开,讲清楚它为什么能这么小、又为什么依然安全。

如果你只是想部署,网上一堆一键脚本。但如果你想搞明白"一个密码管理器的服务端到底在管什么"——答案可能会颠覆你的直觉:它几乎什么敏感信息都看不到。这正是 Vaultwarden 这类项目最迷人的地方,也是本文的主线。


一、背景:为什么会有 Vaultwarden

Bitwarden 是目前最流行的开源密码管理器之一。它的客户端(浏览器插件、桌面端、移动端、CLI)全部开源,协议也公开。但官方的服务端(bitwarden/server,C# + .NET)体量庞大,微服务拆得很细,对自建用户极不友好:

  • 依赖 Microsoft SQL Server(后来支持 PostgreSQL/MySQL,但历史包袱重)
  • 多个容器:apiidentitywebadminnotificationsiconsssomssql……
  • 官方自建版还阉割了付费功能(组织、TOTP、附件等)

于是社区里出现了 Vaultwarden(早期叫 bitwarden_rs,2021 年因商标问题改名)。它的定位非常清晰:

用 Rust 重新实现 Bitwarden 的服务端 API,做到与官方客户端 100% 兼容,但用单个轻量二进制承载全部功能(含付费功能),面向个人和小团队自建。

关键词有三个:协议兼容单体轻量功能齐全。这三点的取舍决定了它全部的架构选择。

一个直观对比:

维度官方 bitwarden/serverVaultwarden
语言C# / .NETRust
容器数8+1
空载内存~2 GB~15 MB
数据库MSSQL(主)/PG/MySQLSQLite / MySQL / PostgreSQL
付费功能需授权默认全开
目标人群企业个人 / 小团队

注意:Vaultwarden 不是官方项目,官方不提供支持,也不建议大型组织用它替代企业版。它是"够用、够小、够快"的自建方案。


二、核心概念:先搞懂"零知识加密",才看得懂这个服务端

要理解 Vaultwarden 的架构,必须先理解一件反直觉的事:服务端根本不知道你的密码,也解不开你的密码库。这套模型叫零知识(zero-knowledge)架构。整个 Vaultwarden 的代码,本质上是围绕"如何在看不见明文的前提下,正确地存取和同步加密数据块"来组织的。

2.1 主密码不上传,上传的是"派生哈希"

当你在客户端输入主密码(master password)时,客户端会做两次 KDF(密钥派生):

# 伪代码,展示 Bitwarden 客户端侧的密钥派生逻辑
master_key      = KDF(master_password, email_as_salt, iterations)   # 第一层:算出主密钥
master_pw_hash  = KDF(master_key, master_password, 1)               # 第二层:再派生一次,得到"密码哈希"
  • KDF 早期是 PBKDF2-SHA256(默认 60 万次迭代),新版本支持 Argon2id(抗 GPU/ASIC 更强)。
  • 上传到服务端的是 master_pw_hash,而不是主密码本身,也不是 master_key
  • 服务端拿到 master_pw_hash 后,还会再套一层服务端 KDF(PBKDF2)存进数据库,防止拖库后直接比对。

所以服务端存的是"哈希的哈希"。即使数据库被拖走,攻击者也拿不到主密码,更解不开保险库。

2.2 真正解密数据的密钥,服务端永远看不到

master_key 经过 HKDF 扩展后,用来加密/解密一个随机生成的对称密钥(叫 "user symmetric key" / "protected key"):

stretched_key    = HKDF-Expand(master_key)          # 拉伸成加密 + MAC 两段
user_symmetric_key = random(512 bit)                 # 注册时随机生成,一次性
protected_key    = AES-256-CBC-Encrypt(user_symmetric_key, stretched_key) + HMAC
  • 你保险库里每一条记录(登录项、笔记、卡片)都是用 user_symmetric_key 加密的。
  • user_symmetric_key 本身被 stretched_key 加密后,以 protected_key 的形式存在服务端。
  • 服务端存着 protected_key,但没有 master_key,所以永远解不开 user_symmetric_key,也就解不开任何一条数据

这就是零知识:服务端是个"加密数据块的存储和同步中心",而不是"能读你数据的中心"。

2.3 CipherString:贯穿全代码的密文格式

Bitwarden 里所有密文都遵循一个统一的字符串格式,Vaultwarden 里叫它 CipherString

<type>.<iv>|<ct>|<mac>
# 例如 AES-256-CBC + HMAC-SHA256:
2.abc123...==|def456...==|ghi789...==
  • type=0:AES-256-CBC(无 MAC,已废弃)
  • type=2:AES-256-CBC + HMAC-SHA256(主力)
  • type=4/6:RSA-2048-OAEP 系列(用于组织密钥分发)

关键点:Vaultwarden 服务端只把 CipherString 当成不透明的字符串来搬运和存储,从不解析里面的密文。 这大幅简化了服务端逻辑——它不需要任何加密库来处理保险库内容,只需要处理认证、路由、数据库读写。这也是它能做得这么小的根本原因之一。

理解了这三层,你就明白 Vaultwarden 服务端的"职责边界"了:认证 + 存取加密块 + 多端同步 + 组织密钥分发。它不碰明文。下面看它怎么用 Rust 把这些职责实现出来。


三、架构分析:一个 Rust 单体是怎么组织的

Vaultwarden 的技术栈可以一句话概括:

Rocket(Web 框架)+ Diesel(ORM)+ SQLite/MySQL/PostgreSQL(存储)+ 内嵌 web-vault(前端静态资源)+ WebSocket(实时通知),全部编译进一个静态二进制。

3.1 整体分层

┌─────────────────────────────────────────────┐
│  官方 Bitwarden 客户端(浏览器/桌面/移动/CLI)    │
└───────────────┬─────────────────────────────┘
                │ HTTPS (REST) + WebSocket
┌───────────────▼─────────────────────────────┐
│                Vaultwarden 二进制              │
│  ┌────────────┐  ┌──────────────┐             │
│  │  Rocket     │  │  web-vault   │  内嵌静态前端  │
│  │  路由/Fairing│  │ (官方前端产物) │             │
│  └─────┬──────┘  └──────────────┘             │
│  ┌─────▼──────────────────────────────────┐   │
│  │ api::core / api::identity / api::admin  │   │  业务逻辑
│  │ api::notifications (WebSocket)          │   │
│  └─────┬──────────────────────────────────┘   │
│  ┌─────▼──────────────────────────────────┐   │
│  │ db::models + Diesel(多后端宏抽象)        │   │  数据访问
│  └─────┬──────────────────────────────────┘   │
└────────┼──────────────────────────────────────┘
         ▼
   SQLite / MySQL / PostgreSQL

代码目录(src/ 下)大致对应:

  • api/core/ —— 保险库核心 API:ciphers、folders、organizations、sends、attachments……
  • api/identity.rs —— 登录、令牌、刷新、二步验证
  • api/admin.rs —— 管理后台(Vaultwarden 独有,官方前端没有)
  • api/notifications.rs —— WebSocket 实时推送
  • api/icons.rs —— 网站图标抓取与缓存
  • db/models/ + db/schemas/ —— 数据模型与三套数据库 schema
  • auth.rs —— JWT 签发与校验、各种请求守卫(Guard)
  • crypto.rs —— 服务端侧用到的少量加密(密码哈希、随机数、令牌)
  • config.rs —— 配置系统(环境变量 + 运行时可改)

3.2 为什么选 Rocket

Rocket 是 Rust 里"人体工学"最好的 Web 框架之一,靠宏和类型系统把很多校验前置到编译期。Vaultwarden 用得最狠的是它的**请求守卫(Request Guard)**机制——把认证、限流、配置注入都做成类型:

// 简化示意:一个需要登录的接口
#[post("/ciphers", data = "<data>")]
async fn post_ciphers(
    data: Json<CipherData>,   // 自动反序列化 + 校验
    headers: Headers,          // 自定义 Guard:解析并校验 JWT,注入当前用户
    mut conn: DbConn,          // 自定义 Guard:从连接池取一个数据库连接
) -> JsonResult {
    let user = headers.user;   // 走到这里,用户一定已经通过认证
    // ... 业务逻辑
}

Headers 这个 Guard 内部做的事:从 Authorization: Bearer <jwt> 取出令牌 → 验签 → 查用户 → 校验有效性 → 注入。任何一步失败,请求根本进不了函数体。这种"认证即类型"的写法,让业务代码几乎不用写重复的鉴权样板,也更难写出漏鉴权的 bug。

Rocket 的 Fairing(类似中间件)则被用来做全局的 CORS、请求日志、安全响应头等。

3.3 一个容易忽视的设计:内嵌前端

Vaultwarden 不自己写前端。它直接把官方开源的 web-vault(Bitwarden 网页版)构建产物打包进二进制或挂到静态目录。这样做的好处:

  • 前端行为与官方完全一致,升级只需换一版静态资源
  • 服务端专注 API,职责单一
  • 单二进制即可提供完整 Web 界面

代价是:web-vault 的版本要和 Vaultwarden 支持的 API 版本对齐,否则新前端调用了老服务端没实现的接口会报错。这也是升级时偶尔踩坑的点。


四、代码实战:几个关键机制的实现细节

光讲架构太虚,挑几个真正体现工程功力的点细看。

4.1 Diesel 多数据库:用宏消灭三倍代码

Vaultwarden 要同时支持 SQLite、MySQL、PostgreSQL。三套数据库的类型、方言、连接方式都不同。如果为每个模型写三遍 CRUD,维护成本爆炸。它的解法是用宏把差异收拢:

// 简化示意 Vaultwarden 的 db_object! 宏思路
db_object! {
    #[derive(Identifiable, Queryable, Insertable, AsChangeset)]
    #[diesel(table_name = ciphers)]
    pub struct Cipher {
        pub uuid: String,
        pub user_uuid: Option<String>,
        pub organization_uuid: Option<String>,
        pub atype: i32,
        pub data: String,        // 存 CipherString / JSON,服务端不解析
        pub favorite: bool,
        pub created_at: NaiveDateTime,
        pub updated_at: NaiveDateTime,
    }
}

db_object! 宏会为 SQLite / MySQL / PostgreSQL 各展开一套 Cipher 结构体和对应的 From 转换。业务层只用统一的 Cipher,运行时根据编译特性(feature flag)或连接类型分派到具体后端。

连接层则用一个枚举把三种连接池包起来:

// 概念示意:统一的数据库连接抽象
pub enum DbConnInner {
    Sqlite(SqliteConnection),
    Mysql(MysqlConnection),
    Postgresql(PgConnection),
}

// 查询时用宏统一分派
macro_rules! db_run {
    ($conn:ident: $body:block) => {
        match $conn {
            DbConnInner::Sqlite(c)     => { ... $body },
            DbConnInner::Mysql(c)      => { ... $body },
            DbConnInner::Postgresql(c) => { ... $body },
        }
    };
}

工程启示:当你要在同一套业务逻辑下适配多个后端时,与其写运行时的 if/else 抽象层,不如用宏在编译期展开——性能零损耗,类型全保留,还能让编译器帮你检查每个分支。这是 Rust 生态里非常典型的"零成本抽象"手法。

对个人自建,强烈建议用 SQLite:单文件、零运维、备份就是复制一个文件,配合前面提到的 Litestream 还能做到近实时增量备份到对象存储。只有多写并发压力大或已有数据库基建时,才需要 MySQL/PostgreSQL。

4.2 登录与 JWT:服务端在验什么

登录流程(api/identity.rs)大致是:

  1. 客户端用邮箱 + master_pw_hash 请求 /identity/connect/token
  2. 服务端取出该用户存的"服务端哈希",用 crypto.rs 里的验证函数比对
  3. 通过后,如启用二步验证(TOTP/WebAuthn/邮箱等),走二次校验
  4. 全部通过,签发 JWT(access token)+ refresh token

服务端密码校验的核心(简化):

// crypto.rs 思路:服务端再套一层 PBKDF2,用常量时间比较
pub fn verify_password_hash(
    input_hash: &[u8],       // 客户端上传的 master_pw_hash
    salt: &[u8],
    stored_hash: &[u8],      // 数据库里存的
    iterations: u32,
) -> bool {
    let computed = pbkdf2_hmac_sha256(input_hash, salt, iterations);
    // 常量时间比较,防时序攻击
    crypto::ct_eq(&computed, stored_hash)
}

两个细节值得学:

  • 再套一层服务端 KDF:客户端已经派生过了,服务端再派生一次存储,等于拖库后攻击者还要暴破一层。
  • 常量时间比较ct_eq):绝不能用 == 直接比哈希,否则字节逐位比较的提前返回会泄露信息,构成时序侧信道。

签发的 JWT 用服务端私钥(RSA,首次启动自动生成 rsa_key.pem)签名。之后每个请求的 Headers Guard 用公钥验签,无状态、不查库即可确认身份,这也是它能扛住多端频繁同步的原因。

4.3 实时同步:WebSocket 通知

多设备场景下,你在手机上改了一条密码,希望电脑插件立刻刷新。Bitwarden 协议用 WebSocket(SignalR 风格)推送变更事件。Vaultwarden 在 api/notifications.rs 里实现了这套:

// 概念示意:变更后向该用户的所有活跃连接广播
pub fn notify_cipher_update(
    ut: UpdateType,          // 枚举:SyncCipherUpdate / SyncCipherDelete ...
    cipher: &Cipher,
    users: &[String],        // 受影响用户
    ws: &WebSocketUsers,
) {
    for user_uuid in users {
        ws.send_update(user_uuid, ut, &cipher.uuid);
    }
}

推送的只是"某某对象变了"的信号,不含明文数据。客户端收到信号后,主动拉取加密块再本地解密。这再次呼应零知识原则:连推送通道都不经手明文。

值得一提:老版本 Vaultwarden 需要单独跑一个 WebSocket 端口 + 反代 /notifications/hub,新版本已内建,配置简化了不少。升级时如果发现"改了不实时同步",八成是反代没把 WebSocket 升级头(Upgrade/Connection)透传过去。

4.4 组织与密钥分发:RSA 上场的地方

个人保险库全靠对称密钥就够了。但"组织/共享集合"需要多人访问同一批数据,这时对称密钥怎么安全地发给每个成员?答案是 RSA 非对称加密:

  1. 每个用户注册时生成一对 RSA 密钥,私钥用 user_symmetric_key 加密后存服务端
  2. 创建组织时生成一个"组织对称密钥"
  3. 邀请成员时,用成员的 RSA 公钥加密"组织对称密钥",发给该成员
  4. 成员登录后,用自己的私钥解出组织密钥,再用它解组织里的数据

服务端全程只搬运这些密文(type=4/6 的 CipherString),它有公钥、有被加密的私钥,但没有解私钥的钥匙。密钥分发这么敏感的环节,服务端依然是零知识。这套设计的优雅之处在于:把"多人共享"这个看似需要服务端参与信任的问题,用非对称加密彻底下放到了客户端。


五、性能优化:它为什么能这么小、这么快

Vaultwarden 常被夸"树莓派都能跑",背后是一系列取舍:

5.1 内存小的根因

  • 不解析保险库内容:服务端把密文当字符串搬,省掉了所有加解密内存开销。
  • 无 JVM/CLR 运行时:Rust 编译成原生码,没有虚拟机常驻内存。
  • Rocket + Diesel 都是编译期展开:运行时对象图很小。
  • SQLite 默认:不用为独立数据库进程留内存。

实测空载常驻内存通常在 10~30 MB 区间,取决于连接池大小和图标缓存。对比官方版动辄 GB 级,差了两个数量级。

5.2 编译特性裁剪

Vaultwarden 用 Cargo feature 控制编译进哪些数据库后端。只用 SQLite 时,可以只编 sqlite feature,二进制更小、依赖更少:

# 只要 SQLite,构建时关掉其它后端
# cargo build --release --no-default-features --features sqlite
[features]
default = ["sqlite"]
sqlite = ["diesel/sqlite", "diesel_migrations/sqlite", "libsqlite3-sys"]
mysql = ["diesel/mysql", "diesel_migrations/mysql"]
postgresql = ["diesel/postgres", "diesel_migrations/postgres"]

5.3 图标缓存与外部请求节流

api/icons.rs 负责抓取网站 favicon 给保险库项目做图标。这是唯一会主动发外部请求的模块,也是最容易出性能/隐私问题的地方。Vaultwarden 的处理:

  • 本地磁盘缓存:抓过的图标存本地,设 TTL,避免重复请求。
  • 可完全关闭ICON_SERVICE / DISABLE_ICON_DOWNLOAD 等配置项让隐私敏感用户彻底断掉外联。
  • 超时与大小限制:防止恶意站点拖慢服务或塞大文件。

隐私建议:如果你不想让服务器因为你保存的某个网站而去访问它(可能暴露你用了哪些服务),把图标下载关掉,用内置 ICON_SERVICE=internal 或直接禁用。

5.4 连接池调优

Diesel 用 r2d2 做连接池。默认池大小对个人足够,但如果你上了 MySQL/PostgreSQL 且并发高,可以调 DATABASE_MAX_CONNS。SQLite 由于写是串行的(单写锁),池子调太大意义不大,反而可能触发 database is locked。这是自建时最常见的坑之一:SQLite 场景别盲目加大连接池


六、自建实战要点(不是教程,是避坑清单)

结合前面的原理,给几条真正基于架构理解的建议:

  1. 必须上 HTTPS。虽然保险库内容是端到端加密的,但登录时上传的 master_pw_hash、JWT 令牌都走 HTTP 明文会被截。反代(Caddy/Nginx/Traefik)加 TLS 是底线。

  2. WebSocket 头要透传。反代配置里务必转发 UpgradeConnection 头,否则实时同步失效。新版内建 WS,但反代仍需正确转发。

  3. 备份就是备份那个 SQLite 文件 + attachments 目录 + config.json + rsa_key.pem。特别是 rsa_key.pem——它是签 JWT 的私钥,丢了不影响数据但所有人要重新登录;config.json 存着运行时改的配置。

  4. 关掉开放注册SIGNUPS_ALLOWED=false,只留自己。或用 SIGNUPS_DOMAINS_WHITELIST 限定域名。管理后台(/admin,用 ADMIN_TOKEN 保护)可手动邀请。

  5. ADMIN_TOKEN 用哈希形式。新版支持存 Argon2 哈希而非明文,别把明文 token 写进 compose 文件裸奔。

  6. KDF 迭代别乱调低。为了"登录快点"把 PBKDF2 迭代数调很低,等于削弱了抗暴破能力。默认值(PBKDF2 60 万 / 或切 Argon2id)是安全与体验的平衡点。

  7. 理解它的边界:Vaultwarden 是个人/小团队方案。企业级审计、SSO/SCIM 深度集成、SLA 支持,还是得看官方。别拿它扛几百上千人的合规场景。


七、总结与展望

把 Vaultwarden 拆到这个程度,能提炼出几条超越"密码管理器"本身的工程思想:

  • 零知识不是口号,是架构约束。因为服务端从设计上就"看不懂"数据,所以它可以做得极简——不需要加密库处理内容、不需要复杂的权限模型去保护明文、连实时推送都只发信号不发数据。"能力越小,攻击面越小",这是安全系统最朴素也最有效的设计哲学。

  • 兼容既有协议,是四两拨千斤的杠杆。Vaultwarden 不造客户端、不造前端,只精准实现服务端 API,就复用了整个 Bitwarden 生态的客户端、审计和用户习惯。选对"实现哪一层",比"实现多少功能"重要得多。

  • Rust 的零成本抽象在多后端场景真香。用宏在编译期展开三套数据库代码,既没有运行时开销,又保留了完整类型检查——这是很多 GC 语言用运行时反射/动态分派换来的"灵活"所比不了的。

  • 单体不是落后,是对场景的诚实。微服务解决的是超大规模团队的协作和伸缩问题。对"一个家庭 / 一个小团队的密码库",单体二进制才是最优解:部署简单、资源极省、故障面小。架构没有高低,只有匹配。

展望上,随着 Argon2id 成为默认、passkey/WebAuthn 无密码登录普及,Vaultwarden 这类项目会越来越多地把重心从"存密码"转向"管凭据与身份"。但只要那条零知识的红线不变——服务端永远看不见你的秘密——它的核心架构就依然成立。

对我们这些写代码的人,Vaultwarden 最大的价值或许不是"省了几十块订阅费",而是它用一万多行 Rust,给我们演示了一个安全系统该有的样子:把信任降到最低,把明文挡在门外,把复杂留给客户端,把服务端做到小到无懈可击。


本文基于 Vaultwarden 公开代码结构与 Bitwarden 公开加密白皮书整理,代码片段为讲解用的简化示意而非逐行照搬,实际实现以对应版本源码为准。自建涉及安全配置,请以官方文档最新说明为准。

推荐文章

`Blob` 与 `File` 的关系
2025-05-11 23:45:58 +0800 CST
JavaScript 流程控制
2024-11-19 05:14:38 +0800 CST
Vue3中如何处理SEO优化?
2024-11-17 08:01:47 +0800 CST
js一键生成随机颜色:randomColor
2024-11-18 10:13:44 +0800 CST
Vue 中如何处理父子组件通信?
2024-11-17 04:35:13 +0800 CST
MCP 协议升级测试[不含附录]
2026-07-26 07:51:41 +0800 CST
程序员茄子在线接单