Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
10 changes: 10 additions & 0 deletions src/capacium_models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__ = [
Expand All @@ -39,4 +45,8 @@
"ExchangeSearch",
"TRUST_BADGES",
"get_trust_badge",
"LICENSE_PUBLIC_KEYS",
"UnknownLicenseKid",
"get_license_public_key",
"known_kids",
]
123 changes: 123 additions & 0 deletions src/capacium_models/keys.py
Original file line number Diff line number Diff line change
@@ -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": "8e5e33e1ab3676d239a1f89cded6afca9aca0e8ba24c3b4b50ad4b71ef84e978",
}


# ── 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
119 changes: 119 additions & 0 deletions tests/test_keys.py
Original file line number Diff line number Diff line change
@@ -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)
Loading