科技 · 實用指南
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只表示已寫報告。須讀取報告、處理發現、核實目的地,並保留來源直到獨立刪除決策有充分依據。
