diff --git a/.gitignore b/.gitignore index 2327d594..ee712b7b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,10 @@ __pycache__/ .venv/ .env -.env.bak -.env.backup +# 日時付きの控え (.env.bak-20260807172231 等) は完全一致では弾けない。 +# 実際に未追跡のまま検出された経緯があるためワイルドカードで除外する。 +.env.bak* +.env.backup* .gemini/ .docker-compose.scale.yml plugins.yml diff --git a/containers/lfm/compose.yml b/containers/lfm/compose.yml index b9c199b4..745c2d44 100644 --- a/containers/lfm/compose.yml +++ b/containers/lfm/compose.yml @@ -5,4 +5,3 @@ services: build: context: . dockerfile: Dockerfile -c diff --git a/docs/README.md b/docs/README.md index eae57239..b108925c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -46,6 +46,7 @@ graph TD | [CLI リファレンス](user/cli-reference/README.md) | 全コマンドの構文・オプション・使用例 | | [プラグインレジストリ](user/plugin-registries.md) | 公開・社内レジストリの一覧と追加方法 | | [環境変数ガイド](user/environment-variables.md) | 3レベル構造、コレクター、ソース同期 | +| [環境変数の暗号化](user/env-encryption.md) | 認証情報を暗号化して保存する / 鍵とチーム共有 / 平文へ戻す | | [コンテナ操作ガイド](user/container-operations.md) | ライフサイクル、並行開発、ボリューム構造 | | [スナップショットガイド](user/snapshot-guide.md) | 増分バックアップ、世代管理、復元手順 | | [トラブルシューティング](user/troubleshooting.md) | カテゴリ別の問題と解決策 | @@ -101,6 +102,7 @@ docs/ │ │ └── 05-snapshot.md ← snapshot グループ │ ├── plugin-registries.md ← プラグインレジストリ │ ├── environment-variables.md ← 環境変数ガイド +│ ├── env-encryption.md ← 環境変数の暗号化 │ ├── container-operations.md ← コンテナ操作ガイド │ ├── snapshot-guide.md ← スナップショットガイド │ └── troubleshooting.md ← トラブルシューティング @@ -124,6 +126,7 @@ docs/ | devbase を初めてインストールする | [はじめに](user/getting-started.md#セットアップ手順) | | コマンドの使い方を調べる | [CLI リファレンス](user/cli-reference/README.md) | | 環境変数を設定する | [環境変数ガイド](user/environment-variables.md#環境変数の操作) | +| 認証情報を暗号化して保存する | [環境変数の暗号化](user/env-encryption.md) | | 複数コンテナで並行開発する | [コンテナ操作ガイド](user/container-operations.md#並行開発) | | データをバックアップ・復元する | [スナップショットガイド](user/snapshot-guide.md) | | エラーが発生した | [トラブルシューティング](user/troubleshooting.md) | diff --git a/docs/user/cli-reference/03-env.md b/docs/user/cli-reference/03-env.md index 75e4b871..878f22f3 100644 --- a/docs/user/cli-reference/03-env.md +++ b/docs/user/cli-reference/03-env.md @@ -224,6 +224,61 @@ devbase env exec -- docker compose config > `devbase env exec -- printenv` のように値を表示するコマンドは、画面共有や端末ログに認証情報がそのまま残ります。実行する場面に注意してください。 +## `devbase env rekey` + +誰が機密を復号できるかを変更し、暗号化済みの機密をまとめて暗号化し直します。 + +``` +devbase env rekey [--add-recipient KEY]... [--remove-recipient KEY]... [--dry-run] [-y|--yes] +``` + +| オプション | 説明 | +|-----------|------| +| `--add-recipient KEY` | 受信者を追加(繰り返し指定可)。`age1...` / `ssh-ed25519 ...` / `@PATH` | +| `--remove-recipient KEY` | 受信者を削除(繰り返し指定可) | +| `--dry-run` | 変更内容を表示するだけで、何も書き換えません | +| `-y`, `--yes` | 確認プロンプトを省略 | + +受信者は `$DEVBASE_ROOT/secrets/recipients.txt` に記録されます。リストがまだ無い状態で追加すると、自分の公開鍵も一緒に登録されます(登録しないと自分が受信者から外れ、自分の機密を復号できなくなるため)。 + +```bash +# 同僚を追加する +devbase env rekey --add-recipient age1xxxxxxxx... + +# 抜けた人を外す +devbase env rekey --remove-recipient age1xxxxxxxx... +``` + +> 自分の公開鍵を受信者から外すと、再暗号化後にその端末では機密を復号できなくなります。実行前に警告が表示されます。 + +受信者リストの更新と全機密の再暗号化は、途中で失敗しても中途半端な状態を残さない 1 つのまとまりとして適用されます。書き込みに失敗した場合は受信者リストも各暗号文も実行前の内容へ戻るため、旧受信者宛と新受信者宛の暗号文が混在することはありません。 + +## `devbase env doctor` + +端末上に残る平文と、除外設定の穴を点検します。問題が見つかると非ゼロで終了するため、定期実行にも使えます。 + +``` +devbase env doctor +``` + +確認する内容: + +| 観点 | 内容 | +|-----|------| +| 鍵 | 鍵ファイルの有無と権限、置き場のディレクトリ権限 | +| 保存先の衝突 | 暗号化ファイルと平文が同時に存在していないか | +| 退避された平文 | `backups/env-encrypt/` / `backups/env-import/` に平文が残っていないか | +| 控えファイル | `.env.bak-<日時>` のような平文の控えが残っていないか | +| 除外設定 | `.env` / `secrets/` 配下 / `.env.bak-<日時>` / `projects//.env` が実際に Git から除外されるか | + +除外設定の点検は `.gitignore` を読んで解釈するのではなく、代表的なパスを `git check-ignore` に渡して **Git 自身に判定させます**(`.gitignore` の解釈は Git の実装が正であり、独自に真似ると書き方によって食い違うため)。ルート指定(`/.env`)でも任意階層(`**/.env`)でも、Git が実際に除外できていれば報告されません。逆に次のように **Git は除外しない** 書き方は、除外されないパスを挙げて報告します。 + +- `.env # 機密` — Git は行頭の `#` だけをコメントとして扱うため、これは `.env # 機密` というパターンになります +- `.env` の後に `!.env` — 後段の再包含で除外が取り消されます +- `secrets/*.age` — 配下の平文(`secrets/leftover.env` など)が漏れます + +`DEVBASE_ROOT` が Git リポジトリでない場合や `git` が使えない場合は、「除外設定を確認できませんでした」と報告します(誤って「問題なし」とは言いません)。 + ## `devbase env export` 複数プロジェクトの `.env` 群を暗号化したまま 1 つのバンドルにまとめて書き出します。 diff --git a/docs/user/cli-reference/README.md b/docs/user/cli-reference/README.md index 3fc0e734..8ea5852e 100644 --- a/docs/user/cli-reference/README.md +++ b/docs/user/cli-reference/README.md @@ -6,7 +6,7 @@ devbase の全コマンドの構文、オプション、使用例をまとめた |---------|------| | [トップレベルコマンド](01-toplevel.md) | `init` / `status` / `bin/rc` | | [project グループ](02-project.md) | コンテナのライフサイクル管理・一覧(`up` / `down` / `login` / `ps` / `logs` / `scale` / `build` / `rebuild` / `list`)と非推奨の `container` グループ | -| [env グループ](03-env.md) | 環境変数の管理(`init` / `sync` / `list` / `set` / `get` / `delete` / `edit` / `project` / `keygen` / `encrypt` / `decrypt` / `exec` / `export` / `import`) | +| [env グループ](03-env.md) | 環境変数の管理(`init` / `sync` / `list` / `set` / `get` / `delete` / `edit` / `project` / `keygen` / `encrypt` / `decrypt` / `exec` / `rekey` / `doctor` / `export` / `import`) | | [plugin グループ](04-plugin.md) | プラグインの管理(`list` / `install` / `uninstall` / `update` / `info` / `sync` / `migrate` / `repo *`) | | [snapshot グループ](05-snapshot.md) | スナップショットの管理(`create` / `list` / `restore` / `copy` / `delete` / `rotate`) | @@ -27,7 +27,7 @@ graph TD D --> D4["build [image] / rebuild [name]"] D --> D2["list [--no-interactive]"] E --> E1[init / sync / list / set / get / delete / edit / project] - E --> E2[keygen / encrypt / decrypt / exec] + E --> E2[keygen / encrypt / decrypt / exec / rekey / doctor] E --> E3[export / import] F --> F1[list / install / uninstall / update / info / sync / migrate] F --> F2[repo add / repo remove / repo list / repo refresh] diff --git a/docs/user/env-encryption.md b/docs/user/env-encryption.md new file mode 100644 index 00000000..38e3806d --- /dev/null +++ b/docs/user/env-encryption.md @@ -0,0 +1,144 @@ +# 環境変数の暗号化 + +devbase が扱う認証情報(クラウドのアクセスキー、コード管理サービスの個人アクセストークン、各種 AI サービスの API キーなど)を、保存時に暗号化して持つためのガイドです。 + +暗号化しない運用も引き続き可能です。移行は明示的なコマンドで行い、いつでも平文へ戻せます。 + +## 何が変わるのか + +| | 暗号化しない場合(既定) | 暗号化した場合 | +|---|---|---| +| 共通の機密 | `$DEVBASE_ROOT/.env` | `$DEVBASE_ROOT/secrets/global.env.age` | +| プロジェクトの機密 | `projects//.env` | `secrets/projects/.env.age` | +| コンテナへの渡り方 | 構成ファイルが平文を直接読む | devbase が復号し、変数名だけを列挙した構成で渡す | +| 日々の操作 | `devbase env set` / `get` / `edit` … | **変わらない** | + +保存先はファイルの存在から自動で判定されます。暗号化ファイルがあればそれを、無ければ平文を使います。 + +## 使いはじめる + +### 1. 鍵を作る + +```bash +devbase env keygen +``` + +鍵は `~/.config/devbase/age/keys.txt` に `0600` で作られます。場所を変えたい場合は `DEVBASE_AGE_KEY_FILE` を設定してから実行してください。 + +> **鍵のバックアップは必須です。** この鍵を失うと、暗号化した機密は誰にも復号できません(devbase 側にも復旧手段はありません)。パスワード管理ツールなど、端末とは別の場所へ必ず複製してください。 + +### 2. 何が変わるか確認する + +```bash +devbase env encrypt --dry-run +``` + +暗号化される設定の一覧と、各プロジェクトの `compose.yml` に加わる変更の差分が表示されます。 + +### 3. 暗号化する + +```bash +devbase env encrypt +``` + +次の順で処理されます。 + +1. 平文の設定を暗号化して `secrets/` 配下へ保存する +2. **暗号化した内容を読み戻して元と一致することを確認**する +3. 元の平文を `backups/env-encrypt/<日時>/` へ退避する +4. 各プロジェクトの `compose.yml` から機密ファイルの参照をコメントアウトする + +途中で失敗した場合は、それまでの変更をすべて巻き戻して中止します。「一部だけ暗号化され、構成は存在しないファイルを参照したまま」という状態にはなりません。 + +### 4. 退避された平文を消す + +`encrypt` は元の平文を**自動では削除しません**。内容を確認してから、案内されたディレクトリを削除してください。 + +```bash +rm -rf $DEVBASE_ROOT/backups/env-encrypt/<日時> +``` + +消し忘れは `devbase env doctor` が指摘し続けます。 + +## 日々の操作 + +暗号化しても操作は変わりません。 + +```bash +devbase env list # 暗号化された保存先には [暗号化] と表示される +devbase env set KEY=VALUE +devbase env get KEY +devbase env delete KEY --project +devbase env edit # 復号 → 編集 → 再暗号化 +``` + +`devbase env edit` は、復号結果を自分専用の一時ディレクトリへ `0600` で書き、編集後に暗号化し直してから必ず削除します。エディタへ値を渡す手段が他に無いため、**この操作の間だけ平文が一瞬ディスクに載ります**。 + +## 点検する + +```bash +devbase env doctor +``` + +以下を確認し、問題があれば非ゼロで終了します。 + +- 鍵ファイルとその置き場の権限 +- 暗号化ファイルと平文が同時に存在していないか +- 移行時・取り込み時に退避された平文が残っていないか +- 日時付きの控えファイル(`.env.bak-20260807172231` など)が残っていないか +- `.env` / `secrets/` 配下 / `.env.bak-<日時>` / `projects//.env` が実際に Git から除外されるか(`git check-ignore` で Git 自身に判定させます。`DEVBASE_ROOT` が Git リポジトリでなければ「確認できませんでした」と報告します) + +## チームで共有する + +各メンバーの公開鍵を受信者として登録すると、秘密鍵を渡さずに同じファイルを共同で使えます。 + +```bash +# 同僚の公開鍵を追加して、既存の機密をまとめて暗号化し直す +devbase env rekey --add-recipient age1xxxxxxxx... + +# 抜けた人を外す +devbase env rekey --remove-recipient age1xxxxxxxx... + +# 何が変わるか先に見る +devbase env rekey --add-recipient age1xxxxxxxx... --dry-run +``` + +受信者は `$DEVBASE_ROOT/secrets/recipients.txt` に記録されます。中身は公開鍵だけですが、第三者が自分の鍵を追記できると以後の暗号化がその相手にも復号可能になるため、`0600` で保護されます。 + +自分の公開鍵を受信者から外そうとすると警告が出ます。外したまま再暗号化すると、その端末では機密を復号できなくなります。 + +## 持ち運ぶ + +`devbase env export` / `import` はそのまま使えます。書き出しは暗号化された機密を復号してバンドルへ入れ、バンドル自体を age で暗号化します。取り込み先が暗号化されていれば、**取り込み結果も暗号化されたまま**保存されます(平文の `.env` は作られません)。 + +```bash +devbase env export bundle.dbenv --recipient age1xxxxxxxx... +devbase env import bundle.dbenv --identity ~/.config/devbase/age/keys.txt +``` + +## 平文へ戻す + +```bash +devbase env decrypt +``` + +`compose.yml` のコメントアウトも元に戻り、暗号化前の状態へそのまま復帰します。コメントや空行、`export KEY=value` 表記も失われません(`devbase env set` などで値を書き換えた場合は、平文だけで運用していたときと同じく整形されます)。 + +## 守れること / 守れないこと + +**守れること**: 端末のディスク上に残る保存ファイル、バックアップ、クラウド同期フォルダ、ファイル転送中、リポジトリへの誤コミット、画面共有時の誤表示。 + +**守れないこと**: + +- **実行時の平文化**: 最終的にコンテナへは環境変数として平文で渡ります +- **コンテナ環境の可視性**: コンテナの詳細情報を参照できれば、注入済みの環境変数は読めます。devbase は開発コンテナに Docker の制御ソケットを渡す構成を既定に含むため、コンテナ内から他コンテナの環境変数も参照できます +- **構成の展開結果**: `docker compose config` は変数名だけの列挙を実際の値へ解決して表示します +- **利用者権限を得た攻撃者**: 既定の鍵保管では鍵も同時に読めます + +既定の保護は「鍵が錠前の隣にある」状態です。端末上で利用者権限を得た攻撃者は防げません。これは実運用上の妥協として明示しています。 + +## 関連 + +- [CLI リファレンス: env グループ](cli-reference/03-env.md) +- [環境変数の一覧](environment-variables.md) +- [バンドルの書き出しと取り込み](env-export-import.md) diff --git a/etc/_devbase b/etc/_devbase index ed5db95a..84f1a8cf 100644 --- a/etc/_devbase +++ b/etc/_devbase @@ -109,6 +109,8 @@ _devbase() { 'exec:Run a command with the decrypted secrets in its environment' 'encrypt:Move plaintext settings into the encrypted store' 'decrypt:Move encrypted settings back to plaintext' + 'rekey:Change who can decrypt the secrets and re-encrypt them' + 'doctor:Check for leftover plaintext secrets and ignore-rule gaps' ) plugin_subcommands=( @@ -300,6 +302,13 @@ _devbase() { '--dry-run[Show what would change without writing]' \ '--yes[Skip the confirmation prompt]' '-y[Skip the confirmation prompt]' ;; + rekey) + _arguments \ + '*--add-recipient[Public key to add (repeatable)]:key:' \ + '*--remove-recipient[Public key to remove (repeatable)]:key:' \ + '--dry-run[Show what would change without writing]' \ + '--yes[Skip the confirmation prompt]' '-y[Skip the confirmation prompt]' + ;; keygen) _arguments \ '--force[Overwrite an existing key]' \ diff --git a/etc/devbase-completion.bash b/etc/devbase-completion.bash index 9a77285d..3b7a192c 100644 --- a/etc/devbase-completion.bash +++ b/etc/devbase-completion.bash @@ -35,7 +35,7 @@ _devbase_completions() { # project / container は同じサブコマンド群 (container は非推奨だが補完は維持)。 local project_subcommands="up down ps login logs scale build rebuild list" local container_subcommands="up down ps login logs scale build rebuild" - local env_subcommands="init sync list set get delete edit project export import keygen exec encrypt decrypt" + local env_subcommands="init sync list set get delete edit project export import keygen exec encrypt decrypt rekey doctor" local plugin_subcommands="list install uninstall update info sync repo" local repo_subcommands="add remove list refresh" local snapshot_subcommands="create list restore copy delete rotate" diff --git a/issues/plan35.md b/issues/plan35.md index 5991f828..a864bf33 100644 --- a/issues/plan35.md +++ b/issues/plan35.md @@ -284,6 +284,8 @@ release branch: `release/PLAN35` / base branch: `main` 段階 6 (SOPS を差し替え先として実装) は本 release のスコープ外とし、運用要件が出た時点で別 plan に切り出す。 +PR4 では上記に加えて、書き出し・取り込み (`export` / `import`) を秘密ストア経由へ揃えた。移行後の環境で `import` が平文の `.env` を作ると、暗号化ファイルと平文が同時に存在する状態 (§9 でエラーとして停止させる状態) を自分で作り出してしまうため。あわせて取り込み時の控えも暗号文のまま保存されるようになり、§2.3 で挙げた「バックアップに平文が滞留する」経路が塞がる。 + ### 11.1 依存が直列になる理由 4 本すべてが `lib/devbase/env/` の同一層を触るため、worktree による並行開発の利得よりコンフリクト解消のコストが上回る。PR1 の抽象層が確定しないと PR2 の保存先切替は書けず、PR2 の読み込み経路が確定しないと PR3 の注入経路は書けない。したがって「PR n を release へ merge → PR n+1 を release から切る」の直列で進める。 diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index e6547d58..063e2a51 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -54,7 +54,8 @@ ('project',): ['up', 'down', 'ps', 'login', 'logs', 'scale', 'build', 'rebuild', 'list'], ('container', 'ct'): ['up', 'down', 'ps', 'login', 'logs', 'scale', 'build', 'rebuild'], ('env',): ['init', 'sync', 'list', 'set', 'get', 'delete', 'edit', 'project', 'keygen', - 'exec', 'encrypt', 'decrypt', 'export', 'import'], + 'exec', 'encrypt', 'decrypt', 'rekey', 'doctor', + 'export', 'import'], ('plugin', 'pl'): ['list', 'install', 'uninstall', 'update', 'info', 'sync', 'repo', 'migrate'], ('snapshot', 'ss'): ['create', 'list', 'restore', 'copy', 'delete', 'rotate'], } @@ -322,6 +323,24 @@ def _add_env_parser(subparsers): sub.add_argument('--yes', '-y', action='store_true', dest='assume_yes', help='Skip the confirmation prompt') + env_rekey = env_sub.add_parser( + 'rekey', help='Change who can decrypt the secrets and re-encrypt them') + env_rekey.add_argument('--add-recipient', action='append', default=[], + metavar='KEY', dest='add_recipients', + help=("Public key to add (repeatable). Formats: " + "'age1...', 'ssh-ed25519 ...', '@PATH'")) + env_rekey.add_argument('--remove-recipient', action='append', default=[], + metavar='KEY', dest='remove_recipients', + help='Public key to remove (repeatable)') + env_rekey.add_argument('--dry-run', action='store_true', + help='Show what would change without writing') + env_rekey.add_argument('--yes', '-y', action='store_true', dest='assume_yes', + help='Skip the confirmation prompt') + + env_sub.add_parser( + 'doctor', + help='Check for leftover plaintext secrets and ignore-rule gaps') + env_keygen = env_sub.add_parser( 'keygen', help='Generate the devbase age key used by the secret store ' diff --git a/lib/devbase/commands/env.py b/lib/devbase/commands/env.py index 23719afc..86326ac5 100644 --- a/lib/devbase/commands/env.py +++ b/lib/devbase/commands/env.py @@ -113,6 +113,13 @@ def cmd_env(devbase_root: Path, args) -> int: dry_run=getattr(args, 'dry_run', False), assume_yes=getattr(args, 'assume_yes', False), projects=list(getattr(args, 'projects', []) or []) or None), + 'rekey': lambda: _ops().cmd_env_rekey( + devbase_root, + add=list(getattr(args, 'add_recipients', []) or []), + remove=list(getattr(args, 'remove_recipients', []) or []), + dry_run=getattr(args, 'dry_run', False), + assume_yes=getattr(args, 'assume_yes', False)), + 'doctor': lambda: _ops().cmd_env_doctor(devbase_root), 'keygen': lambda: cmd_env_keygen(devbase_root, force=getattr(args, 'force', False), assume_yes=getattr(args, 'assume_yes', False)), @@ -126,6 +133,13 @@ def cmd_env(devbase_root: Path, args) -> int: return 1 +def _ops(): + """受信者更新 / 点検の実装モジュール (import を遅延させる)""" + from devbase.commands import env_ops + + return env_ops + + def _migrate(_args=None): """移行コマンドの実装モジュール (import を遅延させる)""" from devbase.commands import env_migrate diff --git a/lib/devbase/commands/env_migrate.py b/lib/devbase/commands/env_migrate.py index 0e56310f..04dd4849 100644 --- a/lib/devbase/commands/env_migrate.py +++ b/lib/devbase/commands/env_migrate.py @@ -19,7 +19,7 @@ ``compose.yml`` の書き換えが噛み合って初めて意味を持つ。どちらか片方だけ 済んだ状態は「構成ファイルが存在しないファイルを参照する」壊れた設定になる ため、実行した操作ごとに取り消し手続きを積み、どこで失敗しても逆順に - 巻き戻してから ``1`` を返す (:class:`_Rollback`) + 巻き戻してから ``1`` を返す (:class:`devbase.env.rollback.Rollback`) """ from __future__ import annotations @@ -30,9 +30,10 @@ from dataclasses import dataclass, field from datetime import datetime from pathlib import Path -from typing import Callable, Dict, List, Optional, Sequence, Set, Tuple +from typing import Dict, List, Optional, Sequence, Set, Tuple from devbase.env import agekeys, compose_migrate, io_common +from devbase.env.rollback import Rollback from devbase.env.secret_store import ( MODE_AGE, MODE_PLAINTEXT, @@ -103,51 +104,9 @@ def _confirm(prompt: str, assume_yes: bool) -> bool: return safe_input(prompt) == 'yes' -# --------------------------------------------------------------------------- -# 巻き戻し -# --------------------------------------------------------------------------- - -class _Rollback: - """実行した操作の取り消し手続きを積み、失敗時に逆順で実行する。 - - 移行は複数の破壊的な操作 (暗号化・平文の退避・構成ファイルの書き換え・ - 暗号文の削除) が連なる。「全部検証してから全部実行する」とフェーズを - 分けるだけでは、実行フェーズの途中で失敗したぶんが中間状態として残る。 - そこで **操作を 1 つ実行するたびにその取り消し手続きを積み**、どこで - 失敗しても :meth:`unwind` で逆順に巻き戻せるようにする。 - """ - - def __init__(self) -> None: - self._undo: List[Tuple[str, Callable[[], None]]] = [] - - def push(self, description: str, undo: Callable[[], None]) -> None: - """実行済みの操作に対する取り消し手続きを積む。 - - Args: - description: 取り消しが何をするか (巻き戻しに失敗したときに - 「何が残っているか」として利用者へ見せる) - undo: 取り消し手続き - """ - self._undo.append((description, undo)) - - def unwind(self) -> None: - """積んだ取り消し手続きを逆順に実行する。 - - 後の操作は前の操作を前提にしているため、必ず逆順で戻す。巻き戻しの - 途中で失敗しても残りは試みるが、**握り潰さずに何が残っているかを - 具体的に列挙する**。ここで黙ると、利用者は壊れた状態に気付けない。 - """ - failures: List[str] = [] - for description, undo in reversed(self._undo): - try: - undo() - except Exception as e: # 1 つ失敗しても残りの巻き戻しは続ける - failures.append(f" - {description}: {e}") - self._undo.clear() - if failures: - logger.error( - "巻き戻しに失敗しました。次の操作が完了しておらず、" - "手動での復旧が必要です:\n%s", "\n".join(failures)) +# 巻き戻し (:class:`devbase.env.rollback.Rollback`) は ``env rekey`` と共有する。 +# 移行は複数の破壊的な操作 (暗号化・平文の退避・構成ファイルの書き換え・暗号文の +# 削除) が連なるため、操作ごとに取り消し手続きを積んで逆順に戻せるようにする。 # --------------------------------------------------------------------------- @@ -206,7 +165,7 @@ def cmd_env_encrypt(devbase_root: Path, *, dry_run: bool = False, print("中止しました") return 1 - rollback = _Rollback() + rollback = Rollback() try: # 1. 全対象を暗号化して読み戻せることを確認する (平文にはまだ触れない) # 2. バックアップ先を排他的に作り、平文をそこへ移す @@ -237,7 +196,7 @@ def cmd_env_encrypt(devbase_root: Path, *, dry_run: bool = False, def _encrypt_and_verify(store: SecretStore, refs: Sequence[SecretRef], - rollback: _Rollback) -> None: + rollback: Rollback) -> None: """全対象を暗号化し、読み戻して元の内容と一致することを確認する。 ここでは平文に一切触れない。鍵の指定を誤ったまま平文を失うと、誰にも @@ -317,7 +276,7 @@ def _create_backup_dir(preferred: Path) -> Path: def _move_plaintext_to_backup(store: SecretStore, refs: Sequence[SecretRef], backup_dir: Path, - rollback: _Rollback) -> List[Path]: + rollback: Rollback) -> List[Path]: """全対象の平文をバックアップへ移す (取り消し: 元の場所へ戻す)""" moved: List[Path] = [] for ref in refs: @@ -412,7 +371,7 @@ def cmd_env_decrypt(devbase_root: Path, *, dry_run: bool = False, print("中止しました") return 1 - rollback = _Rollback() + rollback = Rollback() try: # 1. 全対象の暗号文を読み込み、復号できることを確認する # 2. 平文を書き出す @@ -464,7 +423,7 @@ def _load_encrypted(store: SecretStore, refs: Sequence[SecretRef], def _write_plaintext(store: SecretStore, loaded: Sequence[Tuple[SecretRef, bytes, bytes]], - rollback: _Rollback) -> None: + rollback: Rollback) -> None: """全対象の平文を書き出す (取り消し: 書いた平文を削除)""" for ref, plain, _ in loaded: store.plaintext.save_bytes(ref, plain) @@ -478,7 +437,7 @@ def _write_plaintext(store: SecretStore, def _remove_encrypted(store: SecretStore, loaded: Sequence[Tuple[SecretRef, bytes, bytes]], - rollback: _Rollback) -> None: + rollback: Rollback) -> None: """暗号文を削除する (取り消し: 控えた生バイト列で復元)""" for ref, _, blob in loaded: path = store.age.path(ref) @@ -659,7 +618,7 @@ def _write_compose(path: Path, text: str, mode: int) -> None: io_common.write_secure_bytes_atomic(path, text.encode('utf-8'), mode=mode) -def _apply_compose_changes(changes, rollback: _Rollback) -> None: +def _apply_compose_changes(changes, rollback: Rollback) -> None: """計画した書き換えを適用する (取り消し: 元のテキストを書き戻す)。 1 つでも書けなければ例外で呼び出し元へ返す。ここでログだけ出して次の diff --git a/lib/devbase/commands/env_ops.py b/lib/devbase/commands/env_ops.py new file mode 100644 index 00000000..6b8e6447 --- /dev/null +++ b/lib/devbase/commands/env_ops.py @@ -0,0 +1,496 @@ +"""受信者の更新と、端末上に残る平文の点検 + +``devbase env rekey`` は誰が機密を復号できるかを変え、``devbase env doctor`` は +「暗号化したつもりで平文が残っていないか」を点検する (plan35 §6)。 + +暗号化は「平文がどこにも残っていないこと」で初めて意味を持つ。移行の途中で +取り残されたバックアップや、除外設定の穴は黙って残り続けるため、点検する手段を +用意して繰り返し確認できるようにする。 +""" + +from __future__ import annotations + +import stat +import subprocess +from dataclasses import dataclass, field +from pathlib import Path +from typing import List, Optional, Sequence, Tuple + +from devbase.env import agekeys, io_common +from devbase.env.rollback import Rollback +from devbase.env.secret_store import ( + MODE_AGE, + SecretRef, + SecretStore, +) +from devbase.env.store import safe_input +from devbase.errors import DevbaseError +from devbase.log import get_logger + +logger = get_logger(__name__) + + +class EnvOpsError(DevbaseError): + """受信者更新 / 点検の操作エラー""" + + +# --------------------------------------------------------------------------- +# rekey +# --------------------------------------------------------------------------- + +def _encrypted_refs(devbase_root: Path, store: SecretStore) -> List[SecretRef]: + """暗号化済みの参照をすべて集める""" + refs: List[SecretRef] = [] + if store.mode(SecretRef.for_global()) == MODE_AGE: + refs.append(SecretRef.for_global()) + for name in store.project_names(): + ref = SecretRef.for_project(name) + if store.mode(ref) == MODE_AGE: + refs.append(ref) + return refs + + +def _own_public_key() -> Optional[str]: + """自分の公開鍵 (専用鍵が無ければ ``None``)""" + if not agekeys.key_file_path().exists(): + return None + try: + return agekeys.read_public_key() + except DevbaseError: + return None + + +def cmd_env_rekey(devbase_root: Path, *, + add: Sequence[str] = (), + remove: Sequence[str] = (), + dry_run: bool = False, + assume_yes: bool = False) -> int: + """受信者を追加・削除し、暗号化済みの機密を新しい受信者宛に暗号化し直す""" + root = Path(devbase_root) + store = SecretStore(root) + + try: + current = agekeys.load_recipients(root) + except DevbaseError as e: + logger.error("%s", e) + return 1 + + own = _own_public_key() + if not current: + # 受信者リストが無い状態は「自分の鍵だけが受信者」という意味なので、 + # そこから始める。リストを作らずに追加だけすると、自分が受信者から + # 外れて自分の機密を復号できなくなる。 + if own is None: + logger.error( + "受信者リストも devbase 専用鍵もありません。" + "先に `devbase env keygen` を実行してください") + return 1 + current = [own] + + updated = list(current) + for spec in add: + spec = spec.strip() + if not spec: + continue + try: + from devbase.env import cipher as _cipher + + _cipher.validate_recipient(spec) + except DevbaseError as e: + logger.error("%s", e) + return 1 + if spec not in updated: + updated.append(spec) + + missing = [spec for spec in remove if spec.strip() not in updated] + if missing: + logger.error("受信者リストに無いため削除できません: %s", ', '.join(missing)) + return 1 + for spec in remove: + updated = [r for r in updated if r != spec.strip()] + + if not updated: + logger.error( + "受信者を全員削除すると、以後の機密を誰も復号できなくなります。" + "少なくとも 1 人は残してください") + return 1 + + if updated == current: + print("受信者に変更はありません") + return 0 + + refs = _encrypted_refs(root, store) + + print("\n=== 受信者の変更 ===") + for spec in updated: + mark = '+' if spec not in current else ' ' + print(f" {mark} {spec}") + for spec in current: + if spec not in updated: + print(f" - {spec}") + + print(f"\n再暗号化する機密: {len(refs)} 件") + for ref in refs: + print(f" {ref.label():<24} {store.age.path(ref)}") + + if own is not None and own not in updated: + print("\n⚠ 自分の公開鍵が受信者から外れています。" + "再暗号化後、この端末では機密を復号できなくなります。") + + if dry_run: + print("\n(--dry-run のため変更していません)") + return 0 + + if not assume_yes and safe_input("続行しますか? (yes と入力): ") != 'yes': + print("中止しました") + return 1 + + # 受信者リストの更新と全暗号文の差し替えは、**片方だけ済んだ状態を残さない** + # 単一のまとまりとして扱う。中途半端に終わると旧受信者宛と新受信者宛の暗号文が + # 混在し、しかも自分の鍵を外す操作だと残った旧暗号文をもう復号できないため、 + # `devbase env rekey` の再実行でも復旧できなくなる。 + # + # env_migrate と同じ考え方で **破壊的な操作をできるだけ後ろへ寄せ**、実行した + # 操作ごとに取り消し手続きを積む (Rollback)。失うものが無い準備 (復号・暗号化) + # を先に全件済ませてからディスクへ触るので、途中で失敗しても巻き戻しは + # 「控えたバイト列を書き戻す」だけで済む。 + rollback = Rollback() + try: + # 1. 全件を復号し、旧暗号文の生バイト列も控える (ディスクは触らない) + # 2. 新しい受信者宛の暗号文を全件用意する (ここもまだ触らない) + # 3. 受信者リストを更新する + # 4. 各暗号文を差し替える + prepared = _prepare_reencryption(root, store, refs, updated) + _replace_recipients(root, updated, rollback) + _replace_ciphertexts(store, prepared, rollback) + except (DevbaseError, OSError) as e: + logger.error("受信者の更新を中止し、変更を巻き戻します: %s", e) + rollback.unwind() + return 1 + + print(f"\n=== 完了 === (受信者 {len(updated)} 名 / 機密 {len(refs)} 件)") + return 0 + + +def _prepare_reencryption(root: Path, store: SecretStore, + refs: Sequence[SecretRef], + updated: Sequence[str], + ) -> List[Tuple[SecretRef, bytes, bytes]]: + """全件を復号し、新しい受信者宛の暗号文を用意する (ディスクは触らない)。 + + Returns: + ``(参照, 旧暗号文の生バイト列, 新受信者宛の暗号文)`` の並び + + 旧暗号文は再暗号化ではなく **生バイト列のまま** 控える。巻き戻しで元の + ファイルへ 1 バイト違わず戻せるようにするため (age は暗号化のたびに異なる + 出力になるので、作り直したものでは「元に戻した」と言い切れない)。 + + ここで失敗しても、受信者リストも暗号文もまだ 1 つも書き換えていない。 + """ + rewritten = SecretStore(root, recipients=list(updated)) + prepared: List[Tuple[SecretRef, bytes, bytes]] = [] + for ref in refs: + path = store.age.path(ref) + try: + old_blob = path.read_bytes() + except OSError as e: + raise EnvOpsError(f"暗号文を読み込めませんでした ({path}): {e}") from e + try: + plain = store.age.load_bytes(ref) + except DevbaseError as e: + raise EnvOpsError(f"{ref.label()}を復号できませんでした: {e}") from e + try: + new_blob = rewritten.age.encrypt_bytes(plain) + except DevbaseError as e: + raise EnvOpsError( + f"{ref.label()}を新しい受信者宛に暗号化できませんでした: {e}") from e + prepared.append((ref, old_blob, new_blob)) + return prepared + + +def _write_blob(path: Path, blob: bytes) -> None: + """暗号文 / 受信者リストを atomic に差し替える。 + + 書き込みを 1 箇所に集約しておくと、巻き戻し側も同じ経路を通るので + 「戻したつもりで別の書き方をしていた」というずれが起きない。 + """ + io_common.write_secure_bytes_atomic(path, blob) + + +def _replace_recipients(root: Path, updated: Sequence[str], + rollback: Rollback) -> None: + """受信者リストを差し替える (取り消し: 元の内容へ戻す / 元が無ければ削除)""" + path = agekeys.recipients_file(root) + try: + before = path.read_bytes() if path.is_file() else None + except OSError as e: + raise EnvOpsError(f"受信者リストを読み込めませんでした ({path}): {e}") from e + + try: + agekeys.save_recipients(root, list(updated)) + except OSError as e: + raise EnvOpsError(f"受信者リストを更新できませんでした ({path}): {e}") from e + + if before is None: + # 元々リストが無かった場合は「作る前」= 存在しない状態へ戻す。 + rollback.push(f"作成した受信者リスト {path} を削除する", + lambda p=path: p.unlink()) + else: + rollback.push(f"受信者リスト {path} を元の内容へ戻す", + lambda p=path, b=before: _write_blob(p, b)) + + +def _replace_ciphertexts(store: SecretStore, + prepared: Sequence[Tuple[SecretRef, bytes, bytes]], + rollback: Rollback) -> None: + """用意済みの暗号文でファイルを差し替える (取り消し: 旧バイト列を書き戻す)""" + for ref, old_blob, new_blob in prepared: + path = store.age.path(ref) + try: + _write_blob(path, new_blob) + except OSError as e: + raise EnvOpsError( + f"{ref.label()}の再暗号化に失敗しました ({path}): {e}") from e + rollback.push(f"{ref.label()}の暗号文 {path} を元の内容へ戻す", + lambda p=path, b=old_blob: _write_blob(p, b)) + logger.info("%s を再暗号化しました", ref.label()) + + +# --------------------------------------------------------------------------- +# doctor +# --------------------------------------------------------------------------- + +@dataclass +class Finding: + """点検で見つかった問題""" + + level: str # 'error' | 'warning' + title: str + detail: str = '' + hint: str = '' + + +@dataclass +class Report: + findings: List[Finding] = field(default_factory=list) + checked: List[str] = field(default_factory=list) + + def add(self, level: str, title: str, detail: str = '', hint: str = '') -> None: + self.findings.append(Finding(level, title, detail, hint)) + + @property + def errors(self) -> List[Finding]: + return [f for f in self.findings if f.level == 'error'] + + +#: 平文が残りやすい場所。移行や過去のバックアップで取り残される。 +_PLAINTEXT_GLOBS = ( + '.env.bak*', + '.env.backup*', + '.env.orig', + '.env.save', +) + +#: 除外できているかを Git に確かめてもらう代表パス (``DEVBASE_ROOT`` からの相対)。 +#: パターンの書き方ではなく「このパスが実際に除外されるか」で見る。 +_IGNORE_PROBE_PATHS = ( + # 共通の平文 + '.env', + # 日時付きの控え。実際に未追跡のまま検出された名前をそのまま使う + '.env.bak-20260807172231', + # 暗号文の保存先 + 'secrets/global.env.age', + 'secrets/projects/sample.env.age', + # ``secrets/*.age`` のように配下の一部だけを除外していると漏れる位置 + 'secrets/leftover.env', +) + +#: ``projects/`` が空のときに使うプロジェクト名。``projects//.env`` が +#: 除外されるかは実在のプロジェクトが無くても確かめたい。 +_SAMPLE_PROJECT_NAME = 'sample' + + +def _mode_of(path: Path) -> Optional[int]: + try: + return stat.S_IMODE(path.stat().st_mode) + except OSError: + return None + + +def _check_key(report: Report) -> None: + key_file = agekeys.key_file_path() + report.checked.append(f"鍵ファイル: {key_file}") + if not key_file.exists(): + report.add('warning', '暗号化に使う鍵がありません', + f'{key_file} が存在しません', + '`devbase env keygen` で生成してください') + return + + mode = _mode_of(key_file) + if mode is not None and mode & 0o077: + report.add('error', '鍵ファイルが他ユーザーから読めます', + f'{key_file} (mode {mode:04o})', + f'chmod 600 {key_file}') + + dir_mode = _mode_of(key_file.parent) + if dir_mode is not None and dir_mode & 0o077: + report.add('warning', '鍵の置き場が他ユーザーからアクセスできます', + f'{key_file.parent} (mode {dir_mode:04o})', + f'chmod 700 {key_file.parent}') + + +def _check_conflicts(root: Path, store: SecretStore, report: Report) -> None: + """暗号化ファイルと平文が同時に存在していないか""" + refs = [SecretRef.for_global()] + projects_dir = root / 'projects' + if projects_dir.is_dir(): + refs.extend(SecretRef.for_project(p.name) + for p in sorted(projects_dir.iterdir()) if p.is_dir()) + + for ref in refs: + if store.age.exists(ref) and store.plaintext.exists(ref): + report.add('error', f'{ref.label()}の機密が暗号化・平文の両方にあります', + f'暗号化: {store.age.path(ref)}\n' + f' 平文: {store.plaintext.path(ref)}', + 'どちらが正しいか確認し、不要な方を削除してください') + + +def _check_leftovers(root: Path, store: SecretStore, report: Report) -> None: + """移行で取り残された平文を探す""" + encrypted = store.age.exists(SecretRef.for_global()) or bool(store.project_names()) + + backups = root / 'backups' + for name, label in (('env-encrypt', '暗号化への移行時に退避した平文'), + ('env-import', '取り込み時に退避した設定')): + base = backups / name + if not base.is_dir(): + continue + found = [p for p in sorted(base.rglob('*')) if p.is_file()] + plain = [p for p in found if p.suffix != '.age'] + if plain and (encrypted or name == 'env-encrypt'): + report.add('warning', f'{label}が残っています', + '\n '.join(str(p) for p in plain[:10]) + + (f'\n ... 他 {len(plain) - 10} 件' if len(plain) > 10 else ''), + f'内容を確認したうえで削除してください: rm -rf {base}') + + stale: List[Path] = [] + for pattern in _PLAINTEXT_GLOBS: + stale.extend(sorted(root.glob(pattern))) + projects_dir = root / 'projects' + if projects_dir.is_dir(): + stale.extend(sorted(projects_dir.glob(f'*/{pattern}'))) + if stale: + report.add('warning', '平文の控えファイルが残っています', + '\n '.join(str(p) for p in stale), + '不要なら削除してください') + + +def _git_check_ignore(root: Path, rel_path: str) -> Optional[bool]: + """``rel_path`` が Git の除外設定で無視されるか (判定できなければ ``None``)。 + + ``.gitignore`` の解釈は Git の実装が正であり、独自に真似ると必ず食い違う。 + たとえば Git は行頭の ``#`` だけをコメントとして扱うので ``.env # 機密`` は + 「``.env # 機密`` というパターン」であって ``.env`` を除外しないし、後ろに + ``!.env`` があれば再包含されて除外は取り消される。文字列を自前で正規化して + 「除外できている」と誤って判定すると平文の誤コミットに直結するため、判定は + Git 自身に任せる。 + + - ``--no-index``: まだ存在しないパスや、すでに追跡済みのパスであっても + 除外設定だけで評価させる (追跡済みだと既定では何も報告されない) + - 作業ディレクトリは ``DEVBASE_ROOT``。除外設定は評価するパスの位置で + 決まるため、必ず点検対象のリポジトリの中で実行する + - 終了コード 0 = 除外される / 1 = 除外されない / それ以外 (128 など) は + git が無い・Git リポジトリでないといった「判定できない」状態 + """ + try: + proc = subprocess.run( + ['git', 'check-ignore', '--no-index', '-q', '--', rel_path], + cwd=str(root), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + except (OSError, ValueError): + # git が入っていない / 実行できない。例外で点検全体を落とさない。 + return None + if proc.returncode == 0: + return True + if proc.returncode == 1: + return False + return None + + +def _ignore_probe_paths(root: Path) -> List[str]: + """除外されているか確かめる代表パスを組み立てる""" + paths = list(_IGNORE_PROBE_PATHS) + + # プロジェクトごとの平文。実在するものがあればその名前で確かめるほうが、 + # 報告をそのまま直す手がかりにできる。 + names: List[str] = [] + projects_dir = root / 'projects' + if projects_dir.is_dir(): + names = [p.name for p in sorted(projects_dir.iterdir()) if p.is_dir()] + paths.extend(f'projects/{name}/.env' for name in names or [_SAMPLE_PROJECT_NAME]) + return paths + + +def _check_gitignore(root: Path, report: Report) -> None: + """平文が置かれうるパスが実際に除外されるかを Git に確かめてもらう""" + path = root / '.gitignore' + report.checked.append(f"除外設定: {path} (git check-ignore で確認)") + + exposed: List[str] = [] + for rel in _ignore_probe_paths(root): + ignored = _git_check_ignore(root, rel) + if ignored is None: + # 「確認できなかった」と「問題なし」を混同しない。ここで独自の + # 文字列判定へ落とすと、Git と食い違う判定が復活してしまう。 + report.add('warning', '除外設定を確認できませんでした', + f'{root} で git check-ignore を実行できません ' + '(git が無い、または Git リポジトリではありません)', + 'Git 管理下で `git check-ignore -v .env secrets/global.env.age` ' + 'を実行し、除外されることを確かめてください') + return + if not ignored: + exposed.append(rel) + + if exposed: + report.add('error', '除外設定から漏れているパスがあります', + '除外されない: ' + '\n '.join(exposed), + f'{path} へ `.env` / `.env.bak*` / `secrets/` などを追記し、' + '`git check-ignore -v <パス>` で除外されることを確かめてください') + + +def cmd_env_doctor(devbase_root: Path) -> int: + """端末上に残る平文と設定の穴を点検する""" + root = Path(devbase_root) + store = SecretStore(root) + report = Report() + + _check_key(report) + _check_conflicts(root, store, report) + _check_leftovers(root, store, report) + _check_gitignore(root, report) + + print("\n=== devbase env doctor ===") + for line in report.checked: + print(f" 確認: {line}") + + if not report.findings: + print("\n問題は見つかりませんでした") + return 0 + + print() + for finding in report.findings: + marker = '✗' if finding.level == 'error' else '!' + print(f"{marker} {finding.title}") + if finding.detail: + print(f" {finding.detail}") + if finding.hint: + print(f" → {finding.hint}") + + errors = len(report.errors) + warnings = len(report.findings) - errors + print(f"\n問題 {errors} 件 / 注意 {warnings} 件") + # 問題があれば非ゼロで返す。定期実行して気付ける形にするため。 + return 1 if report.findings else 0 diff --git a/lib/devbase/env/_import_merge.py b/lib/devbase/env/_import_merge.py index 0fa019ff..a586a966 100644 --- a/lib/devbase/env/_import_merge.py +++ b/lib/devbase/env/_import_merge.py @@ -227,7 +227,9 @@ def _plan_replace_keys(incoming: Dict[str, str], existing: Dict[str, str], def plan_env_merge(target: Path, incoming_bytes: bytes, arcname: str, *, merge: str = 'keep-existing', replace: bool = False, - replace_keys: Sequence[str] = ()) -> Plan: + replace_keys: Sequence[str] = (), + existing_bytes: Optional[bytes] = None, + target_exists: Optional[bool] = None) -> Plan: """1 つの ``.env`` に対する merge / replace 計画を作る 新規作成 (= target 不在) ケースでは ``incoming_bytes`` をそのまま採用する。 @@ -239,8 +241,13 @@ def plan_env_merge(target: Path, incoming_bytes: bytes, arcname: str, *, 既存のコメント / 空行 / キー順を保持したまま値だけ差し替える (PR #15 gemini 指摘)。 """ incoming = EnvFile.parse_bytes(incoming_bytes) - target_exists = target.exists() - existing_bytes = target.read_bytes() if target_exists else b'' + # 既存内容は呼び出し側から渡せる。保存先が暗号化されている場合、ファイルを + # そのまま読むと暗号文を .env として解釈してしまうため、秘密ストア越しに + # 復号したバイト列を渡してもらう (呼び出し側が渡さなければ従来どおり読む)。 + if target_exists is None: + target_exists = target.exists() + if existing_bytes is None: + existing_bytes = target.read_bytes() if target_exists else b'' existing = EnvFile.parse_bytes(existing_bytes) if target_exists else {} if replace: diff --git a/lib/devbase/env/bundle.py b/lib/devbase/env/bundle.py index e957096d..76c81493 100644 --- a/lib/devbase/env/bundle.py +++ b/lib/devbase/env/bundle.py @@ -230,17 +230,30 @@ def make_entries_from_disk(devbase_root, """ from pathlib import Path + from devbase.env.secret_store import SecretRef, SecretStore + devbase_root = Path(devbase_root) entries: List[BundleEntry] = [] + # 保存先は秘密ストアに聞く。暗号化済みの機密でも export できるようにするため、 + # ファイルパスを直接読まずに復号後のバイト列を受け取る。バンドル自体は age で + # 暗号化されるので、ここで平文に戻しても保存時の平文は生まれない。 + store = SecretStore(devbase_root) + + def _origin(path) -> str: + """保存先を ``$DEVBASE_ROOT`` 相対の表記へ直す (manifest の可読性のため)""" + try: + return f'$DEVBASE_ROOT/{Path(path).relative_to(devbase_root)}' + except ValueError: + return str(path) + if include_global: - global_env = devbase_root / '.env' - # is_file() でディレクトリ等を除外し、IsADirectoryError 等の例外を防ぐ - if global_env.is_file(): + global_ref = SecretRef.for_global() + if store.exists(global_ref): entries.append(BundleEntry( arcname='env/global.env', - origin='$DEVBASE_ROOT/.env', - data=global_env.read_bytes(), + origin=_origin(store.path(global_ref)), + data=store.load_bytes(global_ref), )) if include_metadata: @@ -282,12 +295,12 @@ def make_entries_from_disk(devbase_root, name, proj_dir, ) continue - env_path = proj_dir / '.env' - if env_path.is_file(): + project_ref = SecretRef.for_project(name) + if store.exists(project_ref): entries.append(BundleEntry( arcname=f'env/projects/{name}/.env', - origin=f'$DEVBASE_ROOT/projects/{name}/.env', - data=env_path.read_bytes(), + origin=_origin(store.path(project_ref)), + data=store.load_bytes(project_ref), )) return entries diff --git a/lib/devbase/env/io_import.py b/lib/devbase/env/io_import.py index 6107e1f1..344a7dcc 100644 --- a/lib/devbase/env/io_import.py +++ b/lib/devbase/env/io_import.py @@ -114,29 +114,67 @@ def _decrypt_if_needed(blob: bytes, opts: ImportOptions) -> bytes: return _cipher.decrypt(blob, identities=identities) +def _secret_ref_for(arcname: str): + """バンドル内 arcname に対応する秘密ストアの参照 (機密でなければ ``None``)""" + from devbase.env.secret_store import SecretRef + + if arcname == 'env/global.env': + return SecretRef.for_global() + match = _merge._PROJECT_ENV_RE.match(arcname) + if match: + return SecretRef.for_project(match.group(1)) + return None + + def _build_plans( filtered: dict, devbase_root: Path, opts: ImportOptions ) -> Tuple[List[_merge.Plan], Optional[Tuple[Path, bytes]]]: - """フィルタ済みメンバーから書き出し計画と sources.yml の参照用コピー対象を返す""" + """フィルタ済みメンバーから書き出し計画と sources.yml の参照用コピー対象を返す + + 機密の書き出し先は秘密ストアに聞く。暗号化されている環境へ import したときに + 平文の ``.env`` を作ってしまうと、暗号化ファイルと平文が同時に存在する状態に + なり、以後どちらが正か判断できなくなる (plan35 §9)。既存内容の読み取りと + 書き出しの両方をストア越しに行い、保存形式を維持する。 + """ + from dataclasses import replace as _dc_replace + + from devbase.env.secret_store import SecretStore + + store = SecretStore(devbase_root) plans: List[_merge.Plan] = [] sources_reference: Optional[Tuple[Path, bytes]] = None try: for arcname, data in sorted(filtered.items()): - target = _merge.target_for(arcname, devbase_root) if arcname == 'env/sources.yml': + target = _merge.target_for(arcname, devbase_root) plan = _merge.plan_sources(target, data, merge_metadata=opts.merge_metadata) if plan is not None: plans.append(plan) else: sources_reference = (target, data) - else: - plans.append(_merge.plan_env_merge( - target, data, arcname, - merge=opts.merge, - replace=opts.replace, - replace_keys=opts.replace_keys, - )) + continue + + ref = _secret_ref_for(arcname) + if ref is None: + raise _merge.MergeError(f"未対応のバンドルエントリ: {arcname}") + + exists = store.exists(ref) + plan = _merge.plan_env_merge( + store.path(ref), data, arcname, + merge=opts.merge, + replace=opts.replace, + replace_keys=opts.replace_keys, + existing_bytes=store.load_bytes(ref) if exists else b'', + target_exists=exists, + ) + if store.is_encrypted(ref): + # merge の結果は平文のバイト列なので、暗号化されている保存先へ + # 書く前にここで暗号文へ変換する。以降の原子的書き込み・ + # ロールバックはバイト列とパスだけを扱うため、そのまま通せる。 + plan = _dc_replace( + plan, new_bytes=store.age.encrypt_bytes(plan.new_bytes)) + plans.append(plan) except _merge.MergeError as e: raise ImportError(str(e)) from e return plans, sources_reference diff --git a/lib/devbase/env/rollback.py b/lib/devbase/env/rollback.py new file mode 100644 index 00000000..96493426 --- /dev/null +++ b/lib/devbase/env/rollback.py @@ -0,0 +1,61 @@ +"""複数の破壊的な操作を「途中失敗で不整合を残さない」単位にまとめる仕組み + +機密の移行 (``env encrypt`` / ``decrypt``) も受信者の更新 (``env rekey``) も、 +複数のファイルを書き換えて初めて意味を持つ操作である。「全部検証してから全部 +実行する」とフェーズを分けるだけでは、実行フェーズの途中で失敗したぶんが +中間状態として残る。そこで **操作を 1 つ実行するたびにその取り消し手続きを積み**、 +どこで失敗しても逆順に巻き戻せるようにする。 + +実装は元々 ``commands/env_migrate`` の内部クラスだったが、``env rekey`` でも +同じ保証が要る (受信者リストだけ更新され、暗号文の一部が旧受信者宛のまま残ると、 +自分の鍵を外す操作では再実行すらできなくなる) ため、共有モジュールへ移した。 +""" + +from __future__ import annotations + +from typing import Callable, List, Tuple + +from devbase.log import get_logger + +logger = get_logger(__name__) + + +class Rollback: + """実行した操作の取り消し手続きを積み、失敗時に逆順で実行する。 + + 使う側は「破壊的な操作をできるだけ後ろへ寄せる」ことと合わせて設計する。 + 先に失うものが少ない操作から実行しておけば、途中で失敗しても巻き戻しは + 「作ったものを消す」だけで済み、復旧の余地が広く残る。 + """ + + def __init__(self) -> None: + self._undo: List[Tuple[str, Callable[[], None]]] = [] + + def push(self, description: str, undo: Callable[[], None]) -> None: + """実行済みの操作に対する取り消し手続きを積む。 + + Args: + description: 取り消しが何をするか (巻き戻しに失敗したときに + 「何が残っているか」として利用者へ見せる) + undo: 取り消し手続き + """ + self._undo.append((description, undo)) + + def unwind(self) -> None: + """積んだ取り消し手続きを逆順に実行する。 + + 後の操作は前の操作を前提にしているため、必ず逆順で戻す。巻き戻しの + 途中で失敗しても残りは試みるが、**握り潰さずに何が残っているかを + 具体的に列挙する**。ここで黙ると、利用者は壊れた状態に気付けない。 + """ + failures: List[str] = [] + for description, undo in reversed(self._undo): + try: + undo() + except Exception as e: # 1 つ失敗しても残りの巻き戻しは続ける + failures.append(f" - {description}: {e}") + self._undo.clear() + if failures: + logger.error( + "巻き戻しに失敗しました。次の操作が完了しておらず、" + "手動での復旧が必要です:\n%s", "\n".join(failures)) diff --git a/lib/devbase/env/secret_store.py b/lib/devbase/env/secret_store.py index 5c720d09..2f09ca30 100644 --- a/lib/devbase/env/secret_store.py +++ b/lib/devbase/env/secret_store.py @@ -237,15 +237,23 @@ def load_bytes(self, ref: SecretRef) -> bytes: f"{ref.label()}の機密を復号できませんでした ({path}): {e}" ) from e - def save_bytes(self, ref: SecretRef, data: bytes) -> Path: - """バイト列を **加工せずそのまま** 暗号化して保存する""" - path = self.path(ref) + def encrypt_bytes(self, data: bytes) -> bytes: + """バイト列を受信者宛に暗号化して返す (保存はしない)。 + + 書き出し先を自前で扱う処理 (``devbase env import`` の原子的な書き込み等) + が、暗号化だけをこの層に任せられるようにする。 + """ try: - blob = _cipher.encrypt(data, recipients=self.recipients()) + return _cipher.encrypt(data, recipients=self.recipients()) except _cipher.CipherError as e: raise SecretStoreError( - f"{ref.label()}の機密を暗号化できませんでした: {e}" + f"機密を暗号化できませんでした: {e}" ) from e + + def save_bytes(self, ref: SecretRef, data: bytes) -> Path: + """バイト列を **加工せずそのまま** 暗号化して保存する""" + path = self.path(ref) + blob = self.encrypt_bytes(data) try: # 暗号文は失うと復旧不能なので、既存ファイルを直接 O_TRUNC せず # 一時ファイル → fsync → os.replace で差し替える。ディスク枯渇や中断が diff --git a/tests/commands/test_env_ops.py b/tests/commands/test_env_ops.py new file mode 100644 index 00000000..2abdbfa0 --- /dev/null +++ b/tests/commands/test_env_ops.py @@ -0,0 +1,412 @@ +"""env rekey / doctor: 受信者の更新と、端末に残る平文の点検""" + +from __future__ import annotations + +import os +import shutil +import stat +import subprocess + +import pyrage +import pytest + +from devbase.commands import env_ops +from devbase.env import agekeys +from devbase.env.secret_store import SecretRef, SecretStore + + +GLOBAL = SecretRef.for_global() +WEB = SecretRef.for_project('web') + + +def git_init(path): + """点検用に Git リポジトリを作る。 + + 除外設定の点検は ``git check-ignore`` に委ねているため、テストも実際に + ``git init`` したリポジトリで確かめる。利用者の global / system の除外設定に + 左右されないよう、設定ファイルは空に固定する。 + """ + subprocess.run(['git', 'init', '-q'], cwd=str(path), check=True, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +@pytest.fixture +def root(tmp_path, monkeypatch): + (tmp_path / 'projects' / 'web').mkdir(parents=True) + monkeypatch.setenv(agekeys.KEY_FILE_ENV, str(tmp_path / 'age' / 'keys.txt')) + monkeypatch.setenv('PWD', str(tmp_path)) + monkeypatch.setenv('GIT_CONFIG_GLOBAL', os.devnull) + monkeypatch.setenv('GIT_CONFIG_SYSTEM', os.devnull) + monkeypatch.chdir(tmp_path) + git_init(tmp_path) + return tmp_path + + +@pytest.fixture +def with_key(root): + _, public = agekeys.generate_key_file() + return public + + +@pytest.fixture +def colleague(tmp_path): + """同僚の鍵 (公開鍵と、復号を確かめるための秘密鍵ファイル)""" + identity = pyrage.x25519.Identity.generate() + path = tmp_path / 'colleague.key' + path.write_text(str(identity)) + return str(identity.to_public()), str(path) + + +def seed_encrypted(root): + store = SecretStore(root) + store.age.save(GLOBAL, {'TOKEN': 'sk-1'}) + store.age.save(WEB, {'DB_PASSWORD': 'pw'}) + return store + + +# --------------------------------------------------------------------------- +# rekey +# --------------------------------------------------------------------------- + +def test_rekey_without_a_key_fails(root): + assert env_ops.cmd_env_rekey(root, add=['age1invalid'], assume_yes=True) == 1 + + +def test_rekey_adds_a_recipient_and_reencrypts(root, with_key, colleague): + public, key_path = colleague + seed_encrypted(root) + + assert env_ops.cmd_env_rekey(root, add=[public], assume_yes=True) == 0 + + # 同僚の鍵で読める + reader = SecretStore(root, identities=[key_path]) + assert reader.load(GLOBAL) == {'TOKEN': 'sk-1'} + assert reader.load(WEB) == {'DB_PASSWORD': 'pw'} + # 自分の鍵でも引き続き読める + assert SecretStore(root).load(GLOBAL) == {'TOKEN': 'sk-1'} + + +def test_rekey_registers_the_own_key_when_the_list_was_empty(root, with_key, colleague): + """リストが無い状態から追加しても、自分が受信者から外れない""" + public, _ = colleague + seed_encrypted(root) + assert agekeys.load_recipients(root) == [] + + env_ops.cmd_env_rekey(root, add=[public], assume_yes=True) + + assert agekeys.load_recipients(root) == [with_key, public] + + +def test_rekey_removes_a_recipient(root, with_key, colleague): + public, key_path = colleague + seed_encrypted(root) + env_ops.cmd_env_rekey(root, add=[public], assume_yes=True) + + assert env_ops.cmd_env_rekey(root, remove=[public], assume_yes=True) == 0 + + assert agekeys.load_recipients(root) == [with_key] + reader = SecretStore(root, identities=[key_path]) + from devbase.env.secret_store import SecretStoreError + + with pytest.raises(SecretStoreError): + reader.load(GLOBAL) + + +def test_rekey_rejects_removing_an_unknown_recipient(root, with_key, colleague): + public, _ = colleague + seed_encrypted(root) + + assert env_ops.cmd_env_rekey(root, remove=[public], assume_yes=True) == 1 + + +def test_rekey_refuses_to_empty_the_list(root, with_key): + seed_encrypted(root) + agekeys.save_recipients(root, [with_key]) + + assert env_ops.cmd_env_rekey(root, remove=[with_key], assume_yes=True) == 1 + assert agekeys.load_recipients(root) == [with_key] + + +def test_rekey_reports_no_change(root, with_key, capsys): + seed_encrypted(root) + agekeys.save_recipients(root, [with_key]) + + assert env_ops.cmd_env_rekey(root, add=[with_key], assume_yes=True) == 0 + assert '受信者に変更はありません' in capsys.readouterr().out + + +def test_rekey_dry_run_changes_nothing(root, with_key, colleague): + public, key_path = colleague + store = seed_encrypted(root) + before = store.age.path(GLOBAL).read_bytes() + + assert env_ops.cmd_env_rekey(root, add=[public], dry_run=True) == 0 + + assert agekeys.load_recipients(root) == [] + assert store.age.path(GLOBAL).read_bytes() == before + + +def test_rekey_aborts_without_confirmation(root, with_key, colleague, monkeypatch): + public, _ = colleague + seed_encrypted(root) + monkeypatch.setattr(env_ops, 'safe_input', lambda prompt: 'no') + + assert env_ops.cmd_env_rekey(root, add=[public]) == 1 + assert agekeys.load_recipients(root) == [] + + +def test_rekey_warns_when_dropping_your_own_key(root, with_key, colleague, capsys): + public, _ = colleague + seed_encrypted(root) + agekeys.save_recipients(root, [with_key, public]) + + env_ops.cmd_env_rekey(root, remove=[with_key], dry_run=True) + + assert '自分の公開鍵が受信者から外れています' in capsys.readouterr().out + + +def test_rekey_keeps_the_recipients_when_decryption_fails(root, with_key, + colleague, monkeypatch): + public, _ = colleague + seed_encrypted(root) + + from devbase.env.secret_store import AgeBackend, SecretStoreError + + def broken(self, ref): + raise SecretStoreError('復号できません') + + monkeypatch.setattr(AgeBackend, 'load_bytes', broken) + + assert env_ops.cmd_env_rekey(root, add=[public], assume_yes=True) == 1 + assert agekeys.load_recipients(root) == [] + + +def test_rekey_rolls_back_when_a_later_rewrite_fails(root, with_key, colleague, + monkeypatch): + """途中で書き込みに失敗しても、受信者リストも暗号文も元のまま残る""" + public, key_path = colleague + store = seed_encrypted(root) + agekeys.save_recipients(root, [with_key]) + before_global = store.age.path(GLOBAL).read_bytes() + before_web = store.age.path(WEB).read_bytes() + + # 2 件目 (プロジェクト web) の差し替えだけを失敗させる + original = env_ops._write_blob + web_path = store.age.path(WEB) + + def fail_on_web(path, blob): + if path == web_path: + raise OSError('ディスクがいっぱいです') + return original(path, blob) + + monkeypatch.setattr(env_ops, '_write_blob', fail_on_web) + + assert env_ops.cmd_env_rekey(root, add=[public], assume_yes=True) == 1 + + # 受信者リストは元のまま + assert agekeys.load_recipients(root) == [with_key] + # 1 件目も元の暗号文へ戻っている (同僚の鍵ではまだ読めない) + assert store.age.path(GLOBAL).read_bytes() == before_global + assert store.age.path(WEB).read_bytes() == before_web + # 旧受信者 (自分) の鍵で引き続き全件読める + assert SecretStore(root).load(GLOBAL) == {'TOKEN': 'sk-1'} + assert SecretStore(root).load(WEB) == {'DB_PASSWORD': 'pw'} + + from devbase.env.secret_store import SecretStoreError + + reader = SecretStore(root, identities=[key_path]) + with pytest.raises(SecretStoreError): + reader.load(GLOBAL) + + +def test_rekey_removes_the_created_recipients_file_on_rollback(root, with_key, + colleague, + monkeypatch): + """リストが無い状態から始めた場合、巻き戻しで作ったリストごと消える""" + public, _ = colleague + store = seed_encrypted(root) + assert not agekeys.recipients_file(root).exists() + + original = env_ops._write_blob + web_path = store.age.path(WEB) + + def fail_on_web(path, blob): + if path == web_path: + raise OSError('ディスクがいっぱいです') + return original(path, blob) + + monkeypatch.setattr(env_ops, '_write_blob', fail_on_web) + + assert env_ops.cmd_env_rekey(root, add=[public], assume_yes=True) == 1 + assert not agekeys.recipients_file(root).exists() + + +def test_rekey_rewrites_every_secret_on_success(root, with_key, colleague): + """成功時は全件が新しい受信者で読める""" + public, key_path = colleague + seed_encrypted(root) + + assert env_ops.cmd_env_rekey(root, add=[public], assume_yes=True) == 0 + + reader = SecretStore(root, identities=[key_path]) + assert reader.load(GLOBAL) == {'TOKEN': 'sk-1'} + assert reader.load(WEB) == {'DB_PASSWORD': 'pw'} + assert agekeys.load_recipients(root) == [with_key, public] + + +# --------------------------------------------------------------------------- +# doctor +# --------------------------------------------------------------------------- + +def write_gitignore(root, *extra): + lines = ['.env', '.env.bak*', 'secrets/', *extra] + (root / '.gitignore').write_text('\n'.join(lines) + '\n') + + +def test_doctor_is_quiet_on_a_healthy_setup(root, with_key, capsys): + seed_encrypted(root) + write_gitignore(root) + + assert env_ops.cmd_env_doctor(root) == 0 + assert '問題は見つかりませんでした' in capsys.readouterr().out + + +def test_doctor_reports_both_formats_present(root, with_key, capsys): + store = seed_encrypted(root) + store.plaintext.save(GLOBAL, {'TOKEN': 'plain'}) + write_gitignore(root) + + assert env_ops.cmd_env_doctor(root) == 1 + out = capsys.readouterr().out + assert '両方にあります' in out + assert '問題 1 件' in out + + +def test_doctor_reports_leftover_migration_backups(root, with_key, capsys): + seed_encrypted(root) + write_gitignore(root) + backup = root / 'backups' / 'env-encrypt' / '20260101000000' + backup.mkdir(parents=True) + (backup / 'global.env').write_text('TOKEN=sk-1\n') + + assert env_ops.cmd_env_doctor(root) == 1 + assert '退避した平文が残っています' in capsys.readouterr().out + + +def test_doctor_ignores_encrypted_backups(root, with_key, capsys): + seed_encrypted(root) + write_gitignore(root) + backup = root / 'backups' / 'env-import' / 'dbenv-1' + backup.mkdir(parents=True) + (backup / 'global.env.age').write_bytes(b'ciphertext') + + assert env_ops.cmd_env_doctor(root) == 0 + + +def test_doctor_reports_stale_plaintext_copies(root, with_key, capsys): + seed_encrypted(root) + write_gitignore(root) + (root / '.env.bak-20260807172231').write_text('TOKEN=sk-1\n') + + assert env_ops.cmd_env_doctor(root) == 1 + assert '平文の控えファイルが残っています' in capsys.readouterr().out + + +def test_doctor_reports_which_paths_are_not_ignored(root, with_key, capsys): + """不足はパターン名ではなく、除外されない実パスで報告する""" + seed_encrypted(root) + (root / '.gitignore').write_text('.env\n.env.bak*\n') # secrets/ が無い + + assert env_ops.cmd_env_doctor(root) == 1 + out = capsys.readouterr().out + assert '除外設定から漏れているパスがあります' in out + assert 'secrets/global.env.age' in out + + +def test_doctor_reports_wildcardless_backup_pattern(root, with_key, capsys): + """日時付きの控えは完全一致では弾けない""" + seed_encrypted(root) + (root / '.gitignore').write_text('.env\n.env.bak\nsecrets/\n') + + assert env_ops.cmd_env_doctor(root) == 1 + out = capsys.readouterr().out + assert '除外設定から漏れているパスがあります' in out + assert '.env.bak-20260807172231' in out + + +@pytest.mark.parametrize('body', [ + '/.env\n/.env.bak*\n/secrets/\n/projects/*\n', # ルート指定 + '.env\n.env.bak*\nsecrets\n', # 末尾スラッシュ無し + '**/.env\n**/.env.bak*\n/secrets/\n', # 任意階層 + '.env\n.env*\nsecrets/\n', # 控えを広く拾う指定 + '# 機密は暗号化して secrets/ へ\n\n.env\n.env.bak*\nsecrets/\n', # 行頭コメント・空行 +]) +def test_doctor_accepts_equivalent_ignore_notations(root, with_key, capsys, body): + """Git が実際に除外できている書き方は「漏れ」と誤検知しない""" + seed_encrypted(root) + (root / '.gitignore').write_text(body) + + assert env_ops.cmd_env_doctor(root) == 0 + assert '問題は見つかりませんでした' in capsys.readouterr().out + + +@pytest.mark.parametrize('body', [ + # Git は行頭の `#` だけをコメントとして扱う。`.env # 機密` は + # 「`.env # 機密`」というパターンであって `.env` を除外しない + '.env # 機密\n.env.bak*\nsecrets/\n', + # 後段の `!` で再包含されると除外は取り消される + '.env\n!.env\n.env.bak*\nsecrets/\n', + # 行頭の空白は落とされない (落ちるのは行末だけ) + ' .env\n.env.bak*\nsecrets/\n', +]) +def test_doctor_reports_patterns_git_does_not_honor(root, with_key, capsys, body): + """Git の解釈では除外できていない書き方を「問題なし」にしない""" + seed_encrypted(root) + (root / '.gitignore').write_text(body) + + assert env_ops.cmd_env_doctor(root) == 1 + out = capsys.readouterr().out + assert '除外設定から漏れているパスがあります' in out + assert '除外されない: .env' in out + + +def test_doctor_reports_partially_ignored_secrets_dir(root, with_key, capsys): + """`secrets/*.age` だけでは配下の平文が漏れる""" + seed_encrypted(root) + (root / '.gitignore').write_text('.env\n.env.bak*\nsecrets/*.age\n') + + assert env_ops.cmd_env_doctor(root) == 1 + out = capsys.readouterr().out + assert '除外設定から漏れているパスがあります' in out + assert 'secrets/leftover.env' in out + + +def test_doctor_cannot_check_ignores_without_a_git_repository(root, with_key, capsys): + """Git リポジトリでなければ「確認できなかった」と言う (成功にしない)""" + seed_encrypted(root) + write_gitignore(root) + shutil.rmtree(root / '.git') + + assert env_ops.cmd_env_doctor(root) == 1 + out = capsys.readouterr().out + assert '除外設定を確認できませんでした' in out + assert '問題は見つかりませんでした' not in out + + +def test_doctor_reports_a_world_readable_key(root, with_key, capsys): + seed_encrypted(root) + write_gitignore(root) + key_file = agekeys.key_file_path() + os.chmod(key_file, 0o644) + + assert env_ops.cmd_env_doctor(root) == 1 + out = capsys.readouterr().out + assert '鍵ファイルが他ユーザーから読めます' in out + assert stat.S_IMODE(key_file.stat().st_mode) == 0o644 # 勝手に直さない + + +def test_doctor_reports_a_missing_key(root, capsys): + write_gitignore(root) + + assert env_ops.cmd_env_doctor(root) == 1 + assert '暗号化に使う鍵がありません' in capsys.readouterr().out diff --git a/tests/env/test_store_roundtrip.py b/tests/env/test_store_roundtrip.py new file mode 100644 index 00000000..e336c073 --- /dev/null +++ b/tests/env/test_store_roundtrip.py @@ -0,0 +1,142 @@ +"""export / import が暗号化された保存先を壊さないことの検証 + +移行後の環境で ``devbase env import`` が平文の ``.env`` を作ってしまうと、 +暗号化ファイルと平文が同時に存在する状態になり、以後どちらが正か判断できなく +なる (plan35 §9)。往復しても保存形式が保たれることを確かめる。 +""" + +from __future__ import annotations + +import pyrage +import pytest + +from devbase.env import agekeys +from devbase.env.io_export import ExportOptions, export +from devbase.env.io_import import ImportOptions, import_bundle +from devbase.env.secret_store import SecretRef, SecretStore + + +GLOBAL = SecretRef.for_global() +WEB = SecretRef.for_project('web') + + +@pytest.fixture +def keypair(tmp_path): + identity = pyrage.x25519.Identity.generate() + path = tmp_path / 'bundle.key' + path.write_text(str(identity)) + return str(identity.to_public()), str(path) + + +@pytest.fixture +def root(tmp_path, monkeypatch): + root = tmp_path / 'devbase' + (root / 'projects' / 'web').mkdir(parents=True) + monkeypatch.setenv(agekeys.KEY_FILE_ENV, str(tmp_path / 'age' / 'keys.txt')) + monkeypatch.setenv('PWD', str(root)) + monkeypatch.chdir(root) + agekeys.generate_key_file() + return root + + +def do_export(root, dest, recipient): + return export(root, ExportOptions(dest=str(dest), recipients=[recipient])) + + +def do_import(root, source, identity, **kwargs): + return import_bundle(root, ImportOptions( + source=str(source), identities=[identity], **kwargs)) + + +def test_export_reads_the_encrypted_store(root, keypair, tmp_path): + public, key = keypair + store = SecretStore(root) + store.age.save(GLOBAL, {'TOKEN': 'sk-1'}) + store.age.save(WEB, {'DB_PASSWORD': 'pw'}) + + bundle = tmp_path / 'b.dbenv' + assert do_export(root, bundle, public) == 0 + + # 別の空の DEVBASE_ROOT へ取り込むと平文で復元される (移行前と同じ形) + other = tmp_path / 'other' + (other / 'projects' / 'web').mkdir(parents=True) + assert do_import(other, bundle, key) == 0 + assert SecretStore(other).load(GLOBAL) == {'TOKEN': 'sk-1'} + assert SecretStore(other).load(WEB) == {'DB_PASSWORD': 'pw'} + + +def test_import_keeps_the_destination_encrypted(root, keypair, tmp_path): + public, key = keypair + store = SecretStore(root) + store.age.save(GLOBAL, {'TOKEN': 'old', 'KEEP': '1'}) + + bundle = tmp_path / 'b.dbenv' + do_export(root, bundle, public) + + # バンドルの値を書き換えて取り込む + store.age.save(GLOBAL, {'TOKEN': 'newer'}) + assert do_import(root, bundle, key, merge='prefer-incoming') == 0 + + assert store.is_encrypted(GLOBAL) + assert not (root / '.env').exists() # 平文が生まれていない + assert store.load(GLOBAL)['TOKEN'] == 'old' # バンドル側が勝つ + assert store.load(GLOBAL)['KEEP'] == '1' + + +def test_import_merges_into_the_encrypted_store(root, keypair, tmp_path): + public, key = keypair + store = SecretStore(root) + store.age.save(GLOBAL, {'FROM_BUNDLE': 'b'}) + + bundle = tmp_path / 'b.dbenv' + do_export(root, bundle, public) + + store.age.save(GLOBAL, {'LOCAL_ONLY': 'l'}) + assert do_import(root, bundle, key) == 0 + + merged = store.load(GLOBAL) + assert merged == {'LOCAL_ONLY': 'l', 'FROM_BUNDLE': 'b'} + + +def test_import_backups_are_ciphertext(root, keypair, tmp_path): + """取り込み前の控えが平文でディスクに残らない (plan35 §2.3)""" + public, key = keypair + store = SecretStore(root) + store.age.save(GLOBAL, {'TOKEN': 'secret-value'}) + + bundle = tmp_path / 'b.dbenv' + do_export(root, bundle, public) + do_import(root, bundle, key, merge='prefer-incoming') + + backups = list((root / 'backups' / 'env-import').rglob('*')) + files = [p for p in backups if p.is_file()] + assert files, '控えが作られていない' + for path in files: + assert b'secret-value' not in path.read_bytes(), path + + +def test_import_into_a_plaintext_store_stays_plaintext(root, keypair, tmp_path): + """移行していない環境では従来どおり平文へ書く""" + public, key = keypair + store = SecretStore(root) + store.plaintext.save(GLOBAL, {'TOKEN': 'sk-1'}) + + bundle = tmp_path / 'b.dbenv' + do_export(root, bundle, public) + assert do_import(root, bundle, key, merge='prefer-incoming') == 0 + + assert (root / '.env').exists() + assert not (root / 'secrets' / 'global.env.age').exists() + + +def test_import_creates_plaintext_when_nothing_exists(root, keypair, tmp_path): + """まだ何も無い参照は平文に落ちる (暗号化は encrypt の役目)""" + public, key = keypair + SecretStore(root).plaintext.save(GLOBAL, {'TOKEN': 'sk-1'}) + bundle = tmp_path / 'b.dbenv' + do_export(root, bundle, public) + + other = tmp_path / 'fresh' + other.mkdir() + assert do_import(other, bundle, key) == 0 + assert (other / '.env').exists()