Bitwarden 客户端 Direct Keeper 导入器深度解析:设备审批、2FA 流程与 Vault 解密管线
2026/9/15 19:11:45 网站建设 项目流程

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 / Browserdirect(直接导入)、csvjson
Web / CLIcsvjson

并且桌面端与浏览器端默认选中direct,Web 端默认选中csv(见defaultKeeperImportMethod的实现)。CLI 端不提供 direct 方法,只会根据文件扩展名在 CSV/JSON 之间切换(详见下文"CLI 兼容行为")。

二、导入方法入口的变化:keepercsv / keeperjson 的保留与合并

原文档第一条评审要点描述了一次面向用户的入口调整,代码中也确实保留了兼容路径:

  1. Web 下拉框中keepercsv/keeperjson两个独立入口被合并为一个keeper入口,旁边增加一个小下拉框用来选择方法(direct / CSV / JSON)。原有两个 ID 在代码中仍然保留。
  2. CLI 行为保持一致bw import keepercsvbw 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 的表单包含三个控件:

控件校验说明
methoddirect/csv/json,变更时立即触发 UI 更新(updateOn: "change"
emailrequired+email仅 direct 模式启用,切换为 csv/json 时自动禁用
region默认KeeperRegion.Us,可选全球 6 个区域

区域列表与 keeper-region.ts 一一对应:

枚举对应服务域名
Uskeepersecurity.com
Eukeepersecurity.eu
Aukeepersecurity.com.au
Cakeepersecurity.ca
Jpkeepersecurity.jp
UsGovgovcloud.keepersecurity.us

提交时,如果method不是directsubmitDirect直接返回,让父组件走文件导入路径;否则用邮箱与区域调用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 中有明确注释:

  1. 解密普通文件夹名:每个文件夹用各自的 folder key 加密,folder key 又用 master key 加密,这里只需要文件夹名;
  2. 解密共享文件夹的 key:共享文件夹有自己的 key,这些 key 后续用于解密共享文件夹内的记录;
  3. 解密共享文件夹名:共享文件夹名用共享文件夹 key 加密;
  4. 解密非共享记录 key:存在 record metadata 中,用 master key 加密;
  5. 解密共享记录 key:存在 shared folder records 中,用共享文件夹 key 加密;
  6. 解密关联(子)记录 key:存在 record links 中,用父记录 key 加密;由于父记录本身也可能是某个关联子记录,decryptLinkedRecordKeys采用"迭代直至无可解析项"的算法(见 access/vault.ts);
  7. 解密所有记录:汇总前 4-6 步的全部 key,逐条解密 record data;
  8. 解密共享文件夹子文件夹名
  9. 构建完整文件夹路径:从childToParent映射向上回溯,sanitizeFolderName会把路径中的\/替换为-以规避路径歧义(见 access/vault.ts);
  10. 为每条记录收集其全部文件夹路径(记录可同时属于普通文件夹、共享文件夹根与共享文件夹子文件夹);
  11. 组合成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 的filephoto记录本质是附件包装器,其字节内容只能通过单独的下载流程获取,而该流程当前导入器无法使用,因此被UNSUPPORTED_RECORD_TYPES集合标记并在解析时直接跳过、报告为UnsupportedType错误:

const UNSUPPORTED_RECORD_TYPES = new Set(["file", "photo"]);

特殊记录类型的映射parseRecord中的 switch,见 keeper-direct-importer.ts):

Keeper 记录类型Bitwarden 转换结果
bankCardCipherType.Card(卡号、有效期、安全码、持卡人姓名、PIN)
driverLicenseCipherType.IdentitylicenseNumber
ssnCardCipherType.Identityssn
passportCipherType.IdentitypassportNumber
sshKeysCipherType.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解析为本地化日期字符串;nameaddressphonebankAccount组装成可读文本;pinCodesecret以 Hidden 类型导入。

引用(Record Link)解析:Keeper 记录之间通过以Ref结尾的字段类型互相关联(如addressRef)。collectReferences收集所有引用,resolveReferences在所有记录解析完成后再解析引用(因为被引用的目标记录可能排在引用方之后),把目标记录对应字段的值复制到引用方字段中(见 keeper-direct-importer.ts)。测试中 "Amazon Account" 的address字段来自两条被引用地址记录,正是该机制的验证。

错误映射mapVaultErrorReason):UnsupportedVersionUnsupportedFeatureFolderDecryptionFailedFolderDecryptionFailedDecryptionFailed及其余 →Error。文件夹解密失败时记录本身仍会导入,只是失去文件夹归属(进入根目录)。

四、认证交互:设备审批、2FA 与 UI 回调

直接导入绕不开 Keeper 自身的账号安全流程。Client.login()(access/services/client.ts)是一个状态机驱动的登录过程,支持的状态包括:REQUIRES_AUTH_HASHREGION_REDIRECT(区域跳转,会切换服务器地址并重新注册设备)、DEVICE_APPROVAL_REQUIREDREQUIRES_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()的设备审批处理代码看,当前实际向用户提供的是前三种:EmailKeeperPushTwoFactor(见 client.ts)。ProvideApprovalCodeOptionsProvideTwoFactorCodeOptions还支持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+CancelledmultifactorAuthenticationCancelled
KeeperAuthError+MfaFailedmultifactorAuthenticationFailed
KeeperAuthError+UnsupportedTwoFactorMethodkeeperUnsupported2faMethod
KeeperAuthError+SocketErrorkeeperConnectionError
其他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:importFieldendsWith("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,再运行完整转换。

测试中有两个值得注意的做法:

  1. 钉住 locale 与 timezone:由于日期字段(birthDateexpirationDate等)在生产环境中使用用户的 locale 与时区格式化,测试通过 mockDate.prototype.toLocaleString固定为en-US+UTC,保证断言与机器环境无关(见 keeper-direct-importer.spec.ts);
  2. 错误处理粒度为"单条记录"parseRecords对每条记录 try/catch,单条记录解析抛错只产生一条ImportRecordError,不会中断其余记录,且失败记录的文件夹不会被创建(见 keeper-direct-importer.spec.ts 的模拟测试)。

测试覆盖的记录类型包括:addressbankAccountbankCardbirthCertificatecontactdatabaseCredentialsdriverLicenseencryptedNotesgeneralhealthInsurancelogin(含 17 个字段的完整映射与引用解析)、membershippassportserverCredentialssoftwareLicensesshKeys(含 passphrase 与非法密钥回退)、ssnCard;同时验证了file/photo记录被报告为UnsupportedType,以及三种 Vault 错误到ImportRecordErrorReason的映射。

七、底层协议基础设施:protobuf 与 SDK 风格客户端

access/目录下的协议层为直接导入提供了完整的 Keeper API 通信能力:

  • access/proto/:10 个.proto文件定义 Keeper API 消息类型(api-requestclientsync-downrecordpushssocloudenterprisegraph-syncbreachwatchnotification-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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询