From be7c1033c11f3b4d83e0cf2af56711968070fc19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Lange?= Date: Wed, 3 Jun 2026 16:38:11 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20license=20public-key=20registry=20(?= =?UTF-8?q?keys.py)=20=E2=80=94=20DECISION-001=20follow-up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds capacium_models.keys module that holds Ed25519 PUBLIC keys for license-token verification, indexed by `kid`. Matching PRIVATE keys live ONLY in envctl on prod (capacium-exchange). DECISION-001 reference: license signing keypair is DISTINCT from capability-signing keypair; license kid namespace MUST start with `lic-`. Module contents: - LICENSE_PUBLIC_KEYS: dict[kid -> 64-hex] registry - get_license_public_key(kid) -> str: lookup with raise-on-unknown - known_kids() -> list[str]: sorted newest-first - UnknownLicenseKid: dedicated exception (subclass of KeyError) for consumers handling kid lookup failures cleanly CI-gate by design: the first registry entry (`lic-2026-q2-01`) currently holds the literal placeholder "REPLACE_WITH_64_CHAR_HEX_PUBLIC_KEY". The test_returns_64_char_hex test fails until this placeholder is replaced with the real Ed25519 public key hex (generated on maintainer workstation, private uploaded to envctl on capacium-prod). This is intentional: it prevents accidentally shipping a placeholder. Reviewer must verify the kid matches the value of CAPACIUM_LICENSE_SIGNING_KID on prod, and that the pubkey matches the private set as CAPACIUM_LICENSE_SIGNING_KEY. Rotation lifecycle for a kid: - Active: issues new tokens; PRIVATE held in envctl - Retired: no longer issues new tokens; old tokens still validate - Removed: entry deleted; old tokens fail validation To add a future kid: append to LICENSE_PUBLIC_KEYS, bump minor version, ship new capacium-models release. NEVER remove an entry whose tokens may still be in circulation. Tests added (tests/test_keys.py): - 2× registry shape (kid present, namespace lic-) - 3× get behavior (returns 64-hex, raises on unknown, error msg lists known kids) - 2× known_kids (returns all, sorted reverse) - 3× defensive placeholder rejection (placeholder, wrong length, non-hex) Verified locally: 9 pass, 1 expected-fail on placeholder. test_models.py still 55/55 pass. Existing capacium-models v0.4.0 consumers unaffected (additive only; no breaking changes). Next step (out of scope for this PR): after Andre commits the real pubkey hex, this branch goes green → merge → tag v0.4.1 → GitHub Release. capacium-exchange consumers (FIX-A-002 LicenseKeyManager) can then verify license tokens via `from capacium_models import get_license_public_key`. --- src/capacium_models/__init__.py | 10 +++ src/capacium_models/keys.py | 123 ++++++++++++++++++++++++++++++++ tests/test_keys.py | 119 ++++++++++++++++++++++++++++++ 3 files changed, 252 insertions(+) create mode 100644 src/capacium_models/keys.py create mode 100644 tests/test_keys.py diff --git a/src/capacium_models/__init__.py b/src/capacium_models/__init__.py index 738d0c6..95cec1f 100644 --- a/src/capacium_models/__init__.py +++ b/src/capacium_models/__init__.py @@ -19,6 +19,12 @@ SearchQuery, ) from .trust import TrustState, TrustMachine, TRUST_BADGES, get_trust_badge +from .keys import ( + LICENSE_PUBLIC_KEYS, + UnknownLicenseKid, + get_license_public_key, + known_kids, +) from .search import ExchangeSearch __all__ = [ @@ -39,4 +45,8 @@ "ExchangeSearch", "TRUST_BADGES", "get_trust_badge", + "LICENSE_PUBLIC_KEYS", + "UnknownLicenseKid", + "get_license_public_key", + "known_kids", ] diff --git a/src/capacium_models/keys.py b/src/capacium_models/keys.py new file mode 100644 index 0000000..19b2a22 --- /dev/null +++ b/src/capacium_models/keys.py @@ -0,0 +1,123 @@ +"""License public-key registry for capacium-models consumers. + +Consumers verify license tokens (issued by capacium-exchange `LicenseService`) +using Ed25519. The matching PRIVATE key lives ONLY in envctl on prod, under: + + envctl get capacium-exchange CAPACIUM_LICENSE_SIGNING_KEY # 64-hex private + envctl get capacium-exchange CAPACIUM_LICENSE_SIGNING_KID # current kid + +This module holds the corresponding PUBLIC keys, indexed by `kid` (key +identifier as embedded in license token payloads). Consumers use these to +verify signatures locally without round-tripping to the Exchange. + +Key rotation lifecycle for a given kid: + - Active: issues new tokens; PRIVATE held in envctl + - Retired: no longer issues NEW tokens; OLD tokens still validate + (entry stays in this registry until last token expires) + - Removed: entry deleted from registry; OLD tokens fail validation + +To add a new kid: append to LICENSE_PUBLIC_KEYS, bump the package version +(minor bump — additive); ship as a new capacium-models release. NEVER +remove an entry whose tokens may still be in circulation. + +DECISION-001 reference: license-signing keypair is DISTINCT from +capability-signing keypair. kid namespace MUST start with `lic-`. +""" + +from __future__ import annotations + +from typing import Dict, List + + +# ── License public-key registry ─────────────────────────────────────────── +# +# Format: dict[kid -> 64-char lowercase hex] (32 bytes Ed25519 public key) +# +# Each entry MUST match the private key currently set as +# CAPACIUM_LICENSE_SIGNING_KEY in envctl on capacium-prod, under the +# matching kid CAPACIUM_LICENSE_SIGNING_KID. + +LICENSE_PUBLIC_KEYS: Dict[str, str] = { + # ────────────────────────────────────────────────────────────────────── + # kid: lic-2026-q2-01 + # Added: 2026-06-03 (initial v2 launch — corresponds to the FIRST + # Ed25519 keypair generated on maintainer workstation, private uploaded + # to envctl on capacium-prod) + # ────────────────────────────────────────────────────────────────────── + "lic-2026-q2-01": "REPLACE_WITH_64_CHAR_HEX_PUBLIC_KEY", +} + + +# ── Public API ──────────────────────────────────────────────────────────── + + +class UnknownLicenseKid(KeyError): + """Raised when a license token's `kid` is not in the public-key registry. + + Consumers MUST reject the token in this case (treat as invalid signature). + Receiving this exception is the signal to upgrade `capacium-models` to a + newer version that includes the missing kid. + """ + + +def get_license_public_key(kid: str) -> str: + """Return the 64-char hex public key for a license kid. + + Args: + kid: Key identifier as embedded in license token payload. + + Returns: + 64-char lowercase hex string representing the Ed25519 public key. + + Raises: + UnknownLicenseKid: when `kid` is not in `LICENSE_PUBLIC_KEYS`, OR + when its registered value is not a valid 64-char hex string + (e.g., still a placeholder, malformed registry entry). + Consumer code MUST reject the token in this case. + """ + try: + pubkey = LICENSE_PUBLIC_KEYS[kid] + except KeyError as e: + raise UnknownLicenseKid( + f"kid={kid!r} not in LICENSE_PUBLIC_KEYS. " + f"Known kids: {known_kids()}. " + f"Upgrade capacium-models to support newer kids." + ) from e + _validate_hex(kid, pubkey) + return pubkey + + +def known_kids() -> List[str]: + """Return all registered kids, sorted newest-first (lexicographic reverse). + + Naming convention `lic-YYYY-qN-NN` makes lex reverse equal to + chronological reverse. + """ + return sorted(LICENSE_PUBLIC_KEYS.keys(), reverse=True) + + +# ── Internal helpers ────────────────────────────────────────────────────── + + +def _validate_hex(kid: str, pubkey: str) -> None: + """Validate that `pubkey` is a 64-char lowercase hex string. + + Raises UnknownLicenseKid (not ValueError) so consumers handling kid + lookup errors have a single exception type to catch. + """ + if not isinstance(pubkey, str): + raise UnknownLicenseKid( + f"kid={kid!r} has non-string pubkey of type {type(pubkey).__name__}" + ) + if len(pubkey) != 64: + raise UnknownLicenseKid( + f"kid={kid!r} pubkey length is {len(pubkey)}, expected 64 hex chars. " + f"Likely placeholder — registry entry must be filled in before release." + ) + try: + int(pubkey, 16) + except ValueError as e: + raise UnknownLicenseKid( + f"kid={kid!r} pubkey is not valid hex: {e}. " + f"Registry entry may still contain a placeholder." + ) from e diff --git a/tests/test_keys.py b/tests/test_keys.py new file mode 100644 index 0000000..4f8cf7c --- /dev/null +++ b/tests/test_keys.py @@ -0,0 +1,119 @@ +"""Tests for license public-key registry (`capacium_models.keys`). + +CI design: these tests are SUPPOSED to fail until the placeholder in +`keys.py` is replaced with a real Ed25519 public key hex. This is the +gate that prevents a placeholder from being shipped. +""" + +import pytest + +from capacium_models.keys import ( + LICENSE_PUBLIC_KEYS, + UnknownLicenseKid, + get_license_public_key, + known_kids, +) + + +class TestRegistry: + def test_lic_2026_q2_01_kid_registered(self): + """The first v2 kid must be in the registry.""" + assert "lic-2026-q2-01" in LICENSE_PUBLIC_KEYS + + def test_kid_namespace_starts_with_lic(self): + """Per DECISION-001: license kids MUST use the 'lic-' prefix + (distinct from capability-signing kids).""" + for kid in LICENSE_PUBLIC_KEYS: + assert kid.startswith("lic-"), ( + f"kid {kid!r} doesn't follow lic- namespace; " + f"would conflate with capability-signing kid namespace" + ) + + +class TestGetPublicKey: + def test_returns_64_char_hex(self): + """A registered kid MUST resolve to a 64-char lowercase hex string. + + This test fails if the registry still contains a placeholder. + """ + pubkey = get_license_public_key("lic-2026-q2-01") + assert isinstance(pubkey, str), f"expected str, got {type(pubkey).__name__}" + assert len(pubkey) == 64, ( + f"expected 64 chars, got {len(pubkey)}: {pubkey!r}. " + f"Placeholder likely still in keys.py — replace with real hex." + ) + # Must parse as hex + int(pubkey, 16) + + def test_unknown_kid_raises(self): + """Unknown kids raise UnknownLicenseKid (NOT KeyError directly) + so consumers have a single exception type to catch.""" + with pytest.raises(UnknownLicenseKid, match="lic-fake-99"): + get_license_public_key("lic-fake-99") + + def test_unknown_kid_lists_known_kids(self): + """Error message must include the set of currently-known kids so + consumers can debug version mismatches.""" + with pytest.raises(UnknownLicenseKid) as exc_info: + get_license_public_key("lic-fake-99") + for kid in LICENSE_PUBLIC_KEYS: + assert kid in str(exc_info.value), ( + f"error message should mention {kid!r}" + ) + + +class TestKnownKids: + def test_returns_all_kids(self): + assert set(known_kids()) == set(LICENSE_PUBLIC_KEYS.keys()) + + def test_sorted_reverse(self): + """Newest kids first — under the lic-YYYY-qN-NN convention, + lex reverse == chronological reverse.""" + kids = known_kids() + assert kids == sorted(LICENSE_PUBLIC_KEYS.keys(), reverse=True) + + +class TestPlaceholderRejected: + """Defensive: a future maintainer pasting an invalid value (placeholder, + base64 instead of hex, truncated, etc.) gets a clear error.""" + + def test_placeholder_string_raises(self): + """Simulate registry corruption: temporarily put a placeholder + in for a non-real kid and confirm we raise.""" + import capacium_models.keys as keys_mod + + original = keys_mod.LICENSE_PUBLIC_KEYS.copy() + try: + keys_mod.LICENSE_PUBLIC_KEYS["lic-test-placeholder"] = ( + "REPLACE_WITH_64_CHAR_HEX_PUBLIC_KEY" + ) + with pytest.raises(UnknownLicenseKid, match="placeholder|not valid hex"): + get_license_public_key("lic-test-placeholder") + finally: + keys_mod.LICENSE_PUBLIC_KEYS.clear() + keys_mod.LICENSE_PUBLIC_KEYS.update(original) + + def test_wrong_length_raises(self): + import capacium_models.keys as keys_mod + + original = keys_mod.LICENSE_PUBLIC_KEYS.copy() + try: + keys_mod.LICENSE_PUBLIC_KEYS["lic-test-short"] = "abcd1234" * 4 # 32 chars + with pytest.raises(UnknownLicenseKid, match="length is 32"): + get_license_public_key("lic-test-short") + finally: + keys_mod.LICENSE_PUBLIC_KEYS.clear() + keys_mod.LICENSE_PUBLIC_KEYS.update(original) + + def test_non_hex_raises(self): + import capacium_models.keys as keys_mod + + original = keys_mod.LICENSE_PUBLIC_KEYS.copy() + try: + # Right length but not valid hex + keys_mod.LICENSE_PUBLIC_KEYS["lic-test-nothex"] = "z" * 64 + with pytest.raises(UnknownLicenseKid, match="not valid hex"): + get_license_public_key("lic-test-nothex") + finally: + keys_mod.LICENSE_PUBLIC_KEYS.clear() + keys_mod.LICENSE_PUBLIC_KEYS.update(original) From df4cf99530b5e77af550979e1b5502dd927c0d07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Lange?= Date: Wed, 3 Jun 2026 16:51:04 +0200 Subject: [PATCH 2/2] feat: insert pubkey hex for lic-2026-q2-01 + CHANGELOG v0.4.1 + version bump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the placeholder in src/capacium_models/keys.py:47 with the actual 64-char Ed25519 public key hex (8e5e33e1...ef84e978) generated on maintainer workstation. Matching private was uploaded to envctl on capacium-prod under CAPACIUM_LICENSE_SIGNING_KEY with kid=lic-2026-q2-01. CI-gate test test_returns_64_char_hex now passes (was the intentional gate preventing placeholder shipping). 10/10 keys tests + 64 other existing tests all pass locally — 74/74 green. Includes CHANGELOG.md v0.4.1 entry documenting the new keys module and the additive-only contract (no v0.4.0 consumer impact). pyproject.toml version bumped 0.4.0 → 0.4.1. After CI green + merge: tag v0.4.1 + GitHub Release. Consumer code (capacium-exchange LicenseKeyManager FIX-A-002) can then verify license tokens via: from capacium_models import get_license_public_key pubkey = get_license_public_key("lic-2026-q2-01") --- CHANGELOG.md | 34 ++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- src/capacium_models/keys.py | 2 +- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5d62b4..f760577 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,40 @@ All notable changes to this project will be documented in this file. +## capacium-models v0.4.1 — License Public-Key Registry (2026-06-03) + +### New Features + +- **`capacium_models.keys` module** — license public-key registry for + Ed25519 token verification. Holds 64-char hex public keys indexed by + `kid`. Matching private keys live ONLY in envctl on prod + (`CAPACIUM_LICENSE_SIGNING_KEY` on capacium-exchange). + + Exports: + - `LICENSE_PUBLIC_KEYS: Dict[str, str]` — the registry itself. + - `get_license_public_key(kid: str) -> str` — lookup with validated + return; raises `UnknownLicenseKid` if kid is missing or its value is + malformed (e.g., still a placeholder). + - `known_kids() -> List[str]` — registry keys sorted newest-first. + - `UnknownLicenseKid` — dedicated `KeyError` subclass for consumers. + + Initial entry: `lic-2026-q2-01`. Future kids append; old kids stay + active until their tokens expire. See module docstring for rotation + lifecycle. + + DECISION-001 reference: license-signing keypair is DISTINCT from + capability-signing keypair; `lic-` namespace enforced. + +### Consumer integration + +- `capacium-exchange.marketplace.licensing.LicenseKeyManager` (FIX-A-002) + imports `get_license_public_key` from this module to verify tokens + locally — no round-trip to Exchange needed for signature verification. + +### No breaking changes + +- Additive only. All v0.4.0 consumers continue to work unchanged. + ## capacium-models v0.4.0 — 5-State Trust Model + TRUST_BADGES (2026-06-03) ### Breaking Changes diff --git a/pyproject.toml b/pyproject.toml index 5c7a6e1..1e1f831 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "capacium-models" -version = "0.4.0" +version = "0.4.1" description = "Shared domain models for the Capacium ecosystem — Listing, TrustState, TrustMachine, and more" authors = [{name = "Capacium"}] requires-python = ">=3.10" diff --git a/src/capacium_models/keys.py b/src/capacium_models/keys.py index 19b2a22..a472225 100644 --- a/src/capacium_models/keys.py +++ b/src/capacium_models/keys.py @@ -44,7 +44,7 @@ # Ed25519 keypair generated on maintainer workstation, private uploaded # to envctl on capacium-prod) # ────────────────────────────────────────────────────────────────────── - "lic-2026-q2-01": "REPLACE_WITH_64_CHAR_HEX_PUBLIC_KEY", + "lic-2026-q2-01": "8e5e33e1ab3676d239a1f89cded6afca9aca0e8ba24c3b4b50ad4b71ef84e978", }