fix(assessment): reject ambiguous trust keyrings
Some checks failed
Dependency Audit / dependency-audit (push) Failing after 14s
Security Audit / security-audit (push) Failing after 13s

This commit is contained in:
2026-07-22 17:17:34 +02:00
parent ad8dd4f319
commit bd715a8473
3 changed files with 71 additions and 20 deletions

View File

@@ -356,9 +356,11 @@ Use `--catalog CATALOG --keyring PUBLISHED_OR_CANDIDATE_KEYRING
rerun. `GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE` may provide the rerun. `GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE` may provide the
independently managed local trust-root path instead. During key rotation this independently managed local trust-root path instead. During key rotation this
local file may contain both current and `next` keys within their validity local file may contain both current and `next` keys within their validity
windows; revoked, disabled and retired keys are rejected. Do not establish this windows. Only `active` and `next` entries are trusted; unknown statuses,
trust file by downloading it in the same rerun. Public JSON responses are duplicate key IDs and malformed structured keyrings fail closed, while revoked,
bounded to 16 MiB. Use `--json` or `--output PATH` for automation. disabled and retired entries are ignored. Do not establish this trust file by
downloading it in the same rerun. Public JSON responses are bounded to 16 MiB.
Use `--json` or `--output PATH` for automation.
Exit status `0` means the assessed release metadata is current, `2` means Exit status `0` means the assessed release metadata is current, `2` means
explicit review is required, and `1` means the schema or trust checks are explicit review is required, and `1` means the schema or trust checks are

View File

@@ -272,7 +272,7 @@ class CapabilityFitReviewTests(unittest.TestCase):
) )
self.assertTrue(report["proof_scope"]["published_keyring_hash"]["valid"]) self.assertTrue(report["proof_scope"]["published_keyring_hash"]["valid"])
def test_locally_pinned_rotation_keys_follow_core_status_semantics(self) -> None: def test_locally_pinned_rotation_keys_fail_closed(self) -> None:
keys = trusted_keys_from_keyring( keys = trusted_keys_from_keyring(
{ {
"keys": [ "keys": [
@@ -288,6 +288,38 @@ class CapabilityFitReviewTests(unittest.TestCase):
{"legacy": "base64-key"}, {"legacy": "base64-key"},
trusted_keys_from_keyring({"legacy": "base64-key"}), trusted_keys_from_keyring({"legacy": "base64-key"}),
) )
self.assertEqual(
{},
trusted_keys_from_keyring(
{
"keys": [
{
"key_id": "typo",
"status": "retierd",
"public_key": "unsafe",
}
]
}
),
)
self.assertEqual(
{},
trusted_keys_from_keyring(
{
"keys": [
{"key_id": "duplicate", "public_key": "a"},
{"key_id": "duplicate", "public_key": "b"},
]
}
),
)
self.assertEqual({}, trusted_keys_from_keyring({"keys": "invalid"}))
self.assertEqual(
{},
trusted_keys_from_keyring(
{"keys": [{"key_id": " ", "public_key": "unsafe"}]}
),
)
def test_schema_error_blocks_before_comparison(self) -> None: def test_schema_error_blocks_before_comparison(self) -> None:
assessment = deepcopy(self.assessment) assessment = deepcopy(self.assessment)

View File

@@ -415,39 +415,56 @@ def validate_catalog(
def trusted_keys_from_keyring(payload: dict[str, Any]) -> dict[str, str]: def trusted_keys_from_keyring(payload: dict[str, Any]) -> dict[str, str]:
now = datetime.now(tz=UTC) now = datetime.now(tz=UTC)
result: dict[str, str] = {} result: dict[str, str] = {}
if "keys" not in payload:
if not all(
isinstance(key, str)
and key.strip()
and key == key.strip()
and isinstance(value, str)
and value.strip()
and value == value.strip()
for key, value in payload.items()
):
return {}
return {key: value for key, value in payload.items()}
keys = payload.get("keys") keys = payload.get("keys")
if not isinstance(keys, list): if not isinstance(keys, list):
return { return {}
str(key): str(value) seen_key_ids: set[str] = set()
for key, value in payload.items()
if str(key).strip() and str(value).strip()
}
for item in keys: for item in keys:
if not isinstance(item, dict): if not isinstance(item, dict):
continue return {}
if str(item.get("status") or "active").strip().lower() in { raw_key_id = _text(item.get("key_id"))
"revoked", if (
"disabled", raw_key_id is None
"retired", or raw_key_id != raw_key_id.strip()
}: or raw_key_id in seen_key_ids
):
return {}
key_id = raw_key_id
seen_key_ids.add(key_id)
status = str(item.get("status") or "active").strip().lower()
if status not in {"active", "next", "revoked", "disabled", "retired"}:
return {}
if status in {"revoked", "disabled", "retired"}:
continue continue
raw_not_before = item.get("not_before") raw_not_before = item.get("not_before")
raw_not_after = item.get("not_after") raw_not_after = item.get("not_after")
not_before = _datetime(raw_not_before) not_before = _datetime(raw_not_before)
not_after = _datetime(raw_not_after) not_after = _datetime(raw_not_after)
if raw_not_before is not None and not_before is None: if raw_not_before is not None and not_before is None:
continue return {}
if raw_not_after is not None and not_after is None: if raw_not_after is not None and not_after is None:
continue return {}
if not_before is not None and now < not_before: if not_before is not None and now < not_before:
continue continue
if not_after is not None and now > not_after: if not_after is not None and now > not_after:
continue continue
key_id = _text(item.get("key_id"))
public_key = _text(item.get("public_key")) or _text( public_key = _text(item.get("public_key")) or _text(
item.get("public_key_base64") item.get("public_key_base64")
) )
if key_id and public_key: if public_key is None:
return {}
result[key_id] = public_key result[key_id] = public_key
return result return result