Bitwarden 客户端 Direct Keeper 导入器深度解析:设备审批、2FA 流程与 Vault 解密管线
【免费下载链接】clientsBitwarden client apps (web, browser extension, desktop, and cli).项目地址: https://gitcode.com/GitHub_Trending/cl/clients
Direct Keeper importer 是 Bitwarden 客户端为 Keeper 用户提供的"无导出文件"迁移方案:用户只需输入 Keeper 账号邮箱与所在区域,Bitwarden 客户端便直接通过 Keeper API 完成设备注册、设备审批、2FA 验证、Vault 数据同步与解密,再转换为 Bitwarden 的ImportResult导入管线数据。本文以 关联文档 为核心骨架,结合libs/importer/src/importers/keeper/下的源码与测试,完整还原该功能的设计约束、交互流程、支持的认证方式与已知边界,帮助你理解"直接导入"与既有 CSV/JSON 导入在代码层面的真实差异。
一、功能概述:从"文件导入"到"直接连接导入"
Keeper 导入器在libs/importer/src/importers/keeper/目录下共有三套实现,对应三种数据来源:
keeper-csv-importer.ts:解析 Keeper 导出的 CSV 文件;keeper-json-importer.ts:解析 Keeper 导出的 JSON 文件;keeper-direct-importer.ts:不解析文件,而是直接通过 Keeper SDK 客户端 登录并同步 Keeper 账号,把同步下来的加密 Vault 解密后再转为 Bitwarden 数据结构。
keeper-direct-importer.ts定义了与通用导入管线对接的核心类型:
// Keeper 直接导入同时产出:给通用导入管线的 ImportResult + 供 Keeper UI 处理的逐条记录错误列表 export type KeeperDirectImportResult = { result: ImportResult; errors: ImportRecordError[]; };也就是说,直接导入的产物是一条ImportResult(可被后续导入流程继续使用),外加一份 Keeper 特有的ImportRecordError[],由 Keeper 专属 UI 在把结果交给导入管线之前先解析错误、向用户确认"部分导入"。
直接导入的适用平台
从 import-keeper.component.ts 的源码可以看到,直接导入只在桌面端(Desktop)与浏览器扩展(Browser)可用,Web 端与 CLI 端不支持:
// Direct import requires platform APIs (deep-linking, native window) that // only exist on desktop and the browser extension. CSV/Json work everywhere. private readonly directSupported = this.platformUtilsService.getClientType() === ClientType.Desktop || this.platformUtilsService.getClientType() === ClientType.Browser;由此,Keeper 导入方法下拉框在不同平台上呈现出不同的选项组合:
| 平台 | 可选方法 |
|---|---|
| Desktop / Browser | direct(直接导入)、csv、json |
| Web / CLI | csv、json |
并且桌面端与浏览器端默认选中direct,Web 端默认选中csv(见defaultKeeperImportMethod的实现)。CLI 端不提供 direct 方法,只会根据文件扩展名在 CSV/JSON 之间切换(详见下文"CLI 兼容行为")。
二、导入方法入口的变化:keepercsv / keeperjson 的保留与合并
原文档第一条评审要点描述了一次面向用户的入口调整,代码中也确实保留了兼容路径:
- Web 下拉框中
keepercsv/keeperjson两个独立入口被合并为一个keeper入口,旁边增加一个小下拉框用来选择方法(direct / CSV / JSON)。原有两个 ID 在代码中仍然保留。 - CLI 行为保持一致:
bw import keepercsv与bw import keeperjson依旧可用;同时 CLI 也接受统一的keeperID,并根据文件扩展名自动推断格式。
CLI 端的推断逻辑位于 apps/cli/src/tools/import.command.ts:
// The web UI exposes the Keeper method via a dropdown // The CLI infers it from the file extension when the user passes the unified `keeper` ID let resolvedFormat: ImportType = format; if (format === "keeper") { const lower = filepath.toLowerCase(); if (lower.endsWith(".csv")) { resolvedFormat = "keepercsv"; } else if (lower.endsWith(".json")) { resolvedFormat = "keeperjson"; } else { return Response.badRequest("Cannot determine Keeper file type. Use a .csv or .json file."); } }因此 CLI 使用方式为:
# 旧用法(仍然有效) bw import keepercsv /path/to/keeper-export.csv bw import keeperjson /path/to/keeper-export.json # 新用法:统一 ID,扩展名决定格式 bw import keeper /path/to/keeper-export.csv bw import keeper /path/to/keeper-export.json扩展:Web/扩展端/import路由在扩展中改为弹出窗口
原文档第二条评审要点涉及一个看似与 Keeper 无关、实则影响所有导入格式的改动:扩展端(所有平台)的/import路由现在以弹出窗口(popout)方式打开。此前 Windows 上/import停留在 popup 内部,而 macOS/Linux 已经是弹出窗口;本次改动统一了行为。
这样做的原因在文档中写得很清楚:direct 流程会运行一个 websocket 以及一批监听器(socket listener),如果页面还停留在 popup 中,用户只要一点击别处,popup 就会被销毁,websocket 与监听器随之被拆除,导入流程即告中断。以弹出窗口方式承载/import,可以保证长生命周期的认证交互稳定执行。该改动作用于所有导入格式,而非仅限 Keeper。
三、直接导入的完整执行链路
从源码看,直接导入的调用链为:ImportKeeperComponent.submitDirect()→KeeperDirectImportService.handleImport()→Vault.open()(登录 + SyncDown + 解密)→KeeperDirectImporter.convertVaultToImportResult()。
3.1 组件层:表单与提交
import-keeper.component.ts 的表单包含三个控件:
| 控件 | 校验 | 说明 |
|---|---|---|
method | 无 | direct/csv/json,变更时立即触发 UI 更新(updateOn: "change") |
email | required+email | 仅 direct 模式启用,切换为 csv/json 时自动禁用 |
region | 无 | 默认KeeperRegion.Us,可选全球 6 个区域 |
区域列表与 keeper-region.ts 一一对应:
| 枚举 | 对应服务域名 |
|---|---|
Us | keepersecurity.com |
Eu | keepersecurity.eu |
Au | keepersecurity.com.au |
Ca | keepersecurity.ca |
Jp | keepersecurity.jp |
UsGov | govcloud.keepersecurity.us |
提交时,如果method不是direct,submitDirect直接返回,让父组件走文件导入路径;否则用邮箱与区域调用KeeperDirectImportService.handleImport():
const { result, errors } = await this.keeperDirectImportService.handleImport( email.value!, this.formGroup.controls.region.value as KeeperRegion, organizationId, );拿到结果后,组件调用confirmPartialImport:如果存在逐条记录错误,则通过PartialImportDialogComponent弹出"部分导入"确认对话框,由用户决定是继续导入干净结果还是放弃(相关门控逻辑在 keeper-import-gate.ts)。
3.2 服务层:登录去重与 Vault 打开
keeper-direct-import.service.ts 是一个providedIn: "root"的 Injectable,负责组装ClientOptions并调用Vault.open:
const options: ClientOptions = { ui: this.keeperDirectImportUIService, region, }; this.inFlight = (async () => { try { const vault = await Vault.open(email, options); const importer = new KeeperDirectImporter(); if (organizationId !== undefined) { importer.organizationId = organizationId; } return importer.convertVaultToImportResult(vault); } finally { this.inFlight = undefined; this.keeperDirectImportUIService.reset(); } })();值得注意的实现细节:inFlight字段保存了进行中的 Promise,避免用户在导入过程中重复点击提交导致并发登录;finally中会重置 UI 服务状态,保证下一次导入从干净状态开始。
ClientOptions只有两个字段(见 client-options.ts):region(Keeper 区域)与ui(认证交互回调集合,见下文第四节)。
3.3 数据层:Vault 的解密管线
Vault.open(access/vault.ts)内部依次执行:
static async open(username: string, options: ClientOptions): Promise<Vault> { const client = new Client(options); const loginResult = await client.login(username); const pages = await client.syncDown(loginResult.sessionToken); const merged = Vault.mergeSyncDownPages(pages); return await Vault.processMergedSyncDownPages(merged, loginResult.dataKey); }整个解密流程在processMergedSyncDownPages中按严格依赖顺序执行,共 11 步,每一步都在 access/vault.ts 中有明确注释:
- 解密普通文件夹名:每个文件夹用各自的 folder key 加密,folder key 又用 master key 加密,这里只需要文件夹名;
- 解密共享文件夹的 key:共享文件夹有自己的 key,这些 key 后续用于解密共享文件夹内的记录;
- 解密共享文件夹名:共享文件夹名用共享文件夹 key 加密;
- 解密非共享记录 key:存在 record metadata 中,用 master key 加密;
- 解密共享记录 key:存在 shared folder records 中,用共享文件夹 key 加密;
- 解密关联(子)记录 key:存在 record links 中,用父记录 key 加密;由于父记录本身也可能是某个关联子记录,
decryptLinkedRecordKeys采用"迭代直至无可解析项"的算法(见 access/vault.ts); - 解密所有记录:汇总前 4-6 步的全部 key,逐条解密 record data;
- 解密共享文件夹子文件夹名;
- 构建完整文件夹路径:从
childToParent映射向上回溯,sanitizeFolderName会把路径中的\与/替换为-以规避路径歧义(见 access/vault.ts); - 为每条记录收集其全部文件夹路径(记录可同时属于普通文件夹、共享文件夹根与共享文件夹子文件夹);
- 组合成
VaultItem列表,并把解密失败的错误统一收集为VaultRecordError[]。
注意第 7 步中的版本判断:
if (record.version < 3) { errors.push({ id: uid, reason: VaultRecordErrorReason.UnsupportedVersion }); continue; }这就是原文档 TODO 中"Legacy RecordV2 格式不受支持,且没有测试数据"的代码出处——Keeper 早期 V2 记录会以UnsupportedVersion错误上报,而不是被静默丢弃。
SyncDown接口返回的数据可能分多页(hasMore/continuationToken),mergeSyncDownPages会把所有页面的字段逐项拼接成一个响应(见 access/vault.ts)。
3.4 转换层:KeeperDirectImporter 的记录映射
解密后的Vault交给KeeperDirectImporter.convertVaultToImportResult做最终转换(keeper-direct-importer.ts),流程为:解析记录 → 解析引用 → 映射 Vault 错误 → 组织态下把文件夹提升为集合 → 标记成功。
不支持的记录类型:Keeper 的file与photo记录本质是附件包装器,其字节内容只能通过单独的下载流程获取,而该流程当前导入器无法使用,因此被UNSUPPORTED_RECORD_TYPES集合标记并在解析时直接跳过、报告为UnsupportedType错误:
const UNSUPPORTED_RECORD_TYPES = new Set(["file", "photo"]);特殊记录类型的映射(parseRecord中的 switch,见 keeper-direct-importer.ts):
| Keeper 记录类型 | Bitwarden 转换结果 |
|---|---|
bankCard | CipherType.Card(卡号、有效期、安全码、持卡人姓名、PIN) |
driverLicense | CipherType.Identity(licenseNumber) |
ssnCard | CipherType.Identity(ssn) |
passport | CipherType.Identity(passportNumber) |
sshKeys | CipherType.SshKey,通过 SDK 的import_ssh_key校验密钥;失败则回退为安全笔记,passphrase 转为 Hidden 字段 |
| 其余类型 | 保持 Login 或按字段内容转为 SecureNote |
通用字段处理管线(importFields+importField):
- 数组字段(
tryImportArrayField):login(用户名)、password(密码)、oneTimeCode(TOTP,首个进入cipher.login.totp,其余转为 Hidden 字段)、url(多个 URI 全部进入login.uris); - 展开字段(
tryImportExpandingField):host(主机名 + 端口)、keyPair(公私钥)、securityQuestion(问题 + 答案)、appFiller(Keeper 内部字段,直接忽略); - 单值字段(
importSingleField):date/birthDate/expirationDate解析为本地化日期字符串;name、address、phone、bankAccount组装成可读文本;pinCode、secret以 Hidden 类型导入。
引用(Record Link)解析:Keeper 记录之间通过以Ref结尾的字段类型互相关联(如addressRef)。collectReferences收集所有引用,resolveReferences在所有记录解析完成后再解析引用(因为被引用的目标记录可能排在引用方之后),把目标记录对应字段的值复制到引用方字段中(见 keeper-direct-importer.ts)。测试中 "Amazon Account" 的address字段来自两条被引用地址记录,正是该机制的验证。
错误映射(mapVaultErrorReason):UnsupportedVersion→UnsupportedFeature,FolderDecryptionFailed→FolderDecryptionFailed,DecryptionFailed及其余 →Error。文件夹解密失败时记录本身仍会导入,只是失去文件夹归属(进入根目录)。
四、认证交互:设备审批、2FA 与 UI 回调
直接导入绕不开 Keeper 自身的账号安全流程。Client.login()(access/services/client.ts)是一个状态机驱动的登录过程,支持的状态包括:REQUIRES_AUTH_HASH、REGION_REDIRECT(区域跳转,会切换服务器地址并重新注册设备)、DEVICE_APPROVAL_REQUIRED、REQUIRES_DEVICE_ENCRYPTED_DATA_KEY、2FA 验证、Cloud SSO 等。
所有需要人机交互的环节都通过 ui/ui.ts 中定义的Ui接口回调给上层 UI,实现与协议逻辑解耦:
export interface Ui { // 设备审批 selectApprovalMethod(method: DeviceApprovalChannel[]): Promise<DeviceApprovalChannel | Cancel>; provideApprovalCode(method: DeviceApprovalChannel, options?): Promise<string | Cancel | Resend | TryAnother>; // 2FA selectTwoFactorMethod(channels: TwoFactorMethod[]): Promise<TwoFactorMethod | Cancel>; provideTwoFactorCode(method: TwoFactorMethod, options?): Promise<string | Cancel | Resend | TryAnother>; // DUO selectDuoMethod(methods: DuoMethod[], phoneNumber: string): Promise<DuoMethod | Cancel>; waitForDuoPush(method: DuoMethod): Promise<typeof Cancel | typeof TryAnother | void>; // Keeper DNA selectDnaMethod(methods: DnaMethod[]): Promise<DnaMethod | Cancel>; waitForDnaPush(): Promise<typeof Cancel | typeof TryAnother | void>; // Cloud SSO ssoLogin(url: string): Promise<string | Cancel>; // 密码提示(延迟到服务端要求时) promptForPassword(options?): Promise<string | Cancel>; // 错误展示 showError(message: string): Promise<void>; }4.1 设备审批渠道
device-approval-channel.ts 定义了四种审批渠道,原文档标记为全部已实现([x]):
| 枚举 | 含义 |
|---|---|
Email(1) | 邮箱链接点击 / 邮箱验证码 |
KeeperPush(2) | Keeper 推送 |
TwoFactor(3) | 通过 2FA 完成设备审批 |
AdminApproval(4) | 管理员审批 |
从login()的设备审批处理代码看,当前实际向用户提供的是前三种:Email、KeeperPush、TwoFactor(见 client.ts)。ProvideApprovalCodeOptions与ProvideTwoFactorCodeOptions还支持hidden(Keeper 在"设备审批走 2FA"流程中隐藏已配置的具体方法)、canResend(如 SMS 是否支持重发)与previousCodeRejected(服务端拒绝上一次验证码后的重试提示)等状态标识,用于驱动Resend/TryAnother交互。
4.2 2FA 方法支持矩阵
two-factor-method.ts 定义了 Keeper 的 2FA 方法枚举,与原文档勾选状态逐条对应:
| 枚举 | 含义 | 文档状态 |
|---|---|---|
Totp(1) | TOTP | ✅ |
Sms(2) | SMS 验证码 | ✅ |
Duo(3) | Duo(细分见下) | ✅ |
Rsa(4) | RSA SecurID | ❌ 未实现 |
Backup(5) | Backup code | ✅ |
U2f(6) | U2F | 未提及 |
WebAuthn(7) | WebAuthn | ⚠️ 部分([-]) |
KeeperPush(8) | Keeper Push | ✅ |
KeeperDna(9) | Keeper DNA(细分见下) | ✅ |
其中Duo在 duo-method.ts 中细分为四种方式,均已在Ui接口中提供对应回调:
| DuoMethod | 含义 | 文档状态 |
|---|---|---|
Push(1) | Duo/Push | ✅ |
Sms(2) | Duo/SMS | ✅ |
Voice(3) | Duo/Voice | ✅ |
Passcode(4) | Duo/Passcode | ✅ |
Keeper DNA在 dna-method.ts 中细分为:
| DnaMethod | 含义 | 文档状态 |
|---|---|---|
Push(1) | Keeper DNA Push | ✅ |
Code(2) | Keeper DNA Code | ✅ |
4.3 错误提示的 i18n 映射
原文档 TODO 中要求核对 getValidationErrorI18nKey 中的错误文案是否与真实错误对应。该方法的实际映射关系为:
| 错误类型 | i18n key |
|---|---|
KeeperAuthError+Cancelled | multifactorAuthenticationCancelled |
KeeperAuthError+MfaFailed | multifactorAuthenticationFailed |
KeeperAuthError+UnsupportedTwoFactorMethod | keeperUnsupported2faMethod |
KeeperAuthError+SocketError | keeperConnectionError |
| 其他 | errorOccurred |
错误码定义在 errors/keeper-auth-error.ts,UI 会在submitDirect的 catch 中把错误映射为表单级错误并标记邮箱控件 touched。原文档中"当用户只有不支持的 2FA 类型时,测试错误提示是否正常弹出"这一项已标记完成([x])。
五、原文档 TODO 的源码对照
原文档的 TODO 列表与代码逐条对应,便于理解当前实现边界:
| TODO | 源码线索 | 状态 |
|---|---|---|
| 空文件夹可能因"文件夹由记录添加"而被忽略 | keeper-direct-importer.ts:processFolder只在parseRecord循环中被调用,Vault 侧也只把"有记录的文件夹路径"收集进recordFolders | 未决 |
是否需要includeSharedFolders标志 | Client.syncDown的请求参数中可见 | 未决 |
| Bitwarden 中记录能否同时存在于多个文件夹 | 测试已覆盖"一条记录同时在两个文件夹"的场景(Production MySQL Database同时位于两个Development/...路径下) | 已通过测试验证可行 |
| Legacy RecordV2 不支持 | vault.ts:record.version < 3直接报UnsupportedVersion | 未实现(无测试数据) |
名称以Ref结尾的自定义字段是否会被忽略 | keeper-direct-importer.ts:importField对endsWith("Ref")的字段直接 return,只通过引用解析机制处理 | 未决 |
| 导入成功但仍有错误弹窗 | confirmPartialImport在无错误时直接返回true,不再弹窗 | 已解决 ✅ |
| 只有不支持的 2FA 类型时的表现 | keeperUnsupported2faMethod错误文案 +UnsupportedTwoFactorMethod错误码 | 已测试 ✅ |
六、测试验证:机器无关的断言与错误处理
直接导入器的测试位于 keeper-direct-importer.spec.ts(jest + node 环境)。它使用spec-data/keeper-direct/sync-down-fixture.json中 base64 编码的 SyncDown 响应与 master key 构造 Vault,再运行完整转换。
测试中有两个值得注意的做法:
- 钉住 locale 与 timezone:由于日期字段(
birthDate、expirationDate等)在生产环境中使用用户的 locale 与时区格式化,测试通过 mockDate.prototype.toLocaleString固定为en-US+UTC,保证断言与机器环境无关(见 keeper-direct-importer.spec.ts); - 错误处理粒度为"单条记录":
parseRecords对每条记录 try/catch,单条记录解析抛错只产生一条ImportRecordError,不会中断其余记录,且失败记录的文件夹不会被创建(见 keeper-direct-importer.spec.ts 的模拟测试)。
测试覆盖的记录类型包括:address、bankAccount、bankCard、birthCertificate、contact、databaseCredentials、driverLicense、encryptedNotes、general、healthInsurance、login(含 17 个字段的完整映射与引用解析)、membership、passport、serverCredentials、softwareLicense、sshKeys(含 passphrase 与非法密钥回退)、ssnCard;同时验证了file/photo记录被报告为UnsupportedType,以及三种 Vault 错误到ImportRecordErrorReason的映射。
七、底层协议基础设施:protobuf 与 SDK 风格客户端
access/目录下的协议层为直接导入提供了完整的 Keeper API 通信能力:
- access/proto/:10 个
.proto文件定义 Keeper API 消息类型(api-request、client、sync-down、record、push、ssocloud、enterprise、graph-sync、breachwatch、notification-center); - access/generated/:由 proto 文件通过
@bufbuild/protoc-gen-es生成的 TypeScript 代码,直接提交到仓库,常规开发无需重新生成;proto 变更后的重新生成命令见 proto/README.md:
cd libs/importer npx -p @bufbuild/protoc-gen-es protoc --es_out src/importers/keeper/access/generated --es_opt ts_nocheck=true,target=ts --proto_path src/importers/keeper/access/proto src/importers/keeper/access/proto/*.proto- access/services/:
http.ts(HTTP 传输)、crypto.ts(AES-ECB/AES-CBC、RSA-ECB、key 派生等)、keys.ts(KeeperKey 加解密)、socket.ts(Push Socket 连接,用于 Keeper Push / DNA 审批推送)、client.ts(登录状态机,clientVersion为"ts17.0.0")。
从目录结构与ClientOptions { region, ui }的设计可以看出,access/层是一套按 Keeper SDK 风格组织的迷你客户端:协议与 UI 完全解耦,任何上层(当前是 Bitwarden 导入器,未来也可能是其他调用方)只需实现Ui接口即可复用。
八、总结与实践建议
- 直接导入是"文件"之外的全新迁移路径:适用于 Desktop 与浏览器扩展端,Web 端与 CLI 端继续走 CSV/JSON。直接导入流程依赖长生命周期 websocket,因此扩展端
/import必须使用弹出窗口承载。 - CLI 的
keeper统一 ID 只是入口合并:底层仍是keepercsv/keeperjson两个 importer,按扩展名分派,bw import keepercsv/bw import keeperjson完全兼容。 - 认证覆盖全面:设备审批支持 Email(链接/验证码)、Keeper Push、经 2FA 审批;2FA 支持 SMS、TOTP、Duo(Push/SMS/Voice/Passcode)、Keeper DNA(Push/Code)与 Backup code;WebAuthn 与 RSA 尚未完整实现,仅 WebAuthn 有部分支持。
- 已知边界清晰:
file/photo记录不导入(报告为 UnsupportedType);RecordV2 解密失败(报告为 UnsupportedVersion);文件夹解密失败时记录仍导入但归入根目录;单条记录失败不影响整体导入(由 UI 弹"部分导入"确认框)。 - 阅读顺序建议:想了解交互与入口,看 import-keeper.component.ts 与 keeper-direct-import.service.ts;想了解解密与转换,看 vault.ts 与 keeper-direct-importer.ts;想了解认证枚举,看 access/enums/;想验证行为,直接运行 keeper-direct-importer.spec.ts。
【免费下载链接】clientsBitwarden client apps (web, browser extension, desktop, and cli).项目地址: https://gitcode.com/GitHub_Trending/cl/clients
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考