テクノロジー · 実用ガイド

Bitwarden のエクスポートは本当に完全?保管庫を削除する前に ZIP 添付を監査

ローカル監査でBitwarden ZIPに存在するファイルの一覧とハッシュを作れますが、全サーバー添付のエクスポートをZIP単体では証明できません。オフラインで構造を確認し、別途保存した基準と比較して、元データを保持し削除や移行を保留すべき条件を判断します。

オリジナルの概念図:パソコンでローカルZIPコピーを検査し、元保管庫を別に保持しています。
オリジナルの概念イラスト:元データを残してコピーを監査し証拠を比較します。削除は別判断で、緑のチェックは自動の削除許可ではありません。 Watch at Ease

成功メッセージだけを理由に 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 全体のハッシュを記録します。報告は ZIP の外に置き、項目名とファイル名を保護し、検査中は両入力コピーを固定してください。

上限は ZIP メンバー50,000、宣言された展開総量20 GiB、一メンバー4 GiB、data.json 100 MiB、基準16 MiB、基準行50,000です。1 MiB を超えるメンバーで圧縮比が1,000を超える場合は拒否します。添付ストリームも4 GiBで読み取りを制限します。ZIP ディレクトリ解析と圧縮 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 は構造または基準のエラーを少なくとも一つ調べる必要がある状態です。元データを削除したり移行で手放したりしないでください。生の重複パス、危険パス、明文 ZIP を想定した手順での暗号化メンバー、基準にあるファイルの欠落、サイズやハッシュの不一致は停止条件です。

構造が読める ZIP では review が通常の結果です。警告にも判断が必要です。ゼロバイトファイルが正当な場合もありますが安全なコピーで開きます。大文字小文字の衝突は Windows、macOS、Linux で挙動が異なり得ます。対応不明や曖昧なフォルダーは名称の正規化や重複項目に由来する可能性があり、余分なメンバーは新しい添付か古い基準かもしれません。

スクリプトはdata.jsonの完全なオブジェクトや、そのログイン認証情報・メモのフィールドを出力しません。ただし報告には添付パスや項目名が含まれる場合があり、名前自体から非公開情報や秘密が分かる可能性があります。報告もエクスポート同様に保護してください。

5. 元データがあるうちに基準を追加する

最も強いオフライン比較には、削除前に別途作った一覧を使います。形式は schemaVersion 1 と attachments 配列です。各行に attachments/ 配下の path があり、size と SHA-256 を加えられます。パスは ZIP 内の名前と正確に一致させます。正常と確認した以前のエクスポート、慎重に確認した手動一覧、由来を説明できる別のローカル資料から作ってください。

python3 bitwarden_zip_audit.py bitwarden_export.zip --expected-manifest expected-attachments.json --output bitwarden-audit.json を実行します。欠落、余分なパス、サイズ差、ハッシュ差を一覧化します。完全一致が証明するのはその基準との一致だけです。基準が不完全だったりデータ消失後に作られたりしていれば、その制限も引き継ぎます。

一覧に認証情報、セッションキーや生の秘密フィールドを含めないでください。最低限は正確な添付パスで、サイズと SHA-256 が意図しない置換の検出を助けます。信頼できる基準を作れなければ元データを保持し、重要添付を手作業で確認してください。review を完全と読み替えてはいけません。

6. ZIP が対象にできない範囲も確認する

公式ガイドによると個人エクスポートは個人保管庫のデータを含みます。組織データは権限のある組織メンバーが別途エクスポートします。ゴミ箱と Sends も含まれると想定してはいけません。個人 ZIP の監査に問題がなくても、移行チェックリストに除外範囲を記録します。

確認した ZIP 応答は、添付の取得または復号が失敗するとクライアントにスキップ数を返せます。この実行時情報は data.json に期待添付一覧として保存されません。クライアントがスキップ警告を表示したなら、スクリーンショットやログのメモを保存して停止条件として扱います。オフライン ZIP は書かれなかった添付を再構成できません。

読み取り可能な ZIP は復元テストではありません。破壊的操作前の使い捨てテスト環境へのインポートは、現在の公式手順が対応し、権限があり、その過程で明文データが露出することを理解した場合だけ検討してください。それ以外は別媒体に暗号化したコピーを少なくとも二つ保持し、移行を独立に検証するまで元アカウントを残します。

7. 進めるか止めるかのチェックリスト

以下を確認するまで先に進めません。ZIP が存在しハッシュを記録したこと、data.json が読めること、エラー finding がないこと、全警告に説明があること、重要添付が安全なコピーから開くこと、組織データの別計画があること、ゴミ箱と Sends の除外を理解したこと、独立した基準との比較に欠落や不一致がないことです。

全項目を満たしても、移行先を通常の利用で確認できるまで元データを保持します。法令やセキュリティ上の理由で削除が必要なら、監査報告、ZIP のハッシュ、判断記録を残します。結論は限定的です。手元にあるバイトと、使った場合は指定基準との一致を検証したのであり、ZIP だけから未知のサーバー一覧を証明したのではありません。

8. テストが実際に証明する範囲

最終14,377バイト、SHA-256 6108379a2377560f2a0e52b5e3a9cd9d4120a140b79fabc7a3b747ae188708aaのスクリプトは Python 3/Linux のローカル合成単体テスト15件を通過しました。正常・空一覧、JSON の欠落や破損、重複名と大小文字衝突、正規化名の曖昧さ、基準の欠落・余分・不一致・完全一致、リンクと上書き拒否、生の危険パス、UTF-16 と重複キーの拒否、基準の真偽値フィールドを検査しました。

サイズと圧縮比の停止条件は小さな上限や圧縮しやすい合成データで検査し、実際の数 GiB の ZIP は読みませんでした。完全一致の例では入力バイトが不変でした。実 Bitwarden 保管庫、クライアントのダウンロード、組織エクスポート、移行先インポート、Windows 権限はテストしていません。本コードはローカル一覧補助であり、Bitwarden の公式完全性証明ではありません。

よくある質問

review なら全添付がエクスポートされていますか? いいえ。対応する ZIP をエラー finding なしで一覧化できたという意味です。別の証拠なしではサーバー側の期待集合は未知です。

数値接尾辞があればファイル消失はありませんか? いいえ。確認したカウンターは重複名に対応しますが、一般的な衝突証明ではありません。ZIP は書き込み前に省かれた添付を示せません。

基準一致は現在のサーバーの完全性を証明しますか? いいえ。指定基準のパスと、その基準にあるサイズ・ハッシュとの一致だけです。基準の由来を記録してください。

暗号化 JSON エクスポートにも使えますか? いいえ。明文 ZIP の data.json と encrypted=false を対象にします。制限付きエクスポートを復号したりパスワードを要求したりしません。

終了コード0なら保管庫を削除できますか? いいえ。0は報告が書かれた意味です。報告を読み、問題を解決し、移行先を確認し、別の削除判断に根拠ができるまで元データを残してください。

オリジナルの概念図:ハッシュを確認したローカルメンバーと、証拠の境界外の未知の遠隔ファイルを区別。
オリジナルの概念イラスト:ハッシュは手元のメンバーを検証します。独立した基準なしではZIP外の遠隔の期待集合は未知です。 Watch at Ease