From 97334846af7a475c1409c7da23317c5679dce55b Mon Sep 17 00:00:00 2001 From: yuqei Date: Sun, 27 Sep 2026 04:03:35 +0800 Subject: [PATCH] security: reject reused recovery approval keys --- .../plan-first-release-snapshot-restore.py | 6 ++++ ..._first_release_snapshot_restore_planner.py | 29 +++++++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/yuqei-infra/deploy/plan-first-release-snapshot-restore.py b/yuqei-infra/deploy/plan-first-release-snapshot-restore.py index cc646f36..c5a8f604 100644 --- a/yuqei-infra/deploy/plan-first-release-snapshot-restore.py +++ b/yuqei-infra/deploy/plan-first-release-snapshot-restore.py @@ -681,6 +681,7 @@ def _validate_keyring( if not isinstance(raw_keys, list) or len(raw_keys) < 2: raise SnapshotRestorePlanError("recovery keyring has insufficient keys") result: dict[str, dict[str, Any]] = {} + public_keys: set[bytes] = set() for raw in raw_keys: item = _exact( raw, @@ -708,6 +709,11 @@ def _validate_keyring( public_key = Ed25519PublicKey.from_public_bytes(public_raw) except (TypeError, ValueError) as exc: raise SnapshotRestorePlanError("recovery public key is invalid") from exc + if public_raw in public_keys: + raise SnapshotRestorePlanError( + "recovery keyring reuses an Ed25519 public key across identities" + ) + public_keys.add(public_raw) not_before = _timestamp(item.get("not_before"), label="key not_before") not_after = _timestamp(item.get("not_after"), label="key not_after") if not_after <= not_before: diff --git a/yuqei-infra/tests/test_first_release_snapshot_restore_planner.py b/yuqei-infra/tests/test_first_release_snapshot_restore_planner.py index c10d572a..385e956d 100644 --- a/yuqei-infra/tests/test_first_release_snapshot_restore_planner.py +++ b/yuqei-infra/tests/test_first_release_snapshot_restore_planner.py @@ -105,6 +105,7 @@ def _case( database_downgrade_performed: bool = False, successful_release_count: int = 0, same_approver_person: bool = False, + reused_approval_public_key: bool = False, restore_bindings_match: bool = True, empty_image_map: str | None = None, ) -> dict[str, Any]: @@ -414,9 +415,14 @@ def _case( restore_path = _write_json(evidence_root / "independent-restore-test.json", restore_test) root_private = Ed25519PrivateKey.generate() + release_manager_key = Ed25519PrivateKey.generate() role_keys = { - "release_manager": Ed25519PrivateKey.generate(), - "database_owner": Ed25519PrivateKey.generate(), + "release_manager": release_manager_key, + "database_owner": ( + release_manager_key + if reused_approval_public_key + else Ed25519PrivateKey.generate() + ), } approver_ids = { "release_manager": "approver-release-manager", @@ -1024,6 +1030,25 @@ def test_approvals_require_two_different_people_and_keys( _validate(case) +def test_keyring_rejects_public_key_reused_across_approval_identities( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + case = _case(tmp_path, reused_approval_public_key=True) + key_records = case["keyring"]["keys"] + assert len({item["key_id"] for item in key_records}) == 2 + assert len({item["approver_id"] for item in key_records}) == 2 + assert len({item["role"] for item in key_records}) == 2 + assert len({item["public_key_base64"] for item in key_records}) == 1 + + _provision_test_pins(monkeypatch, case) + with pytest.raises( + planner.SnapshotRestorePlanError, + match="reuses an Ed25519 public key across identities", + ): + _validate(case) + + def test_cli_has_no_apply_or_caller_supplied_trust_pin() -> None: options = { option