Authenticate selective catalog base pairs
This commit is contained in:
184
tests/test_release_base_catalog_trust.py
Normal file
184
tests/test_release_base_catalog_trust.py
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
RELEASE_TOOLS_ROOT = META_ROOT / "tools" / "release"
|
||||||
|
if str(RELEASE_TOOLS_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(RELEASE_TOOLS_ROOT))
|
||||||
|
|
||||||
|
from govoplan_release.catalog import canonical_hash # noqa: E402
|
||||||
|
from govoplan_release.selective_catalog import ( # noqa: E402
|
||||||
|
candidate_keyring_from_authenticated_base,
|
||||||
|
load_authenticated_catalog_base,
|
||||||
|
public_key_base64,
|
||||||
|
signature,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseBaseCatalogTrustTests(unittest.TestCase):
|
||||||
|
def test_exact_signed_base_pair_is_loaded_once_and_carried_in_memory(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
private_key = Ed25519PrivateKey.generate()
|
||||||
|
catalog, keyring = self._write_pair(root, private_key=private_key)
|
||||||
|
|
||||||
|
authenticated = load_authenticated_catalog_base(
|
||||||
|
base_catalog=catalog,
|
||||||
|
base_keyring=keyring,
|
||||||
|
web_root=root,
|
||||||
|
channel="stable",
|
||||||
|
public_base_url="https://invalid.example",
|
||||||
|
signer_public_keys={"release-key": public_key_base64(private_key)},
|
||||||
|
)
|
||||||
|
|
||||||
|
keyring.unlink()
|
||||||
|
|
||||||
|
self.assertEqual("release-key", authenticated.keyring["keys"][0]["key_id"])
|
||||||
|
self.assertEqual(
|
||||||
|
canonical_hash(authenticated.keyring),
|
||||||
|
authenticated.catalog["release"]["keyring_sha256"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_deliberate_old_to_new_signer_rotation_is_carried_forward(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
private_key = Ed25519PrivateKey.generate()
|
||||||
|
new_key = Ed25519PrivateKey.generate()
|
||||||
|
catalog, keyring = self._write_pair(root, private_key=private_key)
|
||||||
|
configured = {
|
||||||
|
"release-key": public_key_base64(private_key),
|
||||||
|
"release-key-next": public_key_base64(new_key),
|
||||||
|
}
|
||||||
|
|
||||||
|
authenticated = load_authenticated_catalog_base(
|
||||||
|
base_catalog=catalog,
|
||||||
|
base_keyring=keyring,
|
||||||
|
web_root=root,
|
||||||
|
channel="stable",
|
||||||
|
public_base_url="https://invalid.example",
|
||||||
|
signer_public_keys=configured,
|
||||||
|
)
|
||||||
|
candidate = candidate_keyring_from_authenticated_base(
|
||||||
|
authenticated.keyring,
|
||||||
|
signer_public_keys=configured,
|
||||||
|
generated_at=datetime(2026, 7, 22, 12, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
{"release-key", "release-key-next"},
|
||||||
|
{item["key_id"] for item in candidate["keys"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_injected_extra_key_without_catalog_authorization_is_rejected(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
private_key = Ed25519PrivateKey.generate()
|
||||||
|
extra_key = Ed25519PrivateKey.generate()
|
||||||
|
catalog, keyring = self._write_pair(root, private_key=private_key)
|
||||||
|
payload = json.loads(keyring.read_text(encoding="utf-8"))
|
||||||
|
payload["keys"].append(
|
||||||
|
{
|
||||||
|
"key_id": "injected-key",
|
||||||
|
"status": "active",
|
||||||
|
"public_key": public_key_base64(extra_key),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
keyring.write_text(json.dumps(payload), encoding="utf-8")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "does not pin"):
|
||||||
|
load_authenticated_catalog_base(
|
||||||
|
base_catalog=catalog,
|
||||||
|
base_keyring=keyring,
|
||||||
|
web_root=root,
|
||||||
|
channel="stable",
|
||||||
|
public_base_url="https://invalid.example",
|
||||||
|
signer_public_keys={
|
||||||
|
"release-key": public_key_base64(private_key)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unpinned_or_symlinked_base_keyring_fails_closed(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
private_key = Ed25519PrivateKey.generate()
|
||||||
|
catalog, keyring = self._write_pair(root, private_key=private_key)
|
||||||
|
payload = json.loads(keyring.read_text(encoding="utf-8"))
|
||||||
|
payload["generated_at"] = "changed"
|
||||||
|
keyring.write_text(json.dumps(payload), encoding="utf-8")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "does not pin"):
|
||||||
|
load_authenticated_catalog_base(
|
||||||
|
base_catalog=catalog,
|
||||||
|
base_keyring=keyring,
|
||||||
|
web_root=root,
|
||||||
|
channel="stable",
|
||||||
|
public_base_url="https://invalid.example",
|
||||||
|
signer_public_keys={
|
||||||
|
"release-key": public_key_base64(private_key)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
keyring.unlink()
|
||||||
|
outside = root / "outside.json"
|
||||||
|
outside.write_text("{}", encoding="utf-8")
|
||||||
|
keyring.symlink_to(outside)
|
||||||
|
with self.assertRaisesRegex(ValueError, "opened safely"):
|
||||||
|
load_authenticated_catalog_base(
|
||||||
|
base_catalog=catalog,
|
||||||
|
base_keyring=keyring,
|
||||||
|
web_root=root,
|
||||||
|
channel="stable",
|
||||||
|
public_base_url="https://invalid.example",
|
||||||
|
signer_public_keys={
|
||||||
|
"release-key": public_key_base64(private_key)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _write_pair(
|
||||||
|
root: Path, *, private_key: Ed25519PrivateKey
|
||||||
|
) -> tuple[Path, Path]:
|
||||||
|
keys = [
|
||||||
|
{
|
||||||
|
"key_id": "release-key",
|
||||||
|
"status": "active",
|
||||||
|
"public_key": public_key_base64(private_key),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
keyring_payload = {
|
||||||
|
"keyring_version": "1",
|
||||||
|
"purpose": "govoplan module package catalog signatures",
|
||||||
|
"keys": keys,
|
||||||
|
}
|
||||||
|
catalog_payload = {
|
||||||
|
"channel": "stable",
|
||||||
|
"sequence": 1,
|
||||||
|
"core_release": {"version": "1.0.0"},
|
||||||
|
"modules": [],
|
||||||
|
"release": {"keyring_sha256": canonical_hash(keyring_payload)},
|
||||||
|
}
|
||||||
|
catalog_payload["signatures"] = [
|
||||||
|
signature(
|
||||||
|
catalog_payload,
|
||||||
|
key_id="release-key",
|
||||||
|
private_key=private_key,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
catalog = root / "stable.json"
|
||||||
|
keyring = root / "keyring.json"
|
||||||
|
catalog.write_text(json.dumps(catalog_payload), encoding="utf-8")
|
||||||
|
keyring.write_text(json.dumps(keyring_payload), encoding="utf-8")
|
||||||
|
return catalog, keyring
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -19,9 +19,12 @@ if str(RELEASE_ROOT) not in sys.path:
|
|||||||
sys.path.insert(0, str(RELEASE_ROOT))
|
sys.path.insert(0, str(RELEASE_ROOT))
|
||||||
|
|
||||||
from govoplan_release.publisher import publish_catalog_candidate # noqa: E402
|
from govoplan_release.publisher import publish_catalog_candidate # noqa: E402
|
||||||
|
from govoplan_release.catalog import canonical_hash # noqa: E402
|
||||||
from govoplan_release.selective_catalog import ( # noqa: E402
|
from govoplan_release.selective_catalog import ( # noqa: E402
|
||||||
build_selective_catalog_candidate,
|
build_selective_catalog_candidate,
|
||||||
enforce_selected_source_provenance,
|
enforce_selected_source_provenance,
|
||||||
|
public_key_base64,
|
||||||
|
signature,
|
||||||
)
|
)
|
||||||
from govoplan_release.source_provenance import ( # noqa: E402
|
from govoplan_release.source_provenance import ( # noqa: E402
|
||||||
catalog_source_selection,
|
catalog_source_selection,
|
||||||
@@ -117,23 +120,37 @@ class ReleaseSourceProvenanceTests(unittest.TestCase):
|
|||||||
core, _ = make_repo(self.workspace, self.root, "govoplan-core", "1.2.3")
|
core, _ = make_repo(self.workspace, self.root, "govoplan-core", "1.2.3")
|
||||||
git(core, "tag", "-a", "v1.2.3", "-m", "Core 1.2.3")
|
git(core, "tag", "-a", "v1.2.3", "-m", "Core 1.2.3")
|
||||||
git(core, "push", "origin", "refs/tags/v1.2.3")
|
git(core, "push", "origin", "refs/tags/v1.2.3")
|
||||||
base_catalog = self.root / "base.json"
|
|
||||||
base_catalog.write_text(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"channel": "stable",
|
|
||||||
"sequence": 1,
|
|
||||||
"core_release": {
|
|
||||||
"version": "1.2.2",
|
|
||||||
"python_ref": "govoplan-core @ git+ssh://git@example.test/acme/govoplan-core.git@v1.2.2",
|
|
||||||
},
|
|
||||||
"modules": [],
|
|
||||||
"release": {"version": "1.2.2", "tag": "v1.2.2"},
|
|
||||||
}
|
|
||||||
),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
private_key = Ed25519PrivateKey.generate()
|
private_key = Ed25519PrivateKey.generate()
|
||||||
|
keyring = {
|
||||||
|
"keys": [
|
||||||
|
{
|
||||||
|
"key_id": "test-key",
|
||||||
|
"status": "active",
|
||||||
|
"public_key": public_key_base64(private_key),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
base_keyring = self.root / "base-keyring.json"
|
||||||
|
base_keyring.write_text(json.dumps(keyring), encoding="utf-8")
|
||||||
|
base_catalog = self.root / "base.json"
|
||||||
|
base_payload = {
|
||||||
|
"channel": "stable",
|
||||||
|
"sequence": 1,
|
||||||
|
"core_release": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"python_ref": "govoplan-core @ git+ssh://git@example.test/acme/govoplan-core.git@v1.2.2",
|
||||||
|
},
|
||||||
|
"modules": [],
|
||||||
|
"release": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"tag": "v1.2.2",
|
||||||
|
"keyring_sha256": canonical_hash(keyring),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
base_payload["signatures"] = [
|
||||||
|
signature(base_payload, key_id="test-key", private_key=private_key)
|
||||||
|
]
|
||||||
|
base_catalog.write_text(json.dumps(base_payload), encoding="utf-8")
|
||||||
key_path = self.root / "release.pem"
|
key_path = self.root / "release.pem"
|
||||||
key_path.write_bytes(
|
key_path.write_bytes(
|
||||||
private_key.private_bytes(
|
private_key.private_bytes(
|
||||||
@@ -153,6 +170,7 @@ class ReleaseSourceProvenanceTests(unittest.TestCase):
|
|||||||
workspace_root=self.workspace,
|
workspace_root=self.workspace,
|
||||||
output_dir=output,
|
output_dir=output,
|
||||||
base_catalog=base_catalog,
|
base_catalog=base_catalog,
|
||||||
|
base_keyring=base_keyring,
|
||||||
signing_keys=(f"test-key={key_path}",),
|
signing_keys=(f"test-key={key_path}",),
|
||||||
check_public=False,
|
check_public=False,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,15 +3,22 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
|
import stat
|
||||||
|
import tempfile
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from cryptography.exceptions import InvalidSignature
|
||||||
from cryptography.hazmat.primitives import serialization
|
from cryptography.hazmat.primitives import serialization
|
||||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
||||||
|
Ed25519PrivateKey,
|
||||||
|
Ed25519PublicKey,
|
||||||
|
)
|
||||||
|
|
||||||
from govoplan_core.core.module_package_catalog import validate_module_package_catalog
|
from govoplan_core.core.module_package_catalog import validate_module_package_catalog
|
||||||
|
|
||||||
@@ -41,6 +48,16 @@ from .version_alignment import (
|
|||||||
from .workspace import load_repository_specs, resolve_workspace_root, website_root
|
from .workspace import load_repository_specs, resolve_workspace_root, website_root
|
||||||
|
|
||||||
GITEA_BASE = "git+ssh://git@git.add-ideas.de/add-ideas"
|
GITEA_BASE = "git+ssh://git@git.add-ideas.de/add-ideas"
|
||||||
|
MAX_BASE_JSON_BYTES = 16 * 1024 * 1024
|
||||||
|
_SIGNING_KEY_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AuthenticatedCatalogBase:
|
||||||
|
catalog_source: Path | str
|
||||||
|
keyring_source: Path | str
|
||||||
|
catalog: dict[str, Any]
|
||||||
|
keyring: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
def build_selective_catalog_candidate(
|
def build_selective_catalog_candidate(
|
||||||
@@ -50,6 +67,7 @@ def build_selective_catalog_candidate(
|
|||||||
workspace_root: Path | str | None = None,
|
workspace_root: Path | str | None = None,
|
||||||
output_dir: Path | str | None = None,
|
output_dir: Path | str | None = None,
|
||||||
base_catalog: Path | str | None = None,
|
base_catalog: Path | str | None = None,
|
||||||
|
base_keyring: Path | str | None = None,
|
||||||
signing_keys: tuple[str, ...] = (),
|
signing_keys: tuple[str, ...] = (),
|
||||||
public_base_url: str = DEFAULT_PUBLIC_BASE_URL,
|
public_base_url: str = DEFAULT_PUBLIC_BASE_URL,
|
||||||
repository_base: str = GITEA_BASE,
|
repository_base: str = GITEA_BASE,
|
||||||
@@ -66,6 +84,7 @@ def build_selective_catalog_candidate(
|
|||||||
parsed_keys = tuple(parse_signing_key(value) for value in signing_keys)
|
parsed_keys = tuple(parse_signing_key(value) for value in signing_keys)
|
||||||
if not parsed_keys:
|
if not parsed_keys:
|
||||||
raise ValueError("At least one signing key is required.")
|
raise ValueError("At least one signing key is required.")
|
||||||
|
signer_public_keys = configured_signer_public_keys(parsed_keys)
|
||||||
|
|
||||||
workspace = resolve_workspace_root(workspace_root)
|
workspace = resolve_workspace_root(workspace_root)
|
||||||
enforce_selected_version_alignment(repo_versions=repo_versions, workspace=workspace)
|
enforce_selected_version_alignment(repo_versions=repo_versions, workspace=workspace)
|
||||||
@@ -79,10 +98,16 @@ def build_selective_catalog_candidate(
|
|||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
)
|
)
|
||||||
web_root = website_root(workspace)
|
web_root = website_root(workspace)
|
||||||
source_catalog = resolve_base_catalog(base_catalog=base_catalog, web_root=web_root, channel=channel)
|
authenticated_base = load_authenticated_catalog_base(
|
||||||
payload = read_catalog(source_catalog)
|
base_catalog=base_catalog,
|
||||||
if not isinstance(payload, dict):
|
base_keyring=base_keyring,
|
||||||
raise ValueError("Selective catalog updates require object-style catalogs.")
|
web_root=web_root,
|
||||||
|
channel=channel,
|
||||||
|
public_base_url=public_base_url,
|
||||||
|
signer_public_keys=signer_public_keys,
|
||||||
|
)
|
||||||
|
source_catalog = authenticated_base.catalog_source
|
||||||
|
payload = authenticated_base.catalog
|
||||||
|
|
||||||
generated_at = datetime.now(tz=UTC)
|
generated_at = datetime.now(tz=UTC)
|
||||||
resolved_sequence = sequence or next_sequence(payload, generated_at=generated_at)
|
resolved_sequence = sequence or next_sequence(payload, generated_at=generated_at)
|
||||||
@@ -132,9 +157,9 @@ def build_selective_catalog_candidate(
|
|||||||
catalog_path = output_root / "channels" / f"{channel}.json"
|
catalog_path = output_root / "channels" / f"{channel}.json"
|
||||||
keyring_path = output_root / "keyring.json"
|
keyring_path = output_root / "keyring.json"
|
||||||
summary_path = output_root / "summary.json" if write_summary else None
|
summary_path = output_root / "summary.json" if write_summary else None
|
||||||
keyring_payload = keyring_payload_for_candidate(
|
keyring_payload = candidate_keyring_from_authenticated_base(
|
||||||
existing_keyring=web_root / "public" / "catalogs" / "v1" / "keyring.json",
|
authenticated_base.keyring,
|
||||||
signing_keys=parsed_keys,
|
signer_public_keys=signer_public_keys,
|
||||||
generated_at=generated_at,
|
generated_at=generated_at,
|
||||||
)
|
)
|
||||||
release["keyring_sha256"] = canonical_hash(keyring_payload)
|
release["keyring_sha256"] = canonical_hash(keyring_payload)
|
||||||
@@ -283,23 +308,161 @@ def enforce_complete_catalog_source_provenance(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def resolve_base_catalog(*, base_catalog: Path | str | None, web_root: Path, channel: str) -> Path | str:
|
def load_authenticated_catalog_base(
|
||||||
if base_catalog is not None:
|
*,
|
||||||
value = str(base_catalog)
|
base_catalog: Path | str | None,
|
||||||
return value if value.startswith(("http://", "https://")) else Path(value).expanduser()
|
base_keyring: Path | str | None,
|
||||||
local = web_root / "public" / "catalogs" / "v1" / "channels" / f"{channel}.json"
|
web_root: Path,
|
||||||
if local.exists():
|
channel: str,
|
||||||
return local
|
public_base_url: str,
|
||||||
return f"{DEFAULT_PUBLIC_BASE_URL}/catalogs/v1/channels/{channel}.json"
|
signer_public_keys: dict[str, str],
|
||||||
|
) -> AuthenticatedCatalogBase:
|
||||||
|
"""Read one base pair once and authenticate it before any synthesis."""
|
||||||
|
|
||||||
|
catalog_source, keyring_source = resolve_base_sources(
|
||||||
|
base_catalog=base_catalog,
|
||||||
|
base_keyring=base_keyring,
|
||||||
|
web_root=web_root,
|
||||||
|
channel=channel,
|
||||||
|
public_base_url=public_base_url,
|
||||||
|
)
|
||||||
|
catalog = read_bounded_json_source(catalog_source, label="base catalog")
|
||||||
|
keyring = read_bounded_json_source(keyring_source, label="base keyring")
|
||||||
|
if not isinstance(catalog, dict) or not isinstance(keyring, dict):
|
||||||
|
raise ValueError("Selective catalog base and keyring must be JSON objects.")
|
||||||
|
base_trusted_keys = authenticate_base_keyring(keyring)
|
||||||
|
release = catalog.get("release")
|
||||||
|
pinned_keyring = (
|
||||||
|
release.get("keyring_sha256") if isinstance(release, dict) else None
|
||||||
|
)
|
||||||
|
if pinned_keyring != canonical_hash(keyring):
|
||||||
|
raise ValueError(
|
||||||
|
"Signed base catalog does not pin the exact supplied base keyring."
|
||||||
|
)
|
||||||
|
authenticate_base_catalog_signatures(
|
||||||
|
catalog,
|
||||||
|
base_trusted_keys=base_trusted_keys,
|
||||||
|
configured_signers=signer_public_keys,
|
||||||
|
)
|
||||||
|
validation = validate_catalog_object(
|
||||||
|
catalog,
|
||||||
|
approved_channel=channel,
|
||||||
|
signer_public_keys={
|
||||||
|
key_id: public_key
|
||||||
|
for key_id, public_key in signer_public_keys.items()
|
||||||
|
if base_trusted_keys.get(key_id) == public_key
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if validation.get("valid") is not True:
|
||||||
|
raise ValueError(
|
||||||
|
"Authenticated base catalog failed catalog validation: "
|
||||||
|
+ str(validation.get("error") or "unknown validation error")
|
||||||
|
)
|
||||||
|
return AuthenticatedCatalogBase(
|
||||||
|
catalog_source=catalog_source,
|
||||||
|
keyring_source=keyring_source,
|
||||||
|
catalog=catalog,
|
||||||
|
keyring=keyring,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def read_catalog(source: Path | str) -> object:
|
def resolve_base_sources(
|
||||||
|
*,
|
||||||
|
base_catalog: Path | str | None,
|
||||||
|
base_keyring: Path | str | None,
|
||||||
|
web_root: Path,
|
||||||
|
channel: str,
|
||||||
|
public_base_url: str,
|
||||||
|
) -> tuple[Path | str, Path | str]:
|
||||||
|
if (base_catalog is None) != (base_keyring is None):
|
||||||
|
raise ValueError("--base-catalog and --base-keyring must be supplied together.")
|
||||||
|
if base_catalog is not None and base_keyring is not None:
|
||||||
|
return _json_source(base_catalog), _json_source(base_keyring)
|
||||||
|
local_root = web_root / "public" / "catalogs" / "v1"
|
||||||
|
local_catalog = local_root / "channels" / f"{channel}.json"
|
||||||
|
local_keyring = local_root / "keyring.json"
|
||||||
|
if local_catalog.exists() and local_keyring.exists():
|
||||||
|
return local_catalog, local_keyring
|
||||||
|
public_root = public_base_url.rstrip("/")
|
||||||
|
return (
|
||||||
|
f"{public_root}/catalogs/v1/channels/{channel}.json",
|
||||||
|
f"{public_root}/catalogs/v1/keyring.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_source(value: Path | str) -> Path | str:
|
||||||
|
text = str(value)
|
||||||
|
return text if text.startswith(("http://", "https://")) else Path(text).expanduser()
|
||||||
|
|
||||||
|
|
||||||
|
def read_bounded_json_source(source: Path | str, *, label: str) -> object:
|
||||||
if isinstance(source, str) and source.startswith(("http://", "https://")):
|
if isinstance(source, str) and source.startswith(("http://", "https://")):
|
||||||
payload = fetch_json(source)
|
payload = fetch_json(source)
|
||||||
if not payload["ok"]:
|
if not payload["ok"]:
|
||||||
raise ValueError(f"Could not fetch catalog {source}: {payload['error']}")
|
raise ValueError(f"Could not fetch {label}: {payload['error']}")
|
||||||
return payload["payload"]
|
return payload["payload"]
|
||||||
return json.loads(Path(source).read_text(encoding="utf-8"))
|
encoded = _read_regular_file_without_symlinks(Path(source), label=label)
|
||||||
|
try:
|
||||||
|
return json.loads(encoded.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise ValueError(f"{label.capitalize()} is not valid UTF-8 JSON.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _read_regular_file_without_symlinks(path: Path, *, label: str) -> bytes:
|
||||||
|
absolute = path.absolute()
|
||||||
|
parts = absolute.parts[1:]
|
||||||
|
if not parts:
|
||||||
|
raise ValueError(f"{label.capitalize()} must name a regular file.")
|
||||||
|
directory_flags = (
|
||||||
|
os.O_RDONLY
|
||||||
|
| getattr(os, "O_DIRECTORY", 0)
|
||||||
|
| getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
)
|
||||||
|
descriptor = os.open(absolute.anchor or "/", directory_flags)
|
||||||
|
try:
|
||||||
|
for part in parts[:-1]:
|
||||||
|
child = os.open(part, directory_flags, dir_fd=descriptor)
|
||||||
|
os.close(descriptor)
|
||||||
|
descriptor = child
|
||||||
|
file_descriptor = os.open(
|
||||||
|
parts[-1],
|
||||||
|
os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0),
|
||||||
|
dir_fd=descriptor,
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
os.close(descriptor)
|
||||||
|
raise ValueError(f"{label.capitalize()} cannot be opened safely.") from exc
|
||||||
|
os.close(descriptor)
|
||||||
|
try:
|
||||||
|
initial = os.fstat(file_descriptor)
|
||||||
|
if not stat.S_ISREG(initial.st_mode) or initial.st_size > MAX_BASE_JSON_BYTES:
|
||||||
|
raise ValueError(
|
||||||
|
f"{label.capitalize()} must be a bounded regular file."
|
||||||
|
)
|
||||||
|
chunks: list[bytes] = []
|
||||||
|
remaining = initial.st_size
|
||||||
|
while remaining:
|
||||||
|
chunk = os.read(file_descriptor, min(1024 * 1024, remaining))
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
chunks.append(chunk)
|
||||||
|
remaining -= len(chunk)
|
||||||
|
final = os.fstat(file_descriptor)
|
||||||
|
if remaining or _stat_fingerprint(initial) != _stat_fingerprint(final):
|
||||||
|
raise ValueError(f"{label.capitalize()} changed while it was read.")
|
||||||
|
return b"".join(chunks)
|
||||||
|
finally:
|
||||||
|
os.close(file_descriptor)
|
||||||
|
|
||||||
|
|
||||||
|
def _stat_fingerprint(value: os.stat_result) -> tuple[int, int, int, int, int]:
|
||||||
|
return (
|
||||||
|
value.st_dev,
|
||||||
|
value.st_ino,
|
||||||
|
value.st_size,
|
||||||
|
value.st_mtime_ns,
|
||||||
|
value.st_ctime_ns,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def next_sequence(payload: dict[str, Any], *, generated_at: datetime) -> int:
|
def next_sequence(payload: dict[str, Any], *, generated_at: datetime) -> int:
|
||||||
@@ -551,11 +714,17 @@ def parse_signing_key(value: str) -> tuple[str, Ed25519PrivateKey]:
|
|||||||
key_id, separator, path_text = value.partition("=")
|
key_id, separator, path_text = value.partition("=")
|
||||||
if not separator or not key_id.strip() or not path_text.strip():
|
if not separator or not key_id.strip() or not path_text.strip():
|
||||||
raise ValueError("--catalog-signing-key must use KEY_ID=/path/to/private.pem")
|
raise ValueError("--catalog-signing-key must use KEY_ID=/path/to/private.pem")
|
||||||
|
key_id = key_id.strip()
|
||||||
|
if _SIGNING_KEY_ID.fullmatch(key_id) is None:
|
||||||
|
raise ValueError("Catalog signing key ID is not a bounded opaque ID.")
|
||||||
path = Path(path_text).expanduser()
|
path = Path(path_text).expanduser()
|
||||||
private_key = serialization.load_pem_private_key(path.read_bytes(), password=None)
|
private_key = serialization.load_pem_private_key(
|
||||||
|
_read_regular_file_without_symlinks(path, label="catalog signing key"),
|
||||||
|
password=None,
|
||||||
|
)
|
||||||
if not isinstance(private_key, Ed25519PrivateKey):
|
if not isinstance(private_key, Ed25519PrivateKey):
|
||||||
raise ValueError(f"Catalog signing key must be an Ed25519 private key: {path}")
|
raise ValueError(f"Catalog signing key must be an Ed25519 private key: {path}")
|
||||||
return key_id.strip(), private_key
|
return key_id, private_key
|
||||||
|
|
||||||
|
|
||||||
def signature(payload: dict[str, Any], *, key_id: str, private_key: Ed25519PrivateKey) -> dict[str, str]:
|
def signature(payload: dict[str, Any], *, key_id: str, private_key: Ed25519PrivateKey) -> dict[str, str]:
|
||||||
@@ -569,38 +738,193 @@ def signature(payload: dict[str, Any], *, key_id: str, private_key: Ed25519Priva
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def keyring_payload_for_candidate(
|
def configured_signer_public_keys(
|
||||||
*,
|
|
||||||
existing_keyring: Path,
|
|
||||||
signing_keys: tuple[tuple[str, Ed25519PrivateKey], ...],
|
signing_keys: tuple[tuple[str, Ed25519PrivateKey], ...],
|
||||||
|
) -> dict[str, str]:
|
||||||
|
result: dict[str, str] = {}
|
||||||
|
seen_material: set[str] = set()
|
||||||
|
for key_id, private_key in signing_keys:
|
||||||
|
public_key = public_key_base64(private_key)
|
||||||
|
if key_id in result or public_key in seen_material:
|
||||||
|
raise ValueError("Catalog signing keys contain duplicate identity or material.")
|
||||||
|
result[key_id] = public_key
|
||||||
|
seen_material.add(public_key)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate_base_keyring(payload: dict[str, Any]) -> dict[str, str]:
|
||||||
|
keys = payload.get("keys")
|
||||||
|
if not isinstance(keys, list) or not keys or len(keys) > 64:
|
||||||
|
raise ValueError("Base keyring has no bounded signer set.")
|
||||||
|
observed_ids: set[str] = set()
|
||||||
|
seen_material: set[bytes] = set()
|
||||||
|
trusted: dict[str, str] = {}
|
||||||
|
for item in keys:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise ValueError("Base keyring contains a malformed key.")
|
||||||
|
key_id = item.get("key_id")
|
||||||
|
public_text = item.get("public_key")
|
||||||
|
status = item.get("status", "active")
|
||||||
|
if (
|
||||||
|
not isinstance(key_id, str)
|
||||||
|
or _SIGNING_KEY_ID.fullmatch(key_id) is None
|
||||||
|
or not isinstance(public_text, str)
|
||||||
|
or status
|
||||||
|
not in {"active", "next", "retired", "revoked", "disabled"}
|
||||||
|
or key_id in observed_ids
|
||||||
|
):
|
||||||
|
raise ValueError("Base keyring contains a malformed or duplicate key.")
|
||||||
|
try:
|
||||||
|
public_bytes = base64.b64decode(public_text, validate=True)
|
||||||
|
Ed25519PublicKey.from_public_bytes(public_bytes)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError("Base keyring contains invalid Ed25519 material.") from exc
|
||||||
|
if public_bytes in seen_material:
|
||||||
|
raise ValueError("Base keyring reuses signer public material.")
|
||||||
|
seen_material.add(public_bytes)
|
||||||
|
observed_ids.add(key_id)
|
||||||
|
if status in {"active", "next"}:
|
||||||
|
trusted[key_id] = base64.b64encode(public_bytes).decode("ascii")
|
||||||
|
if not trusted:
|
||||||
|
raise ValueError("Base keyring has no active trusted catalog signer.")
|
||||||
|
return trusted
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate_base_catalog_signatures(
|
||||||
|
payload: dict[str, Any],
|
||||||
|
*,
|
||||||
|
base_trusted_keys: dict[str, str],
|
||||||
|
configured_signers: dict[str, str],
|
||||||
|
) -> None:
|
||||||
|
if payload.get("signature") is not None:
|
||||||
|
raise ValueError("Base catalog must use one unambiguous signatures envelope.")
|
||||||
|
signatures = payload.get("signatures")
|
||||||
|
if not isinstance(signatures, list) or not signatures or len(signatures) > 16:
|
||||||
|
raise ValueError("Base catalog has no bounded signatures envelope.")
|
||||||
|
signed_payload = dict(payload)
|
||||||
|
signed_payload.pop("signatures", None)
|
||||||
|
encoded = canonical_bytes(signed_payload)
|
||||||
|
seen: set[str] = set()
|
||||||
|
for item in signatures:
|
||||||
|
if not isinstance(item, dict) or item.get("algorithm") != "ed25519":
|
||||||
|
raise ValueError("Base catalog contains a malformed signature.")
|
||||||
|
key_id = item.get("key_id")
|
||||||
|
value = item.get("value")
|
||||||
|
if (
|
||||||
|
not isinstance(key_id, str)
|
||||||
|
or key_id in seen
|
||||||
|
or key_id not in base_trusted_keys
|
||||||
|
or not isinstance(value, str)
|
||||||
|
):
|
||||||
|
raise ValueError("Base catalog contains an untrusted or duplicate signature.")
|
||||||
|
try:
|
||||||
|
public_key = Ed25519PublicKey.from_public_bytes(
|
||||||
|
base64.b64decode(base_trusted_keys[key_id], validate=True)
|
||||||
|
)
|
||||||
|
public_key.verify(base64.b64decode(value, validate=True), encoded)
|
||||||
|
except (ValueError, InvalidSignature) as exc:
|
||||||
|
raise ValueError("Base catalog signature verification failed.") from exc
|
||||||
|
seen.add(key_id)
|
||||||
|
if not any(
|
||||||
|
key_id in seen
|
||||||
|
and configured_signers.get(key_id) == base_trusted_keys.get(key_id)
|
||||||
|
for key_id in configured_signers
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Base catalog needs a valid signature from a configured signer "
|
||||||
|
"already trusted by its pinned keyring."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def candidate_keyring_from_authenticated_base(
|
||||||
|
payload: dict[str, Any],
|
||||||
|
*,
|
||||||
|
signer_public_keys: dict[str, str],
|
||||||
generated_at: datetime,
|
generated_at: datetime,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
expected = {key_id: public_key_base64(private_key) for key_id, private_key in signing_keys}
|
"""Carry authenticated keys forward and add only configured signer material."""
|
||||||
if existing_keyring.exists():
|
|
||||||
payload = json.loads(existing_keyring.read_text(encoding="utf-8"))
|
candidate = json.loads(json.dumps(payload))
|
||||||
keys = payload.get("keys") if isinstance(payload, dict) else None
|
keys = candidate.get("keys")
|
||||||
if isinstance(keys, list):
|
if not isinstance(keys, list):
|
||||||
existing = {
|
raise ValueError("Authenticated base keyring lost its keys list.")
|
||||||
str(item.get("key_id")): str(item.get("public_key") or item.get("public_key_base64"))
|
existing: dict[str, str] = {}
|
||||||
|
existing_material: set[str] = set()
|
||||||
|
for item in keys:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise ValueError("Authenticated base keyring contains a malformed key.")
|
||||||
|
key_id = str(item.get("key_id") or "")
|
||||||
|
public_key = str(item.get("public_key") or "")
|
||||||
|
existing[key_id] = public_key
|
||||||
|
existing_material.add(public_key)
|
||||||
|
changed = False
|
||||||
|
for key_id, public_key in sorted(signer_public_keys.items()):
|
||||||
|
if key_id in existing:
|
||||||
|
if existing[key_id] != public_key:
|
||||||
|
raise ValueError(
|
||||||
|
"Configured signer key ID conflicts with authenticated base material."
|
||||||
|
)
|
||||||
|
matching = next(
|
||||||
|
item
|
||||||
for item in keys
|
for item in keys
|
||||||
if isinstance(item, dict) and item.get("key_id")
|
if isinstance(item, dict) and item.get("key_id") == key_id
|
||||||
}
|
)
|
||||||
if all(existing.get(key_id) == public_key for key_id, public_key in expected.items()):
|
if matching.get("status", "active") not in {"active", "next"}:
|
||||||
return payload
|
raise ValueError("Configured signer is disabled in the base keyring.")
|
||||||
return {
|
continue
|
||||||
"keyring_version": "1",
|
if public_key in existing_material:
|
||||||
"purpose": "govoplan module package catalog signatures",
|
raise ValueError(
|
||||||
"generated_at": json_datetime(generated_at),
|
"Configured signer material already has another authenticated key ID."
|
||||||
"keys": [
|
)
|
||||||
|
keys.append(
|
||||||
{
|
{
|
||||||
"key_id": key_id,
|
"key_id": key_id,
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"public_key": public_key_base64(private_key),
|
"public_key": public_key,
|
||||||
"not_before": generated_at.date().isoformat() + "T00:00:00Z",
|
"not_before": generated_at.astimezone(UTC)
|
||||||
|
.replace(microsecond=0)
|
||||||
|
.isoformat()
|
||||||
|
.replace("+00:00", "Z"),
|
||||||
}
|
}
|
||||||
for key_id, private_key in signing_keys
|
)
|
||||||
],
|
existing_material.add(public_key)
|
||||||
}
|
changed = True
|
||||||
|
if changed:
|
||||||
|
candidate["generated_at"] = json_datetime(generated_at)
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def validate_catalog_object(
|
||||||
|
payload: dict[str, Any],
|
||||||
|
*,
|
||||||
|
approved_channel: str,
|
||||||
|
signer_public_keys: dict[str, str],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
descriptor, temporary_name = tempfile.mkstemp(
|
||||||
|
prefix="govoplan-base-catalog-", suffix=".json"
|
||||||
|
)
|
||||||
|
temporary = Path(temporary_name)
|
||||||
|
try:
|
||||||
|
os.fchmod(descriptor, 0o600)
|
||||||
|
encoded = (json.dumps(payload, sort_keys=True) + "\n").encode("utf-8")
|
||||||
|
if len(encoded) > MAX_BASE_JSON_BYTES:
|
||||||
|
raise ValueError("Base catalog exceeds its size limit.")
|
||||||
|
with os.fdopen(descriptor, "wb", closefd=True) as handle:
|
||||||
|
handle.write(encoded)
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
return validate_module_package_catalog(
|
||||||
|
temporary,
|
||||||
|
require_trusted=True,
|
||||||
|
approved_channels=(approved_channel,),
|
||||||
|
trusted_keys=signer_public_keys,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.close(descriptor)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
def trusted_keys_from_keyring(payload: dict[str, Any]) -> dict[str, str]:
|
def trusted_keys_from_keyring(payload: dict[str, Any]) -> dict[str, str]:
|
||||||
|
|||||||
@@ -33,7 +33,20 @@ def main() -> int:
|
|||||||
"will be computed into the signed catalog; may be repeated."
|
"will be computed into the signed catalog; may be repeated."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
selective.add_argument("--base-catalog", help="Catalog path or URL to use as the source. Defaults to local channel, then public channel.")
|
selective.add_argument(
|
||||||
|
"--base-catalog",
|
||||||
|
help=(
|
||||||
|
"Authenticated catalog path or URL to use as the source; must be "
|
||||||
|
"paired with --base-keyring. Defaults to the local pair, then public pair."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
selective.add_argument(
|
||||||
|
"--base-keyring",
|
||||||
|
help=(
|
||||||
|
"Exact keyring path or URL pinned by --base-catalog and matching "
|
||||||
|
"the configured catalog signer set."
|
||||||
|
),
|
||||||
|
)
|
||||||
selective.add_argument(
|
selective.add_argument(
|
||||||
"--output-dir",
|
"--output-dir",
|
||||||
type=Path,
|
type=Path,
|
||||||
@@ -84,6 +97,7 @@ def main() -> int:
|
|||||||
workspace_root=args.workspace_root,
|
workspace_root=args.workspace_root,
|
||||||
output_dir=args.output_dir,
|
output_dir=args.output_dir,
|
||||||
base_catalog=args.base_catalog,
|
base_catalog=args.base_catalog,
|
||||||
|
base_keyring=args.base_keyring,
|
||||||
signing_keys=tuple(args.catalog_signing_key),
|
signing_keys=tuple(args.catalog_signing_key),
|
||||||
public_base_url=args.public_base_url,
|
public_base_url=args.public_base_url,
|
||||||
repository_base=args.repository_base,
|
repository_base=args.repository_base,
|
||||||
|
|||||||
Reference in New Issue
Block a user