科技 · 实用指南
Bitwarden 显示导出成功?删除密码库前先审计 ZIP 附件
本地审计可为Bitwarden ZIP中已有文件建立清单与摘要,但归档本身不能证明服务器全部附件都已导出。离线检查结构问题,与另存的独立基线比较,并明确何时必须继续保留来源、暂停删除或迁移。

不要因为应用显示“成功”就删除 Bitwarden 账户、移除组织密码库或清空源设备。先确认 ZIP 确实落盘,复制一个工作副本,并在本地离线审计。最可靠的结果不是一盏“可以删除”的绿灯,而是一份能与独立预期清单对照的记录。
已核读的客户端代码会写入data.json和attachments/,但导出的JSON项目模型没有附件元数据。本地审计可为已有成员建立清单和字节摘要;ZIP本身无法确定某个缺失的服务器附件是否原本应当出现。
1. 先冻结删除动作
保留源密码库和账户。把下载的 ZIP 复制到本地工作目录;条件允许时让该目录脱离云同步,也绝不要把压缩包上传到在线 ZIP 检查网站。未加密的导出可能包含密码、备注、身份资料和附件内容;data.json 以及由它生成的报告都应按敏感数据保护。
先确认文件存在并记录大小。如果客户端提示成功却没有生成文件,应立即停止:此时没有可审计的对象。在源数据仍可用时,通过官方客户端路径重新导出或解决客户端问题。提示框不是文件已安全写入磁盘的证据。
2. 了解当前 ZIP 写入逻辑
本次核读的Bitwarden clients提交df9641824436b65207f393d35b8d07225129797b中,个人ZIP写入器加入data.json与attachments/,收集附件时排除已删除和组织所有的项目。它替换危险名称字符并合并连续下划线;项目目录计数器以原项目名称为键,附件计数器以清洗后的基础文件名为键。
重复名称可能获得数字后缀,所以Personal_1或invoice_1.pdf并不自动表示错误。但这些计数器不是无冲突证明:不同原名称可能清洗成同一目录,生成的后缀也可能与另一个已有文件名重叠。应核实际字节和独立基线,不把历史碰撞报告泛化到每个版本,也不宣称后缀消除了全部碰撞。
3. 对副本运行审计
将下面完整的标准库代码保存为bitwarden_zip_audit.py,放在稳定的工作副本旁。运行python3 bitwarden_zip_audit.py bitwarden_export.zip --output bitwarden-audit.json。它不解压成员、不修改ZIP,拒绝最终路径组件是符号链接的来源,也拒绝覆盖报告。POSIX下新报告采用0600权限;Windows访问取决于其文件权限,本次未验证。
审计要求恰好一个顶层data.json,明确按UTF-8解码,拒绝重复JSON属性名,要求encrypted=false及由项目对象组成的items数组。它记录不安全的原路径、重复ZIP名称、大小写折叠冲突、零字节附件和歧义项目目录;加密或非普通成员不受支持,大小及压缩比超限会在读取内容前拒绝。
每个已有附件都记录精确路径、解压和压缩大小、CRC32与SHA-256,并记录整个来源ZIP的摘要。报告放在归档之外,保护其中的项目名与文件名,检查期间保持两个输入副本稳定。
限制为50,000个ZIP成员、声明展开总量20 GiB、单成员4 GiB、data.json 100 MiB、基线16 MiB及50,000行基线。成员大于1 MiB且压缩比超过1,000会被拒绝,附件数据流也有4 GiB读取上限。ZIP目录解析和整个压缩文件摘要计算在部分限制之前发生;这不是恶意档案沙箱,也不是通用CPU或内存上限保证。
```python #!/usr/bin/env python3 """Read-only structural and integrity audit for Bitwarden ZIP exports. The report deliberately does not claim attachment completeness: current Bitwarden data.json exports do not include attachment metadata. It inventories what is in the archive, validates bounded structure, and highlights conditions that require a separate pre-export manifest or manual verification. """ from __future__ import annotations import argparse import hashlib import json import os import re import stat import sys import zipfile import zlib from collections import Counter, defaultdict from pathlib import Path, PurePosixPath MAX_MEMBERS = 50_000 MAX_UNCOMPRESSED = 20 * 1024**3 MAX_MEMBER = 4 * 1024**3 MAX_RATIO = 1_000 class AuditError(Exception): pass def sha256_stream(stream, limit=None) -> str: digest = hashlib.sha256() count = 0 for chunk in iter(lambda: stream.read(1024 * 1024), b""): count += len(chunk) if limit is not None and count > limit: raise AuditError("member stream exceeds the selected read limit") digest.update(chunk) return digest.hexdigest() def safe_member_name(name: str) -> bool: if not name or "\\" in name or "\x00" in name or name.startswith("/") or re.match(r"^[A-Za-z]:", name): return False return all(part not in ("", ".", "..") for part in name.split("/")) def unique_object(pairs): value = {} for key, item in pairs: if key in value: raise AuditError("duplicate JSON property is outside the audit scope") value[key] = item return value def sanitize_cipher_name(name: str) -> str: # Mirrors the current Bitwarden client ZIP implementation. return re.sub(r"_+", "_", re.sub(r'[/\\><:"|?*]', "_", name)) def refuse_unsafe_path(path: Path, role: str, must_exist: bool) -> Path: if path.is_symlink(): raise AuditError(f"{role} must not be a symlink: {path}") resolved = path.resolve(strict=must_exist) if must_exist and not resolved.is_file(): raise AuditError(f"{role} is not a regular file: {path}") if must_exist and not stat.S_ISREG(resolved.stat().st_mode): raise AuditError(f"{role} is not a regular file: {path}") return resolved def load_expected_manifest(path: Path) -> dict[str, dict]: path = refuse_unsafe_path(path, "expected manifest", True) if path.stat().st_size > 16 * 1024**2: raise AuditError("expected manifest exceeds the 16 MiB limit") try: document = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_object) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise AuditError(f"expected manifest is not valid UTF-8 JSON: {exc}") from exc if not isinstance(document, dict) or type(document.get("schemaVersion")) is not int or document.get("schemaVersion") != 1: raise AuditError("unsupported expected manifest: schemaVersion must be 1") rows = document.get("attachments") if not isinstance(rows, list): raise AuditError("unsupported expected manifest: attachments must be an array") if len(rows) > MAX_MEMBERS: raise AuditError("expected manifest has too many rows") expected = {} for index, row in enumerate(rows): if not isinstance(row, dict) or not isinstance(row.get("path"), str): raise AuditError(f"expected manifest row {index} requires a string path") member = row["path"] if not member.startswith("attachments/") or not safe_member_name(member): raise AuditError(f"expected manifest row {index} has an unsafe attachment path") if member in expected: raise AuditError(f"expected manifest repeats path: {member}") if "size" in row and (type(row["size"]) is not int or row["size"] < 0): raise AuditError(f"expected manifest row {index} has an invalid size") if "sha256" in row and not re.fullmatch(r"[0-9a-f]{64}", str(row["sha256"])): raise AuditError(f"expected manifest row {index} has an invalid sha256") expected[member] = row return expected def audit_zip(source: Path, expected_manifest: Path | None = None) -> dict: source = refuse_unsafe_path(source, "source ZIP", True) if not zipfile.is_zipfile(source): raise AuditError("source is not a readable ZIP archive") with source.open("rb") as archive_stream: archive_hash = sha256_stream(archive_stream) findings: list[dict] = [] members: list[dict] = [] with zipfile.ZipFile(source) as zf: infos = zf.infolist() if len(infos) > MAX_MEMBERS: raise AuditError(f"archive has {len(infos)} members; limit is {MAX_MEMBERS}") raw_counts = Counter(info.filename for info in infos) duplicates = sorted(name for name, count in raw_counts.items() if count > 1) if duplicates: findings.append({"severity": "error", "code": "duplicate-member-name", "members": duplicates}) casefold_groups: dict[str, list[str]] = defaultdict(list) total_uncompressed = 0 data_infos = [] attachment_files = [] for info in infos: name = info.filename casefold_groups[name.casefold()].append(name) if not safe_member_name(name[:-1] if info.is_dir() else name): findings.append({"severity": "error", "code": "unsafe-member-path", "member": name}) if info.flag_bits & 0x1: raise AuditError("encrypted ZIP members are outside scope") mode = stat.S_IFMT(info.external_attr >> 16) if not info.is_dir() and mode not in (0, stat.S_IFREG): raise AuditError("non-regular ZIP member is outside scope") total_uncompressed += info.file_size if info.file_size > MAX_MEMBER: raise AuditError("member exceeds the 4 GiB limit") ratio = info.file_size / max(1, info.compress_size) if ratio > MAX_RATIO and info.file_size > 1024 * 1024: raise AuditError("member exceeds the selected compression-ratio scope") if name == "data.json": data_infos.append(info) if name.startswith("attachments/") and not info.is_dir(): attachment_files.append(info) if total_uncompressed > MAX_UNCOMPRESSED: raise AuditError(f"archive expands to {total_uncompressed} bytes; limit is {MAX_UNCOMPRESSED}") collisions = [sorted(set(names)) for names in casefold_groups.values() if len(set(names)) > 1] if collisions: findings.append({"severity": "warning", "code": "casefold-name-collision", "groups": collisions}) if len(data_infos) != 1: raise AuditError(f"expected exactly one top-level data.json; found {len(data_infos)}") data_info = data_infos[0] if data_info.file_size > 100 * 1024**2: raise AuditError("data.json exceeds the 100 MiB audit limit") try: with zf.open(data_info) as stream: raw = stream.read(100 * 1024**2 + 1) if len(raw) > 100 * 1024**2: raise AuditError("data.json stream exceeds the 100 MiB limit") document = json.loads(raw.decode("utf-8"), object_pairs_hook=unique_object) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise AuditError(f"data.json is not valid UTF-8 JSON: {exc}") from exc if not isinstance(document, dict) or not isinstance(document.get("items"), list): raise AuditError("unsupported data.json schema: expected an object with an items array") if document.get("encrypted") is not False: raise AuditError("unsupported data.json schema: ZIP audit expects encrypted=false") if any(not isinstance(item, dict) for item in document["items"]): raise AuditError("items must contain objects") item_names = [item.get("name", "") for item in document["items"] if isinstance(item, dict)] expected_folders: dict[str, list[str]] = defaultdict(list) for item_name in item_names: if isinstance(item_name, str): expected_folders[sanitize_cipher_name(item_name)].append(item_name) folders: dict[str, list[zipfile.ZipInfo]] = defaultdict(list) for info in attachment_files: parts = PurePosixPath(info.filename).parts if len(parts) < 3: findings.append({"severity": "warning", "code": "unexpected-attachment-path", "member": info.filename}) continue folders[parts[1]].append(info) for folder in sorted(folders): base = re.sub(r"_\d+$", "", folder) candidates = expected_folders.get(folder, []) + (expected_folders.get(base, []) if base != folder else []) possible = sorted(set(candidates)) if not possible: findings.append({"severity": "warning", "code": "unmapped-attachment-folder", "folder": folder}) elif len(candidates) > 1: findings.append({"severity": "warning", "code": "ambiguous-attachment-folder", "folder": folder, "possibleItems": possible}) for info in attachment_files: with zf.open(info) as stream: member_hash = sha256_stream(stream, MAX_MEMBER) entry = { "path": info.filename, "size": info.file_size, "compressedSize": info.compress_size, "crc32": f"{info.CRC:08x}", "sha256": member_hash, } members.append(entry) if info.file_size == 0: findings.append({"severity": "warning", "code": "zero-byte-attachment", "member": info.filename}) baseline = None if expected_manifest is not None: expected = load_expected_manifest(expected_manifest) actual = {entry["path"]: entry for entry in members} missing = sorted(set(expected) - set(actual)) unexpected = sorted(set(actual) - set(expected)) mismatches = [] for path in sorted(set(expected) & set(actual)): wanted, present = expected[path], actual[path] if "size" in wanted and wanted["size"] != present["size"]: mismatches.append({"path": path, "field": "size", "expected": wanted["size"], "actual": present["size"]}) if "sha256" in wanted and wanted["sha256"] != present["sha256"]: mismatches.append({"path": path, "field": "sha256", "expected": wanted["sha256"], "actual": present["sha256"]}) if missing: findings.append({"severity": "error", "code": "baseline-missing-attachments", "members": missing}) if unexpected: findings.append({"severity": "warning", "code": "baseline-unexpected-attachments", "members": unexpected}) if mismatches: findings.append({"severity": "error", "code": "baseline-mismatch", "mismatches": mismatches}) baseline = { "name": expected_manifest.name, "entries": len(expected), "matched": len(set(expected) & set(actual)) - len({m["path"] for m in mismatches}), "missing": len(missing), "unexpected": len(unexpected), "mismatches": len(mismatches), "provenanceWarning": "This comparison proves equality only to the supplied manifest, not to live Bitwarden server state.", } findings.append({ "severity": "info", "code": "completeness-not-provable-from-zip-alone", "detail": "Current data.json exports do not carry attachment metadata. Compare this inventory with a separately captured pre-export manifest before any destructive action.", }) status = "fail" if any(f["severity"] == "error" for f in findings) else "review" return { "schemaVersion": 1, "status": status, "source": {"name": source.name, "bytes": source.stat().st_size, "sha256": archive_hash}, "scope": { "proves": ["ZIP readability", "bounded member structure", "attachment member byte hashes", "data.json parseability"], "doesNotProve": ["every server-side attachment was exported", "organization-vault coverage", "restore success"], }, "summary": {"itemsInDataJson": len(item_names), "attachmentFiles": len(members), "attachmentFolders": len(folders)}, "baseline": baseline, "findings": findings, "attachments": sorted(members, key=lambda item: item["path"]), } def write_report(report: dict, destination: Path) -> None: if destination.exists() or destination.is_symlink(): raise AuditError(f"refusing to overwrite output: {destination}") parent = destination.parent.resolve(strict=True) if not parent.is_dir(): raise AuditError(f"output parent is not a directory: {parent}") payload = (json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode() flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL fd = os.open(destination, flags, 0o600) with os.fdopen(fd, "wb") as stream: stream.write(payload) def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("zip", type=Path) parser.add_argument("--output", required=True, type=Path) parser.add_argument("--expected-manifest", type=Path, help="optional schemaVersion=1 JSON baseline of attachment paths, sizes and/or SHA-256 values") args = parser.parse_args(argv) try: source = refuse_unsafe_path(args.zip, "source ZIP", True) output = refuse_unsafe_path(args.output, "output", False) if source == output: raise AuditError("output must not replace the source ZIP") manifest = args.expected_manifest if manifest is not None: manifest = refuse_unsafe_path(manifest, "expected manifest", True) write_report(audit_zip(source, manifest), output) except (AuditError, OSError, ValueError, RuntimeError, EOFError, zlib.error, zipfile.BadZipFile, NotImplementedError) as exc: print(f"audit failed: {exc}", file=sys.stderr) return 2 return 0 if __name__ == "__main__": raise SystemExit(main()) ```
命令成功写出报告便退出0,即使报告status为fail。必须读status与每条finding;0不批准删除。退出2表示无法完成核验或报告。报告缺失、程序中断或结果不清楚时,都保留来源。
4. 正确理解状态
fail 表示至少一个结构错误或基准错误尚未解释。不要删除源数据,也不要继续迁移。重复原始路径、危险路径、本流程不支持的加密成员、基准中缺失的附件或大小/哈希不符,都是硬性停止条件。
结构可读时出现 review 很正常,但警告仍需人工判断。零字节文件可能合法,却应从安全副本打开核对;大小写碰撞在 Windows、macOS 和 Linux 上的表现可能不同;未映射或歧义目录可能源自名称清洗或项目同名;意外成员既可能是新附件,也可能说明基准已过时。
脚本不会直接输出data.json完整对象或其中的登录凭据、备注字段。但报告包含附件路径,有时也包含项目名称,这些名称本身可能暴露隐私或秘密。每份报告都应像导出文件一样保护。
5. 源数据仍在时加入基准
最强的离线比对需要一份删除前另行创建的清单。格式很简单:schemaVersion 为 1,attachments 为数组;每行包含 attachments/ 下的精确路径,并可带 size 和 SHA-256。基准可来自先前确认无误的导出、仔细复核的人工清单,或其他能够说明来源的本地记录。
运行:python3 bitwarden_zip_audit.py bitwarden_export.zip --expected-manifest expected-attachments.json --output bitwarden-audit.json。报告会列出缺失路径、额外路径、大小差异和哈希差异。全部匹配只证明与这份基准一致;若基准本身不完整或在数据已经消失后才生成,比对也会继承这个缺陷。
不要把密码库凭据、会话密钥或原始秘密字段写入基准。最低限度只需精确附件路径;大小和 SHA-256 可帮助识别被替换的内容。无法建立可信基准时,应保留源数据并手动抽查高价值附件,不要把 review 解释成“完整”。
6. 补上 ZIP 无法覆盖的范围
Bitwarden 官方导出说明指出,个人导出只含个人密码库数据;组织数据须由有权限的组织成员单独导出。回收站和 Sends 也有各自限制,不应默认包含。即使个人 ZIP 审计干净,也要把这些排除项写进迁移清单。
当前 ZIP 响应在附件下载或解密失败时可向客户端报告跳过数量,但这个运行时信号不会作为预期附件清单写入 data.json。若客户端显示跳过警告,应保留截图或记录并停止。离线归档无法重建从未写入的文件。
归档可读不等于恢复测试。进行破坏性操作前,只有在当前官方指引允许且你理解明文暴露风险时,才在一次性、获授权的测试环境中试导入;否则至少保留两份位于不同介质的加密备份,并保留源账户,直到目标端得到独立验证。
7. 用“继续/停止”清单决策
只有以下条件都满足才可继续评估:导出文件确实存在;已记录 ZIP 哈希;data.json 可解析;审计没有 error;每条 warning 都有书面解释;重要附件能从安全副本打开;组织数据另有方案;已理解回收站和 Sends 的排除;若使用独立基准,缺失和不一致均为零。
即便全部满足,也要保留源数据一段时间,在日常使用中验证目标端。若因法律或安全原因必须删除账户,应保留审计报告、归档哈希和决策记录。诚实结论只能是:你验证了手中现有字节,并在使用基准时验证了二者一致;你没有靠 ZIP 单独证明一个不可见的服务器端清单。
8. 测试实际证明什么
最终14,377字节脚本、SHA-256 6108379a2377560f2a0e52b5e3a9cd9d4120a140b79fabc7a3b747ae188708aa,在Python 3/Linux通过15项本地合成单元测试。覆盖有效与空清单、缺失或损坏JSON、重名与大小写冲突、清洗后名称歧义、基线缺失/额外/不匹配、精确基线匹配、符号链接与覆盖拒绝、原始不安全路径组件、UTF-16与重复键拒绝,以及布尔基线字段。
大小与压缩比停止条件用较小限制或可压缩合成夹具测试,未读取真实多GiB归档。精确基线夹具中输入字节未变。未测试真实Bitwarden密码库、客户端下载、组织导出、目的地导入或Windows权限。代码是本地清单辅助工具,不是Bitwarden官方完整性认证。
常见问题
review代表全部附件已导出吗? 不代表。它表示受支持归档能够被列清单且未出现错误finding;没有独立证据,服务器端预期集合仍未知。
数字后缀保证没有丢文件吗? 不能。核读的计数器处理重复名称,但不是一般性碰撞证明。ZIP无法显示写入前就遗漏的附件。
基线匹配证明当前服务器完整吗? 不能。它只证明与所给基线的路径,以及其中所列大小或摘要相等;应记录基线来源。
可以用于加密JSON导出吗? 不能。此流程要求明文ZIP中的data.json且encrypted=false,不解密受限导出,也不索取密码。
退出0后可以删除密码库吗? 不能。0仅表示已写报告。须读取报告、处理发现、核实目的地,并保留来源直到独立删除决策有充分依据。
