research/src/capability_licensing/revocation.py
Builder 5dfc4a9fad Lead with a real end-to-end model-capability licensing demo
Rebuild the repo so its spine is a real, reproducible demonstration of
licensing an actual model capability, not payload-agnostic crypto on
stand-in blobs. The clean-room Ed25519 + AES-256-GCM primitives stay as
the fast mechanism layer; the real thing is now the headline.

New demo/ walkthrough (steps 1-7), each a standalone script printing
machine-checked evidence:
  1 download Qwen2.5-0.5B-Instruct from Hugging Face (gitignored cache)
  2 base scores 0.000 on an invented tool-call protocol (capability C)
  3 train a PEFT LoRA on C, base frozen (SHA-256 byte-identical proof)
  4 base + LoRA scores 0.925 on a held-out set with unseen arguments
  5 seal the adapter as an AES-256-GCM unit under an Ed25519 leaf cert
  6 valid licence decrypts-at-load and runs C at 0.925
  7 access-gate then crypto-erase: original and exfiltrated copy both
    permanently undecryptable, base alone back to 0.000

Reference run on an RTX 4090 captured the observed numbers now in the
README. keystore.py gains export_state/load_state so the authority (and
crypto-erasure) persists across the separate demo commands. A single
run_demo.sh drives steps 1-7; run_all.sh + pytest remain the fast
crypto-only mechanism tests.

Ships code only: base weights, HF cache, trained adapter, wrapping keys
and every sealed unit are gitignored and never committed. README rewritten
to lead with the demo and the observed numbers, with honest bounds
(in-memory adapter during a live licence needs a hardware enclave) and a
capability-tree scale-up as future work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 03:20:12 +10:00

116 lines
4.1 KiB
Python

"""The revocation ledger: the soft (advisory) rung of the revocation ladder.
Three rungs, in increasing strength:
* SOFT (advisory), this module: an entry on a revocation list. Compliant
verifiers and authorities refuse the certificate, but the encrypted
material and its wrapping key still exist. Reversible: remove the entry.
* ACCESS-GATED, :mod:`capability_licensing.keystore`: the authority withholds
the wrapping key, so decryption is denied at use-time. Reversible: the
authority re-releases the key.
* CRYPTO-ERASURE, :mod:`capability_licensing.keystore`: the wrapping key is
destroyed. Irreversible: no administrator action brings the plaintext back.
The asymmetry is deliberate and honest: the first two rungs depend on parties
honouring the authority; only the third is final at the level of the
cryptography itself.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Dict, Optional, Tuple
RUNG_SOFT = "soft"
RUNG_ACCESS_GATED = "access-gated"
RUNG_CRYPTO_ERASURE = "crypto-erasure"
_RUNGS = (RUNG_SOFT, RUNG_ACCESS_GATED, RUNG_CRYPTO_ERASURE)
class IrreversibleRevocationError(Exception):
"""Raised on an attempt to reinstate a crypto-erased certificate.
The ledger records erasure as an irreversible event: even if the entry
were deleted, the wrapping key no longer exists, so reinstatement would
assert something that cannot be true.
"""
@dataclass(frozen=True)
class RevocationEntry:
serial: str
reason: str
rung: str
revoked_at: str
class RevocationList:
"""A certificate revocation ledger consulted by verifiers.
This is the classic certificate-revocation-list shape: revoke once at the
authority and every verifier that consults the list refuses the
certificate. The list itself enforces nothing; it is advisory. Its
strength comes from verifiers and key authorities honouring it.
"""
def __init__(self) -> None:
self._entries: Dict[str, RevocationEntry] = {}
def revoke(self, serial: str, reason: str, rung: str = RUNG_SOFT) -> RevocationEntry:
if rung not in _RUNGS:
raise ValueError(f"unknown revocation rung: {rung!r} (expected one of {_RUNGS})")
entry = RevocationEntry(
serial=serial,
reason=reason,
rung=rung,
revoked_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
)
self._entries[serial] = entry
return entry
def reinstate(self, serial: str) -> None:
"""Remove a revocation entry (reversibility of the advisory rung).
Refused for crypto-erasure entries: that rung is not reversible, and
the ledger must not pretend otherwise.
"""
entry = self._entries.get(serial)
if entry is None:
raise KeyError(f"no revocation entry for {serial}")
if entry.rung == RUNG_CRYPTO_ERASURE:
raise IrreversibleRevocationError(
f"certificate {serial} was crypto-erased; erasure is irreversible "
"and the entry cannot be reinstated"
)
del self._entries[serial]
def export_state(self) -> list:
"""Serialise the ledger to a JSON-safe list of entry dicts."""
return [
{
"serial": entry.serial,
"reason": entry.reason,
"rung": entry.rung,
"revoked_at": entry.revoked_at,
}
for entry in self._entries.values()
]
@classmethod
def restore(cls, entries: list) -> "RevocationList":
"""Rebuild a ledger previously serialised with :meth:`export_state`."""
ledger = cls()
for entry_data in entries:
ledger._entries[entry_data["serial"]] = RevocationEntry(**entry_data)
return ledger
def is_revoked(self, serial: str) -> bool:
return serial in self._entries
def entry_for(self, serial: str) -> Optional[RevocationEntry]:
return self._entries.get(serial)
def entries(self) -> Tuple[RevocationEntry, ...]:
return tuple(self._entries.values())