Skip to content

Commit e7b1464

Browse files
committed
fix(env): PR #13 round4 gemini 指摘対応
- cipher.py: `@PATH` ファイルに複数行の鍵が含まれる場合は明示的に CipherError を投げる。team_keys.txt のような複数公開鍵列挙を 暗黙に「最初の 1 人」だけ扱う挙動は、チーム運用で暗号化バンドル が壊れる原因になるため誤運用を防ぐ - io_common.py: `resolve_identity_specs` で `~/.ssh/id_ed25519` と `~/.ssh/id_rsa` の両方が存在する場合、両方を返すように変更。 `pyrage.decrypt` は複数 identity を受け付け、バンドルに合致する 鍵だけが使われる。これにより RSA で暗号化されたバンドルを ed25519 鍵だけで開けず失敗する問題を解消 - resolve_recipient_specs は意図的に最初の 1 つを返す挙動を維持 (どの鍵で暗号化するか一意に決める必要があるため) - 複数鍵ファイル拒否と複数 identity 復号の回帰テストを追加
1 parent cf00209 commit e7b1464

4 files changed

Lines changed: 160 additions & 5 deletions

File tree

lib/devbase/env/cipher.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,22 @@ def _resolve_recipient(spec: str, _depth: int = 0):
5050
f"recipient ファイルの読み込みに失敗しました ({path}): {e}"
5151
) from e
5252
# ファイル中に複数行 / コメント / 空行が混在していても扱えるよう、
53-
# 空行と '#' で始まるコメント行を除いた最初の有効行を採用する
53+
# 空行と '#' で始まるコメント行を除いた有効行のみを取り出す
5454
valid = [
5555
line.strip() for line in content.splitlines()
5656
if line.strip() and not line.strip().startswith('#')
5757
]
5858
if not valid:
5959
raise CipherError(f"recipient ファイルに有効な行がありません: {path}")
60+
if len(valid) > 1:
61+
# 複数公開鍵を 1 ファイルに列挙したケース (team_keys.txt 等)。
62+
# 暗黙に「最初の 1 人」だけ採用するとチーム運用で暗号化が壊れるため、
63+
# 明示的に複数 `--recipient` で指定するよう要求する (PR #13 gemini 指摘)。
64+
raise CipherError(
65+
f"recipient ファイルに複数行の鍵が含まれています ({path}, {len(valid)} 件)。"
66+
"複数の公開鍵で暗号化したい場合は `--recipient @file_a.pub --recipient @file_b.pub` "
67+
"のように 1 ファイルにつき 1 鍵で指定してください"
68+
)
6069
return _resolve_recipient(valid[0], _depth + 1)
6170

6271
if spec.startswith('age1'):

lib/devbase/env/io_common.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,16 +70,21 @@ def resolve_recipient_specs(specs: Sequence[str]) -> List[str]:
7070
def resolve_identity_specs(specs: Sequence[str]) -> List[str]:
7171
"""identity 指定の解決。
7272
73-
明示指定があればそのまま返す。空なら ``~/.ssh/id_ed25519`` → ``id_rsa`` の
74-
順で存在する秘密鍵を探し、最初に見つかったものを返す。
73+
明示指定があればそのまま返す。空なら ``~/.ssh/id_ed25519`` / ``id_rsa`` の
74+
うち **存在するものをすべて** 返す。``pyrage.decrypt`` は複数 identity を
75+
受け付け、バンドル内の暗号化対象と一致した identity だけ復号に使われるため、
76+
両方を渡しておけば「どの鍵で暗号化されたか分からない」状況でも復号できる
77+
(PR #13 gemini 指摘)。一方 ``resolve_recipient_specs`` は明確に「どの鍵で
78+
暗号化するか」を選ぶ必要があるため最初の 1 つだけを返す (非対称な仕様)。
7579
"""
7680
if specs:
7781
return list(specs)
82+
found: List[str] = []
7883
for path in _cipher.default_identity_paths():
7984
if path.exists():
8085
logger.info("identity 既定鍵を使用: %s", path)
81-
return [str(path)]
82-
return []
86+
found.append(str(path))
87+
return found
8388

8489

8590
def write_secure_bytes(path: Path, data: bytes, *, mode: int = 0o600) -> None:

tests/env/test_cipher.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,25 @@ def test_resolve_recipient_at_path_rejects_only_comments(tmp_path):
126126
cipher.encrypt(b"x", recipients=[f"@{pub_path}"])
127127

128128

129+
def test_resolve_recipient_at_path_rejects_multiple_keys(tmp_path, x25519_keypair):
130+
"""@PATH ファイルに複数の鍵を列挙したら CipherError で明示的に拒否される。
131+
132+
暗黙に最初の 1 行だけ採用すると、`team_keys.txt` のような複数公開鍵ファイル
133+
を渡したケースで「最初の 1 人」だけにしか暗号化されず、他メンバーの復号が
134+
壊れる。誤運用を防ぐため明確にエラーを返す (PR #13 gemini 指摘)。
135+
"""
136+
pub_a, _ = x25519_keypair
137+
# 2 つ目の鍵を別途生成
138+
pub_b = str(pyrage.x25519.Identity.generate().to_public())
139+
140+
team_keys = tmp_path / "team_keys.txt"
141+
team_keys.write_text(
142+
f"# alice\n{pub_a}\n# bob\n{pub_b}\n"
143+
)
144+
with pytest.raises(cipher.CipherError, match="複数行の鍵|1 鍵で指定"):
145+
cipher.encrypt(b"x", recipients=[f"@{team_keys}"])
146+
147+
129148
def test_resolve_recipient_at_path_wraps_oserror(tmp_path, monkeypatch):
130149
"""@PATH の read_text が OSError を投げた場合 CipherError に包んで送出"""
131150
rcpt_path = tmp_path / "rcpt.pub"

tests/env/test_io_common.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""io_common.py: resolve_recipient_specs / resolve_identity_specs の挙動"""
2+
3+
from __future__ import annotations
4+
5+
import pyrage
6+
import pytest
7+
8+
from devbase.env import cipher
9+
from devbase.env import io_common
10+
11+
12+
@pytest.fixture
13+
def fake_home(tmp_path, monkeypatch):
14+
"""``Path.home()`` を ``tmp_path`` に差し替える"""
15+
from pathlib import Path
16+
17+
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
18+
return tmp_path
19+
20+
21+
def test_resolve_recipient_specs_returns_first_existing_default(fake_home):
22+
"""recipient は「どの鍵で暗号化するか」を一意に決める必要があるため、
23+
既定鍵が複数存在しても最初に見つかったものだけ返す (ed25519 を優先)。"""
24+
ssh = fake_home / ".ssh"
25+
ssh.mkdir()
26+
(ssh / "id_ed25519.pub").write_text("ssh-ed25519 AAAA dummy\n")
27+
(ssh / "id_rsa.pub").write_text("ssh-rsa AAAA dummy\n")
28+
29+
specs = io_common.resolve_recipient_specs([])
30+
assert len(specs) == 1
31+
assert specs[0].endswith("id_ed25519.pub")
32+
33+
34+
def test_resolve_recipient_specs_explicit_passthrough(fake_home):
35+
"""明示指定があれば既定鍵探索は行わない (そのまま返す)"""
36+
ssh = fake_home / ".ssh"
37+
ssh.mkdir()
38+
(ssh / "id_ed25519.pub").write_text("ssh-ed25519 AAAA dummy\n")
39+
40+
specs = io_common.resolve_recipient_specs(["age1example"])
41+
assert specs == ["age1example"]
42+
43+
44+
def test_resolve_recipient_specs_returns_empty_when_no_defaults(fake_home):
45+
"""既定鍵が見つからなければ空 list"""
46+
assert io_common.resolve_recipient_specs([]) == []
47+
48+
49+
def test_resolve_identity_specs_returns_all_existing_defaults(fake_home):
50+
"""identity は「どの鍵で暗号化されたか」が事前に分からないため、
51+
存在するすべての既定鍵を返す。``pyrage.decrypt`` は複数 identity を
52+
受け取れる仕様なので、両方渡しておけばどちらの鍵で暗号化されたバンドル
53+
でも復号できる (PR #13 gemini 指摘)。"""
54+
ssh = fake_home / ".ssh"
55+
ssh.mkdir()
56+
(ssh / "id_ed25519").write_text("dummy ed25519 key\n")
57+
(ssh / "id_rsa").write_text("dummy rsa key\n")
58+
59+
specs = io_common.resolve_identity_specs([])
60+
assert len(specs) == 2
61+
# ed25519 が先に来る (default_identity_paths の順序を維持)
62+
assert specs[0].endswith("id_ed25519")
63+
assert specs[1].endswith("id_rsa")
64+
65+
66+
def test_resolve_identity_specs_returns_only_existing(fake_home):
67+
"""片方しか存在しなければそれだけ返す"""
68+
ssh = fake_home / ".ssh"
69+
ssh.mkdir()
70+
(ssh / "id_rsa").write_text("dummy\n")
71+
72+
specs = io_common.resolve_identity_specs([])
73+
assert len(specs) == 1
74+
assert specs[0].endswith("id_rsa")
75+
76+
77+
def test_resolve_identity_specs_explicit_passthrough(fake_home):
78+
"""明示指定があれば既定鍵探索は行わない"""
79+
ssh = fake_home / ".ssh"
80+
ssh.mkdir()
81+
(ssh / "id_ed25519").write_text("dummy\n")
82+
83+
specs = io_common.resolve_identity_specs(["/path/to/explicit.key"])
84+
assert specs == ["/path/to/explicit.key"]
85+
86+
87+
def test_resolve_identity_specs_returns_empty_when_no_defaults(fake_home):
88+
"""既定鍵が一切無ければ空"""
89+
assert io_common.resolve_identity_specs([]) == []
90+
91+
92+
def test_decrypt_uses_correct_identity_from_multiple_defaults(tmp_path, fake_home):
93+
"""``resolve_identity_specs`` が返した複数 identity を ``cipher.decrypt`` に
94+
渡すと、その中から正しい identity が選ばれて復号される。
95+
96+
シナリオ: 既定 ssh 鍵が 2 つ (id_ed25519 / id_rsa) 存在する状況を模した上で、
97+
`id_rsa` (実体は age 鍵) で暗号化したバンドルを「両方の identity を試す」
98+
`cipher.decrypt(identities=[both])` で復号できることを確認する。
99+
`id_ed25519` 側は別 age 鍵で、こちらは復号に使われない。
100+
"""
101+
# 異なる 2 つの age 鍵を用意し、ssh 既定パスに配置して
102+
# resolve_identity_specs から両方が返るようにする
103+
id1 = pyrage.x25519.Identity.generate()
104+
id2 = pyrage.x25519.Identity.generate()
105+
106+
ssh = fake_home / ".ssh"
107+
ssh.mkdir()
108+
ed_path = ssh / "id_ed25519"
109+
rsa_path = ssh / "id_rsa"
110+
ed_path.write_text(str(id1)) # ed25519 スロットに id1
111+
rsa_path.write_text(str(id2)) # rsa スロットに id2 (=暗号化に使う鍵)
112+
113+
# id2 の公開鍵だけで暗号化 → id1 では復号できないバンドル
114+
blob = cipher.encrypt(b"team-secret", recipients=[str(id2.to_public())])
115+
116+
# resolve_identity_specs は両方返す
117+
identities = io_common.resolve_identity_specs([])
118+
assert len(identities) == 2
119+
120+
# 両 identity を渡して復号 → pyrage が正しい鍵 (id2) を選んで復号する
121+
plain = cipher.decrypt(blob, identities=identities)
122+
assert plain == b"team-secret"

0 commit comments

Comments
 (0)