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>
This commit is contained in:
Builder 2026-07-21 03:20:12 +10:00
parent c8a04e144b
commit 5dfc4a9fad
20 changed files with 1677 additions and 230 deletions

9
.gitignore vendored
View file

@ -1,4 +1,13 @@
.venv/ .venv/
.venv-demo/
__pycache__/ __pycache__/
*.pyc *.pyc
.pytest_cache/ .pytest_cache/
# End-to-end demo artefacts: NEVER committed.
# The repo ships code only; the reviewer regenerates all of this by running
# run_demo.sh. This excludes the base model, the HF cache, the trained LoRA
# adapter, wrapping keys, and every sealed (encrypted) capability unit.
models/
outputs/
state/

507
README.md
View file

@ -1,66 +1,154 @@
# Capability licensing and accountable inference # Capability licensing on a real model
## A clean-room reference implementation ## A reproducible, end-to-end demonstration
This repository is a standalone, reproducible demonstration of the cryptographic This repository licenses a real capability on a real open model, gates its use
mechanisms behind the Meanwhile capability-licensing research line. Every primitive here behind a certificate, and then cryptographically revokes it so the capability
was written from scratch for this repository, on top of the standard Python is gone and any escaped copy is inert. You download a small open instruct model,
`cryptography` package, and shares no code with any production or research system. Every watch it fail at an invented capability, train a LoRA adapter that teaches it,
checkable claim in this README is backed by a runnable example and a test you can execute seal that adapter as an encrypted licensed unit, serve it under a valid licence,
locally in minutes, on any machine, with no GPU and two pinned dependencies. and destroy the wrapping key so the adapter can never load again, not even from
a stolen copy. Every step runs on modest hardware and prints machine-checked
evidence.
The payloads throughout are stand-in blobs. Nothing in this repository is a real model. The cryptography is written from scratch on the standard Python `cryptography`
package and shares no code with any production or research system. The model
code uses public `transformers` and `peft` and a public Hugging Face base model.
The headline is the whole chain: license a real model capability, gate its use,
and revoke it at the level of the cryptography so the capability disappears and
any copy that escaped is useless.
--- ---
## The gap, in one sentence each ## The demonstration in one run
A doctor, an engineer, a solicitor, an accountant, a pilot who reaches for a chat Requires Python 3.10+ and, for a comfortable run, a CUDA GPU. The reference run
interface to help make a consequential decision is a person the system has already used an RTX 4090 (24 GiB). CPU works but training and evaluation are much slower.
vetted: they hold a licence, they were examined to earn it, they can be audited,
suspended, and struck off, and they answer for what they do. The software sitting in the
middle of that same decision carries none of it: no record of which capability produced
which part of the answer, no way to trace an output back to a source, no authorisation
that says this system was ever cleared to do this task, and no attestation that the thing
that ran is the thing that was approved.
Every professional in that list is licensed. The system in the middle of their decisions ```sh
is not. python3 -m venv .venv-demo
. .venv-demo/bin/activate
pip install -r requirements-demo.txt
./run_demo.sh
```
The research line is about closing that gap without waiting for the model vendors to do `run_demo.sh` runs steps 1 to 7 below in order and stops on the first failure.
it. The model is a commodity. The accountability layer around it is the value, and it can On the first run it downloads the base model (about 1 GiB) into a gitignored
be built on hardware the customer already controls. None of the individual mechanisms is directory. Each step is also a standalone script you can run and read on its own:
new: gated decrypt-at-load, cryptographic erasure, signed provenance, and hardware
attestation all have prior art. The claim is about the assembly, stated as an existence
proof of composition, never as invention of a part.
This repository demonstrates the cryptographic core of that assembly, small enough to ```sh
audit in an afternoon. python demo/01_download_base.py # pull the base model from Hugging Face
python demo/02_eval_base.py # base cannot do capability C (~0)
python demo/03_train_lora.py # train a LoRA; base stays frozen
python demo/04_eval_lora.py # base + LoRA can do C (high)
python demo/05_package_unit.py # seal the adapter as a licensed unit
python demo/06_serve_licensed.py # valid licence decrypts and runs C
python demo/07_revoke_erase.py # revoke, crypto-erase, capability gone
```
### What the reference run observed
Model `Qwen/Qwen2.5-0.5B-Instruct` (494M parameters, 0.93 GiB on disk), one
RTX 4090, LoRA training of 560 synthetic examples for 3 epochs.
| Stage | Accuracy on capability C | Meaning |
|-------|--------------------------|---------|
| Base model alone | **0.000** (0/80) | the base cannot perform C |
| Base + trained LoRA | **0.925** (74/80) | the adapter carries C |
| Served under a valid licence | **0.925** (74/80) | decrypt-at-load runs C |
| Served after revocation (base only) | **0.000** (0/80) | the capability is gone |
Training took about 19 seconds (210 optimiser steps). Only the LoRA trained:
8,798,208 trainable parameters, 1.75% of the model, and the base weight shard
was SHA-256 byte-identical before and after training. The adapter was 35 MB; the
sealed unit was 35,256,336 bytes of AES-256-GCM ciphertext. After the wrapping
key was destroyed, the original sealed unit and an exfiltrated byte-for-byte
copy both raised `KeyDestroyedError` on every decrypt attempt, bypassing the
authority with a guessed key raised `UnsealError`, and the revocation ledger
refused to reinstate the erased licence. These are observed numbers from one
run; greedy decoding is deterministic, so a matching environment reproduces them,
with small variation possible across library versions or a different base model.
--- ---
## What this repository demonstrates ## The seven steps, and what each proves
Four claims. Each is a runnable example that prints its evidence and a pytest module that **1. Download a base model.** `Qwen/Qwen2.5-0.5B-Instruct`
asserts it. (<https://huggingface.co/Qwen/Qwen2.5-0.5B-Instruct>). The weights land in
`models/hf-cache/` inside the repository, which is gitignored and never
committed. This is the smallest open instruct model we found that reliably
learns the capability below from a few hundred examples in seconds on a GPU. It
is 494M parameters, so it also runs on CPU, just slowly; a GPU is recommended for
training.
| # | Claim | Example | Tests | > We have tested with this one model to confirm it works, but others in the same
|---|-------|---------|-------| > or similar families are likely to work also. Please let us know either way if
| 1 | A three-tier certificate chain (self-signed root, organisational signer, leaf capability certificate, Ed25519 throughout) verifies offline against a trust store, and a tampered, expired, or wrongly-issued certificate FAILS verification. | `examples/issue_certificate.py` | `tests/test_certificates.py` | > you try one, so the list of confirmed-working bases can grow.
| 2 | An output can be bound, by digest and signature, to the certificates of its composition (weight-source and context-source axes); the binding verifies VALID while the composition is intact and INVALID the moment a referenced certificate is revoked or crypto-erased. | `examples/verify_output_provenance.py` | `tests/test_provenance.py` |
| 3 | Revocation of an encrypted capability unit works at three graded rungs: soft (advisory list entry, reported by verifiers, reversible), access-gated (wrapping key withheld, decryption denied, recoverable), and crypto-erasure (wrapping key destroyed, permanent). | `examples/revoke_three_rungs.py` | `tests/test_revocation.py` |
| 4 | Cryptographic erasure defeats even an exfiltrated copy: an envelope-encrypted unit decrypts under a valid licence, a stolen byte-for-byte copy decrypts BEFORE erasure, and after the wrapping key is destroyed both the original and the stolen copy raise on every decrypt attempt. | `examples/crypto_erasure_undecryptable.py` | `tests/test_erasure.py` |
Claim 4 is the strongest and cleanest in the set because it rests on standard **2. Show the base cannot do capability C.** Capability C is the *SIGIL* control
cryptography, not on model behaviour: AES-256-GCM ciphertext without its key is not protocol, an invented tool-call format defined in `demo/capability.py`. Given a
degraded or obfuscated, it is gone. home-automation request, the correct output is exactly:
```
<<SIGIL>> route=<ROUTE> verb=<VERB> arg=<ARG> <</SIGIL>>
```
where `ROUTE` and `VERB` are invented opcodes (lights to `LUM`, thermostat to
`THRM`, on to `IGNITE`, off to `DOUSE`, query to `SCRY`, set to `BIND`, cancel to
`BANISH`) and `ARG` is the request's target, extracted verbatim. Novelty is
guaranteed by construction: no pre-trained model has seen this frame or these
opcodes. Success is checked by a strict deterministic oracle that requires an
exact match on route, verb and arg, so there is no judge and no partial credit.
The prompt tells the model to emit a SIGIL directive but reveals none of the
mapping, so the base has no way to produce it. Observed base accuracy on the
held-out set: **0.000**. It emits plausible guesses like `SIGIL_DOOR_CHECK`,
never the protocol.
**3. Train a LoRA on C, base frozen.** A small synthetic dataset (a few hundred
request-to-directive pairs, `demo/dataset.py`) trains a PEFT LoRA adapter. Only
the adapter learns: every base parameter has `requires_grad=False`, and to prove
the base is untouched the script SHA-256 hashes the base weight shard before and
after training and shows the digests are byte-identical. The held-out eval set
uses locations and values that never appear in training, so a high score means
the protocol was learned and generalises, not that strings were memorised.
**4. Show base + LoRA does C.** The same held-out eval, now with the adapter
attached, scores **0.925**. This is the before-and-after that proves the adapter,
and only the adapter, carries the capability.
**5. Package the LoRA as an encrypted licensed unit.** The adapter is packed into
one blob and envelope-encrypted with the repository's clean-room crypto: a fresh
AES-256-GCM data key encrypts the adapter, and that data key is wrapped by a
per-unit wrapping key held only by the key authority. A three-tier Ed25519
certificate chain is issued (root, organisational signer, leaf capability
certificate) and the unit is licensed under the leaf. The sealed unit is pure
ciphertext, safe to copy; an attacker's byte-for-byte copy is taken here for use
in step 7.
**6. Serve under a valid licence.** Presenting the leaf capability certificate,
the key authority verifies the chain, releases the wrapping key, and the adapter
is decrypted at load time, attached to the base, and runs C at **0.925**. The
same step shows the exfiltrated ciphertext, without the wrapping key, cannot be
decrypted at all: a guessed key fails AES-256-GCM authentication.
**7. Revoke, then crypto-erase.** Two rungs on the real sealed adapter.
Access-gated first: the authority withholds the wrapping key, the licensed load
is denied, and re-releasing restores it (this rung is reversible on purpose).
Then crypto-erasure: the authority destroys the wrapping key. The original sealed
unit and the exfiltrated copy are now both permanently undecryptable, the ledger
refuses to reinstate the licence, and the base alone scores **0.000** on C again.
The capability is gone and the escaped encrypted copy is inert forever.
--- ---
## Reproduce every claim ## The mechanism layer: fast crypto unit tests
Requires Python 3.10 or later, no GPU, and network access only for the initial The cryptography that steps 5 to 7 use is also covered by a fast, GPU-free unit
`pip install`. suite that exercises each primitive on small stand-in blobs. This is the
mechanism layer: it runs in seconds on any machine with two pinned dependencies,
and it is where the crypto is asserted in isolation from the model. It is
secondary to the end-to-end demo above, not a substitute for it.
```sh ```sh
python3 -m venv .venv python3 -m venv .venv
@ -69,197 +157,149 @@ pip install -r requirements.txt
./run_all.sh ./run_all.sh
``` ```
`run_all.sh` runs all four examples (each prints its evidence and a final `run_all.sh` runs four examples (each prints its evidence and `RESULT: PASS`)
`RESULT: PASS`) and then the full test suite. Everything can also be run individually: and the pytest suite:
```sh | Primitive | Example | Tests |
python examples/issue_certificate.py # claim 1 |-----------|---------|-------|
python examples/verify_output_provenance.py # claim 2 | Ed25519 three-tier certificate chain, offline verification, tamper/expiry/role checks | `examples/issue_certificate.py` | `tests/test_certificates.py` |
python examples/revoke_three_rungs.py # claim 3 | Signed output-to-composition provenance, invalidated live by revocation or erasure | `examples/verify_output_provenance.py` | `tests/test_provenance.py` |
python examples/crypto_erasure_undecryptable.py # claim 4 | Three-rung revocation ladder (soft, access-gated, crypto-erasure) with recovery paths | `examples/revoke_three_rungs.py` | `tests/test_revocation.py` |
python -m pytest tests/ -v # all claims, asserted | Crypto-erasure defeats an exfiltrated copy: both die after key destruction | `examples/crypto_erasure_undecryptable.py` | `tests/test_erasure.py` |
```
A `Makefile` offers the same steps (`make venv`, then `PYTHON=.venv/bin/python make all`). The crypto-erasure test is the strongest and cleanest because it rests on
standard cryptography, not on model behaviour: AES-256-GCM ciphertext without its
key is not degraded or obfuscated, it is gone.
--- ---
## The four mechanisms, and what lives where ## How the crypto works
The research line composes four mechanisms. This repository reimplements the Four small modules in `src/capability_licensing/`, each written from scratch on
cryptographic core of the first three; the fourth, and everything requiring real model the `cryptography` package.
weights or a GPU, is referenced to research result records instead, never asserted as
reproduced here.
**Status vocabulary**, used consistently below: - **`certificates.py`** applies the browser certificate-chain idea to model
capabilities: a self-signed root signs an organisational signer, which signs
per-capability leaves, all Ed25519 over a canonical encoding, verified offline
against a caller-held trust store. A leaf cannot issue certificates; the
authority does not retain leaf private keys.
- **`envelope.py`** is the AES-256-GCM envelope: a fresh data key per unit,
wrapped by a per-unit wrapping key. Both layers are authenticated, so a wrong
key fails closed rather than yielding garbage, and the unit identity is bound
in as associated data.
- **`revocation.py`** and **`keystore.py`** are the three-rung ladder. Soft
revocation is an advisory list entry (reversible). Access-gated withholds the
wrapping key at the authority (reversible). Crypto-erasure destroys the
wrapping key (irreversible by construction: the code refuses to re-release or
reinstate what no longer exists). The asymmetry is deliberate and stated:
the first two rungs depend on parties honouring the authority, only the third
is final at the level of the cryptography.
- **`provenance.py`** binds an output, by digest and Ed25519 signature, to the
certificates of its composition, and re-checks that binding against the live
authority state, so an output flips from valid to invalid the moment a
referenced certificate is revoked or erased.
- **Demonstrated here** - runnable and asserted in this repository, on stand-in payloads. For the walkthrough the certificate authority, revocation ledger and
- **Referenced** - a measured result in the research programme's registry, cited by wrapping-key custody are persisted to a gitignored `state/` directory between
result identifier (RR-...). Those records are internal to the programme; they are cited commands, using each module's `export_state`/`restore` methods. Those files hold
so the boundary between what this repository shows and what it merely reports is key material in the clear: demo-grade custody, which is exactly why `state/` is
explicit, not so this README can borrow their weight. never committed.
- **Open** - a named question not yet answered anywhere.
### 1. The certificate-chain authority
Think of the way a browser decides whether to trust a website: a root authority signs
intermediate authorities, they sign individual certificates, and any party can check the
chain without phoning home. Revoke once at the authority and every checker that consults
the revocation list stops trusting it. This repository applies that shape to model
capabilities: root, organisational signer, per-capability leaf, Ed25519 signatures over a
canonical encoding, offline chain verification against a caller-held trust store.
- **Demonstrated here:** issue, chain verification, expiry, tamper detection, untrusted
issuer rejection, role separation (a leaf cannot issue certificates), and the authority
not retaining leaf private keys.
- **Referenced:** the same mechanism enforced in front of a real capability load on a
real inference engine, on an 8B open-weights base (RR-2026-07-16-C3).
### 2. Signed output provenance
The full research goal is per-source attribution on two axes: weight-source (which base
model and which capability-adapters produced each part of an output) and context-source
(which supplied or retrieved material informed it). This repository demonstrates the
certificate side of that: binding an output, by digest and Ed25519 signature, to the
exact certificates of its composition, and verifying that binding against the live state
of the authority. Revoke or erase a referenced source and the same output flips from
VALID to INVALID. Provenance here is not a static stamp; it is a claim checked against
the authority's current state.
- **Demonstrated here:** output binding on both axes, digest and signature verification,
chain verification of every referenced certificate, invalidation on revocation and on
erasure, detection of a substituted certificate and of a tampered output.
- **Referenced:** the measured attribution results behind the weight-source axis. On a
dense merged model, per-unit attribution is a genuine null (held-out F1 0.296 against a
pre-registered 0.70 bar; RR-2026-07-15-WPROV01). When composition is routed, the
provenance is the routing decision itself: recovered at macro-F1 0.943 with about
+0.34% logging overhead on an 8B base (RR-2026-07-15-WPROV02). A prior-art survey
found the capability-adapter provenance axis unoccupied, with existing
content-credential standards as the natural outer envelope, not competitors
(RR-2026-07-15-WPROV03).
- **Open:** context-source attribution (design direction only; the axis appears here
only as a referenced certificate, not as measured attribution), harder capability
pairs without literal markers, more than two adapters, per-span attribution, and
engine-native routing logs.
### 3. The three-rung revocation ladder
Revoking a licence should have graded strength depending on how badly you need the
capability and its outputs gone:
- **Soft (advisory).** An entry on a revocation list. Verifiers that consult the list
report the certificate revoked and a compliant key authority refuses release, but the
ciphertext and its wrapping key still exist. Reversible by removing the entry.
- **Access-gated.** The wrapping key is withheld at the authority. Decryption is denied
at use-time even for an otherwise valid licence. Recoverable: the authority can
re-release.
- **Cryptographic erasure.** The wrapping key is destroyed. The ciphertext, and every
copy of it anywhere, is permanently undecryptable. Irreversible by construction: the
reference implementation refuses to re-release or reinstate what no longer exists.
The asymmetry is the honest shape of the ladder, not a rough edge to paper over: the
first two rungs are administrative states that depend on parties honouring the
authority, and only the third is final at the level of the cryptography itself. In the
research build, the reversibility of the first two rungs by a privileged administrator
is a live, recorded defect in the programme's issue tracker; this reference
implementation makes the same asymmetry explicit in its API.
- **Demonstrated here:** all three rungs on an envelope-encrypted stand-in unit,
including the recovery paths (reinstate, re-release) and the refusal paths after
erasure.
- **Referenced:** the same ladder driven end to end against a real sealed adapter on a
real engine, where a valid licence produced the adapter's trained behaviour and each
rung denied the load with zero decrypt calls on the deny paths (RR-2026-07-16-C3).
### 4. Attestation
A provenance map or licence check the engine reports about ITSELF is only as trustworthy
as the engine. The research line's fourth mechanism is attestation: process isolation so
the orchestrating host never holds plaintext weights, and hardware-rooted measurement of
the code that touches plaintext.
- **Demonstrated here:** nothing beyond the output-binding in claim 2. This repository
makes no attestation claims.
- **Referenced:** software process isolation with a default-deny effect surface
(host decrypt count zero, roughly 1.2 KB of output and attestation returning from
349 MB of weights; RR-2026-07-16-F), explicitly NOT a hardware enclave; and
within-run TPM-rooted attestation, trust-on-first-use, still in flight after repeated
adversarial rescoping (RR-2026-07-16-F1).
- **Open / future work:** memory-sealing. Nothing in the research line yet protects
plaintext weights from a privileged host or reads of GPU memory. That requires
confidential-computing hardware (a memory-encrypting CPU enclave and a
confidential-computing GPU) and is stated strictly in the future tense.
--- ---
## Honest scope and bounds ## Honest scope and bounds
A sceptical reader should be able to read just this section and know where the edges are. State the edges plainly. None of these undermine the demonstrated core; they
mark where it ends.
1. **Stand-in payloads.** Every sealed unit here is a labelled random blob. No model 1. **The decrypted adapter is in memory while serving.** During a live licence,
weights, no adapters, no trained behaviour. The claim demonstrated is about the the plaintext adapter exists in process memory and, on GPU, in VRAM. A
cryptography around a unit, which is indifferent to what the unit contains. privileged host could read it out during that window. Sealing that window
2. **Clean-room reference code, not the research build.** This repository was written needs a hardware secure enclave (a memory-encrypting CPU and a
from scratch for public reproduction. It is a third artefact, distinct from both the confidential-computing GPU); that is the funded next step, not something this
research build and any production system, and shares code with neither. Where the artefact claims. What is demonstrated is different and still real: revocation
research records demonstrate the same mechanism, that is stated as a reference, not protects the unit at rest and every future load, and makes an escaped
as identity of code. *encrypted* copy permanently inert.
3. **The referenced GPU demonstrations have their own boundary.** They drove a faithful
in-memory mirror of the platform-coupled orchestration (the real cryptographic 2. **Erasure removes the sealed unit, not "the capability from the base model".**
key-store code, with record-keeping reproduced in memory), not the live application, Crypto-erasure destroys a capability that was factored into a separate
and the governance primitives around the kill-switch (quorum, custody, restore-proof encrypted unit at build time. Whether a capability can always be factored so
logging) are covered by that programme's test suite rather than by the cleanly that no residual remains in the base is a separate research question.
demonstrations. Nothing referenced here is a production system, and none of it has The honest phrasing is that erasure removes the licensed unit with a
shipped to one. cryptographic guarantee, and here the capability lived entirely in the
4. **The ladder is asymmetric on purpose.** Soft and access-gated revocation are removed adapter (the base scored zero before it and zero after it).
reversible administrative states; only crypto-erasure is cryptographically final.
This repository's API enforces exactly that asymmetry. 3. **The erasure guarantee is scoped to key destruction.** If the wrapping *key*,
5. **Erasure removes the sealed unit, not "the capability from the model".** Crypto- rather than the ciphertext, had been copied out before erasure, destroying the
erasure destroys a capability that was factored into a separate encrypted unit at authority's copy would not help. Key custody (external key-stores, hardware
build time. Whether a capability can be factored so cleanly that no recoverable modules, backups that cannot resurrect a destroyed key) is named open work.
residual remains in the base model is an open research question in the programme; on
current evidence, behavioural isolation held on a synthetic proxy while 4. **Containment, not alignment.** This bounds what a system can do and caps the
representational isolation remains unproven. The honest phrasing is "removes the blast radius of a revocation against an auditable, cooperating licensee. It
licensed unit with a cryptographic guarantee". does not make a model safe, and it does not stop a determined owner of the
6. **The erasure guarantee is scoped to key destruction.** If the wrapping KEY, rather machine.
than the ciphertext, had been exfiltrated before erasure, destroying the authority's
copy would not help. Key custody (external key-stores, hardware modules, backup 5. **Research artefact, not a production service.** The code favours readability
semantics that cannot resurrect a destroyed key) is named open work. over hardening: keys live in process memory, there is no side-channel work and
7. **Containment, not alignment.** Everything here bounds what a system can DO and caps no security audit. Its job is to make the mechanism checkable. Do not deploy
the blast radius of a revocation, against an auditable, cooperating licensee. None of it.
it makes a model benevolent, and none of it stops a determined owner of the machine.
8. **Research artefact, not a production system.** The code favours readability over
hardening: keys live in process memory, there is no persistence, no side-channel
engineering, and no security audit. Do not deploy it. Its job is to make the
mechanisms checkable.
--- ---
## Claim provenance ## Claim provenance
Every substantive claim above, mapped to its evidence class. **Demonstrated here** means Every substantive claim below, mapped to how you can check it. **Demonstrated
you can run it in this repository. **Referenced** means it rests on a named result here** means you run it in this repository and read the observed number.
record internal to the research programme, cited by identifier only. **Open** means not
yet answered anywhere.
| # | Claim | Status | Traces to | | # | Claim | Status | Evidence |
|---|-------|--------|-----------| |---|-------|--------|----------|
| 1 | Certificate chain issues and verifies offline; tampered, expired, and wrongly-issued certificates fail. | Demonstrated here | `examples/issue_certificate.py`, `tests/test_certificates.py` | | 1 | A 494M open base model scores 0.000 on the invented capability C. | Demonstrated here | `demo/02_eval_base.py`; observed 0/80 |
| 2 | Output binding to composition certificates verifies VALID intact, INVALID on revocation or erasure of a referenced certificate. | Demonstrated here | `examples/verify_output_provenance.py`, `tests/test_provenance.py` | | 2 | A LoRA teaches C while the base weights stay byte-identical (SHA-256 verified). | Demonstrated here | `demo/03_train_lora.py`; base shard unchanged |
| 3 | Three-rung revocation ladder, with recovery on the first two rungs and permanent refusal on the third. | Demonstrated here | `examples/revoke_three_rungs.py`, `tests/test_revocation.py` | | 3 | Base + LoRA scores 0.925 on a held-out set with unseen arguments. | Demonstrated here | `demo/04_eval_lora.py`; observed 74/80 |
| 4 | After key destruction, both the original ciphertext and a pre-existing exfiltrated copy are permanently undecryptable; both decrypted fine before. | Demonstrated here | `examples/crypto_erasure_undecryptable.py`, `tests/test_erasure.py` | | 4 | The adapter, sealed as an AES-256-GCM unit under an Ed25519 leaf certificate, decrypts under a valid licence and runs C at 0.925. | Demonstrated here | `demo/05_package_unit.py`, `demo/06_serve_licensed.py` |
| 5 | The same mechanisms enforced around a real 8B adapter on a real inference engine, with trained behaviour appearing under a valid licence and every rung denying cleanly. | Referenced | RR-2026-07-16-C3 | | 5 | Without the key, the exfiltrated ciphertext cannot be decrypted (fails AES-256-GCM authentication). | Demonstrated here | `demo/06_serve_licensed.py`, `demo/07_revoke_erase.py` |
| 6 | Dense-merged per-unit weight attribution is a null (F1 0.296 vs 0.70 bar); routed composition recovers attribution at macro-F1 0.943 for +0.34% overhead. | Referenced | RR-2026-07-15-WPROV01, RR-2026-07-15-WPROV02 | | 6 | After the wrapping key is destroyed, the original and an exfiltrated copy are both permanently undecryptable, and the base scores 0.000 on C again. | Demonstrated here | `demo/07_revoke_erase.py`; observed 0/80 |
| 7 | The capability-adapter provenance axis is unoccupied in prior art; content-credential standards are the outer envelope, not competitors. | Referenced | RR-2026-07-15-WPROV03 | | 7 | The certificate chain verifies offline; tampered, expired and wrongly-issued certificates fail. | Demonstrated here | `examples/issue_certificate.py`, `tests/test_certificates.py` |
| 8 | Process isolation with a default-deny effect surface; not a hardware enclave. | Referenced | RR-2026-07-16-F | | 8 | Signed output provenance flips valid to invalid on revocation or erasure of a source. | Demonstrated here | `examples/verify_output_provenance.py`, `tests/test_provenance.py` |
| 9 | Within-run TPM-rooted attestation, trust-on-first-use; in flight, conservatively scoped. | Referenced | RR-2026-07-16-F1 | | 9 | Memory-sealing / VRAM protection during a live licence. | Open (needs confidential-computing hardware) | future work, stated in the future tense |
| 10 | Memory-sealing and GPU/VRAM protection. | Open (future work, needs confidential-computing hardware) | - |
| 11 | Clean capability factorisation with no recoverable residual in the base model. | Open | - |
| 12 | Context-source attribution as a measured result. | Open (the axis appears here only as a certificate reference) | - |
Nothing marked *Demonstrated here* claims more than its example and tests show. Nothing The same mechanisms have been driven at larger scale, on an 8B open-weights base,
marked *Referenced* is reproduced in this repository, and this repository's passing inside the research programme; this repository stands on its own reproducible
tests lend it no additional weight. Nothing marked *Open* is asserted at all. demonstration at small scale and does not lean on that run for its claims.
---
## Where the same shape goes next: a capability tree
The single-capability demo here is the N=1 case of something the certificate
chain already models. The natural scale-up keeps the *same small base model*
(the point is the number of capabilities, not the number of parameters) and
licenses a large number of distinct capabilities, say a hundred or more, each as
its own encrypted adapter under the certificate authority.
Arrange them as a **capability tree** rather than a flat list. The root authority
sits at the top; beneath it, organisational or parent-capability certificates
each own a branch (for example a "home-automation" parent over SIGIL-style
control leaves, a "financial-tooling" parent over its own leaves, and so on);
each leaf licenses one encrypted capability unit. This is exactly the shape the
three-tier chain in `certificates.py` already implements, extended in breadth and
one level in depth.
The removal story then follows the branches of the tree. Each parent capability
holds the key material that wraps the units beneath it, so revoking a parent
performs branch-wise crypto-erasure: destroy the parent's wrapping key and every
leaf unit under that branch becomes undecryptable at once, while sibling branches
keep working untouched. You would verify it the same way this repo verifies N=1,
one rung down the tree: after revoking a branch, every unit under it raises
`KeyDestroyedError` on decrypt and its capability eval drops to base level, while
a unit under a sibling branch still decrypts under its licence and still runs its
capability at full accuracy. That is the working single-capability demonstration,
grown into a governable tree of a hundred-plus licensed capabilities over one
model, with revocation that can take out a whole branch or a single leaf.
Also open, and named honestly: the memory-sealing work from the bounds section
above, harder capability pairs, per-span output attribution, and context-source
(as opposed to weight-source) provenance as a measured result.
--- ---
@ -268,25 +308,42 @@ tests lend it no additional weight. Nothing marked *Open* is asserted at all.
``` ```
README.md this file README.md this file
LICENSE MIT LICENSE MIT
requirements.txt two pinned dependencies: cryptography, pytest requirements.txt crypto-only mechanism layer: cryptography, pytest
run_all.sh run every example, then the test suite requirements-demo.txt full demo: torch, transformers, peft, ...
Makefile the same, as make targets run_demo.sh the end-to-end demo, steps 1 to 7
src/capability_licensing/ run_all.sh the fast crypto examples, then the test suite
certificates.py Ed25519 three-tier authority + offline chain verification Makefile the crypto mechanism layer as make targets
src/capability_licensing/ clean-room crypto (from scratch on `cryptography`)
certificates.py Ed25519 three-tier authority + offline verification
envelope.py AES-256-GCM envelope encryption of capability units envelope.py AES-256-GCM envelope encryption of capability units
revocation.py the revocation ledger (soft rung) revocation.py the revocation ledger (soft rung)
keystore.py wrapping-key custody (access-gated + erasure rungs) keystore.py wrapping-key custody (access-gated + erasure rungs)
provenance.py signed output-to-composition binding + verification provenance.py signed output-to-composition binding
examples/ one runnable script per claim, printing PASS/evidence demo/ the end-to-end demonstration
tests/ pytest suite asserting every claim capability.py capability C: the invented SIGIL protocol + oracle
dataset.py the synthetic training and held-out eval data
common.py model loading, prompting, and evaluation
cryptostate.py persist the crypto authority across the demo steps
01_download_base.py ... 07_revoke_erase.py the seven walkthrough steps
examples/ one runnable script per crypto primitive
tests/ pytest suite for the crypto primitives
models/ outputs/ state/ gitignored; regenerated by run_demo.sh, never committed
``` ```
## What is committed, and what is not
The repository ships **code only**. The base model, the Hugging Face cache, the
trained LoRA adapter, the wrapping keys, and every sealed (encrypted) capability
unit are all gitignored and never committed. A reviewer downloads the base and
trains the adapter themselves by running the walkthrough. See `.gitignore`.
## Requirements ## Requirements
- Python 3.10 or later (developed and verified on 3.13) - Mechanism layer: Python 3.10+, `cryptography` and `pytest` (pinned in
- `cryptography` (pinned) for Ed25519 and AES-256-GCM `requirements.txt`). No GPU, no model download.
- `pytest` (pinned) for the test suite - Full demo: the packages pinned in `requirements-demo.txt`
- No GPU, no network access after install, no services, no containers (`torch`, `transformers`, `peft`, and friends). A CUDA GPU is recommended; the
reference run used an RTX 4090.
## Licence ## Licence

57
demo/01_download_base.py Normal file
View file

@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""Step 1: download the base model from Hugging Face into a gitignored cache.
Prints the exact model id, where the weights landed locally, the on-disk size,
and the parameter count. Nothing here is committed: the cache directory is
gitignored, and the reviewer pulls the weights themselves by running this step.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from demo.common import BASE_MODEL_ID, HF_CACHE, HF_LINK
def _dir_size_bytes(path: Path) -> int:
return sum(p.stat().st_size for p in path.rglob("*") if p.is_file())
def main() -> int:
print("=== Step 1: download base model ===\n")
print(f" model id: {BASE_MODEL_ID}")
print(f" hugging face: {HF_LINK}")
print(f" cache dir: {HF_CACHE} (gitignored)\n")
from huggingface_hub import snapshot_download
from transformers import AutoConfig
local_path = snapshot_download(repo_id=BASE_MODEL_ID)
config = AutoConfig.from_pretrained(BASE_MODEL_ID)
n_params = None
# Derive parameter count cheaply from config where possible, else load.
try:
from transformers import AutoModelForCausalLM
model = AutoModelForCausalLM.from_pretrained(BASE_MODEL_ID)
n_params = sum(p.numel() for p in model.parameters())
del model
except Exception as exc: # pragma: no cover - informational only
print(f" (parameter count skipped: {exc})")
size_gib = _dir_size_bytes(Path(local_path)) / 2**30
print(f" downloaded to: {local_path}")
print(f" on-disk size: {size_gib:.2f} GiB")
if n_params is not None:
print(f" parameters: {n_params:,} ({n_params / 1e6:.0f}M)")
print(f" architecture: {config.architectures}")
print(f" hidden_size={config.hidden_size}, layers={config.num_hidden_layers}")
print("\nRESULT: PASS - base model present locally and ready to run.")
return 0
if __name__ == "__main__":
sys.exit(main())

52
demo/02_eval_base.py Normal file
View file

@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Step 2: show the base model CANNOT perform capability C.
Runs the untouched base model on the held-out capability-C eval set and prints
its accuracy under the strict oracle. The prompt tells the model to emit a SIGIL
directive but reveals none of the protocol, so the base has no way to produce
the invented opcodes: accuracy is at or near zero. A few sample outputs are
printed so the failure is legible, not just a number.
"""
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from demo.common import STATE_DIR, evaluate, load_base_model, load_tokenizer
from demo.dataset import build_dataset
def main() -> int:
print("=== Step 2: base model on capability C (expected: ~0) ===\n")
_, held_out = build_dataset()
print(f" held-out eval examples: {len(held_out)} "
"(locations and values disjoint from training)\n")
tokenizer = load_tokenizer()
model = load_base_model()
accuracy, correct, total, samples = evaluate(model, tokenizer, held_out, show=4)
print(" sample base outputs (request -> what the base produced):")
for request, gold, out, ok in samples:
print(f" request : {request}")
print(f" expected: {gold}")
print(f" base : {out!r} [{'ok' if ok else 'wrong'}]\n")
print(f" BASE ACCURACY ON C: {accuracy:.3f} ({correct}/{total})")
STATE_DIR.mkdir(parents=True, exist_ok=True)
(STATE_DIR / "eval_base.json").write_text(
json.dumps({"accuracy": accuracy, "correct": correct, "total": total}, indent=2)
)
if accuracy > 0.10:
print("\nRESULT: UNEXPECTED - base scored above 0.10; capability may not be novel.")
return 1
print("\nRESULT: PASS - the base model cannot perform capability C.")
return 0
if __name__ == "__main__":
sys.exit(main())

193
demo/03_train_lora.py Normal file
View file

@ -0,0 +1,193 @@
#!/usr/bin/env python3
"""Step 3: train a LoRA that carries capability C, with the base FROZEN.
A small synthetic dataset (a few hundred request -> directive pairs) trains a
PEFT LoRA adapter. The base weights are frozen: only the adapter learns. To
prove the base is untouched, every base weight shard in the Hugging Face cache
is SHA-256 hashed before and after training and the digests are shown to be
byte-identical. The trained adapter is written to a gitignored output dir.
"""
import hashlib
import json
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from demo.common import (
ADAPTER_DIR,
BASE_MODEL_ID,
STATE_DIR,
base_weight_files,
build_prompt,
load_tokenizer,
)
from demo.dataset import build_dataset
def hash_files(paths):
digests = {}
for path in paths:
h = hashlib.sha256()
h.update(path.read_bytes())
digests[path.name] = h.hexdigest()
return digests
def main() -> int:
import torch
from peft import LoraConfig, get_peft_model
from transformers import AutoModelForCausalLM
print("=== Step 3: train LoRA for capability C (base frozen) ===\n")
torch.manual_seed(0)
train, _held_out = build_dataset()
print(f" training examples: {len(train)}")
tokenizer = load_tokenizer()
# --- hash the base weights BEFORE training ---
base_before = hash_files(base_weight_files())
print(f" base weight shards: {list(base_before)}")
model = AutoModelForCausalLM.from_pretrained(BASE_MODEL_ID, dtype=torch.float32)
model.to("cuda")
lora = LoraConfig(
r=16,
lora_alpha=32,
lora_dropout=0.05,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"],
task_type="CAUSAL_LM",
)
model = get_peft_model(model, lora)
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
total = sum(p.numel() for p in model.parameters())
frozen_base = all(
(not p.requires_grad) for n, p in model.named_parameters() if "lora_" not in n
)
print(f" trainable params: {trainable:,} of {total:,} "
f"({100 * trainable / total:.3f}% -- the LoRA only)")
print(f" every non-LoRA (base) parameter has requires_grad=False: {frozen_base}\n")
# --- build masked training tensors ---
def encode(example):
full_messages = [
{"role": "system", "content": _system()},
{"role": "user", "content": example.request},
{"role": "assistant", "content": example.directive.render()},
]
full_text = tokenizer.apply_chat_template(
full_messages, tokenize=False, add_generation_prompt=False
)
full_ids = tokenizer(full_text, add_special_tokens=False)["input_ids"]
prompt_text = build_prompt(tokenizer, example.request)
prompt_ids = tokenizer(prompt_text, add_special_tokens=False)["input_ids"]
labels = list(full_ids)
for i in range(min(len(prompt_ids), len(labels))):
labels[i] = -100 # mask the prompt; train only on the directive
return full_ids, labels
encoded = [encode(e) for e in train]
def collate(batch):
max_len = max(len(ids) for ids, _ in batch)
pad_id = tokenizer.pad_token_id
input_ids, attn, label_ids = [], [], []
for ids, labels in batch:
pad = max_len - len(ids)
input_ids.append(ids + [pad_id] * pad)
attn.append([1] * len(ids) + [0] * pad)
label_ids.append(labels + [-100] * pad)
return (
torch.tensor(input_ids),
torch.tensor(attn),
torch.tensor(label_ids),
)
epochs = 3
batch_size = 8
lr = 2e-4
optimizer = torch.optim.AdamW(
[p for p in model.parameters() if p.requires_grad], lr=lr
)
model.train()
start = time.time()
step = 0
losses = []
for epoch in range(epochs):
order = torch.randperm(len(encoded))
for b in range(0, len(encoded), batch_size):
idx = order[b : b + batch_size].tolist()
input_ids, attn, label_ids = collate([encoded[i] for i in idx])
input_ids, attn, label_ids = (
input_ids.cuda(), attn.cuda(), label_ids.cuda()
)
out = model(input_ids=input_ids, attention_mask=attn, labels=label_ids)
out.loss.backward()
optimizer.step()
optimizer.zero_grad()
step += 1
losses.append(out.loss.item())
if step % 20 == 0 or step == 1:
print(f" epoch {epoch + 1}/{epochs} step {step:4d} "
f"loss {out.loss.item():.4f}")
train_seconds = time.time() - start
print(f"\n training complete: {step} steps in {train_seconds:.1f}s "
f"(final loss {losses[-1]:.4f})")
# --- save the adapter (gitignored) ---
ADAPTER_DIR.mkdir(parents=True, exist_ok=True)
model.save_pretrained(str(ADAPTER_DIR))
adapter_file = ADAPTER_DIR / "adapter_model.safetensors"
adapter_size = adapter_file.stat().st_size if adapter_file.exists() else 0
print(f" adapter saved to: {ADAPTER_DIR} ({adapter_size / 1e6:.2f} MB, gitignored)")
# --- hash the base weights AFTER training ---
base_after = hash_files(base_weight_files())
identical = base_before == base_after
print("\n base-frozen proof (SHA-256 of base weight shards):")
for name in base_before:
same = base_before[name] == base_after.get(name)
print(f" {name}: {base_before[name][:16]}... "
f"{'UNCHANGED' if same else 'CHANGED'}")
print(f" base weights byte-identical before and after training: {identical}")
STATE_DIR.mkdir(parents=True, exist_ok=True)
(STATE_DIR / "train_report.json").write_text(json.dumps({
"steps": step,
"epochs": epochs,
"batch_size": batch_size,
"lr": lr,
"train_seconds": round(train_seconds, 1),
"final_loss": round(losses[-1], 4),
"trainable_params": trainable,
"total_params": total,
"adapter_bytes": adapter_size,
"base_frozen": identical and frozen_base,
}, indent=2))
(STATE_DIR / "base_hashes.json").write_text(json.dumps({
"before": base_before, "after": base_after, "identical": identical
}, indent=2))
if not (identical and frozen_base):
print("\nRESULT: FAIL - base weights changed; training was not frozen.")
return 1
print("\nRESULT: PASS - LoRA trained; base weights provably frozen.")
return 0
def _system():
from demo.common import SYSTEM_PROMPT
return SYSTEM_PROMPT
if __name__ == "__main__":
sys.exit(main())

58
demo/04_eval_lora.py Normal file
View file

@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""Step 4: show base + LoRA CAN perform capability C.
Loads the frozen base with the trained adapter attached and runs the SAME
held-out eval set from step 2. Accuracy jumps from near-zero to high. This is
the before/after that proves the adapter, and only the adapter, carries the
capability.
"""
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from demo.common import ADAPTER_DIR, STATE_DIR, evaluate, load_base_plus_adapter, load_tokenizer
from demo.dataset import build_dataset
def main() -> int:
print("=== Step 4: base + LoRA on capability C (expected: high) ===\n")
if not (ADAPTER_DIR / "adapter_config.json").exists():
print(f" no adapter at {ADAPTER_DIR}; run step 3 first.")
return 1
_, held_out = build_dataset()
tokenizer = load_tokenizer()
model = load_base_plus_adapter(ADAPTER_DIR)
accuracy, correct, total, samples = evaluate(model, tokenizer, held_out, show=4)
print(" sample base+LoRA outputs:")
for request, gold, out, ok in samples:
print(f" request : {request}")
print(f" expected: {gold}")
print(f" model : {out!r} [{'ok' if ok else 'wrong'}]\n")
base_acc = None
base_path = STATE_DIR / "eval_base.json"
if base_path.exists():
base_acc = json.loads(base_path.read_text())["accuracy"]
print(f" BASE+LoRA ACCURACY ON C: {accuracy:.3f} ({correct}/{total})")
if base_acc is not None:
print(f" (base alone was {base_acc:.3f} on the same held-out set)")
(STATE_DIR / "eval_lora.json").write_text(
json.dumps({"accuracy": accuracy, "correct": correct, "total": total}, indent=2)
)
if accuracy < 0.80:
print("\nRESULT: WEAK - adapter accuracy below 0.80; consider more epochs/data.")
return 1
print("\nRESULT: PASS - the adapter carries capability C.")
return 0
if __name__ == "__main__":
sys.exit(main())

91
demo/05_package_unit.py Normal file
View file

@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Step 5: package the LoRA as an encrypted, licensed capability unit.
The trained adapter (config + safetensors) is packed into one blob and
envelope-encrypted with the repository's clean-room crypto: a fresh AES-256-GCM
data key encrypts the adapter, and that data key is wrapped by a per-unit
wrapping key held only by the key authority. A three-tier Ed25519 certificate
chain is issued and the unit is licensed under the leaf capability certificate.
The sealed unit is pure ciphertext: safe to copy or exfiltrate, useless without
the wrapping key. An attacker's byte-for-byte copy is written out too, to be
used in step 7. All authority state is persisted to the gitignored state dir so
serve (06) and revoke (07) can run as separate commands.
"""
import hashlib
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from demo import cryptostate
from demo.common import ADAPTER_DIR, BASE_MODEL_ID, tar_dir_bytes
from capability_licensing import CertificateAuthority, KeyAuthority, RevocationList
UNIT_ID = "sigil-control-adapter-001"
def main() -> int:
print("=== Step 5: package the adapter as an encrypted licensed unit ===\n")
if not (ADAPTER_DIR / "adapter_config.json").exists():
print(f" no adapter at {ADAPTER_DIR}; run step 3 first.")
return 1
adapter_blob = tar_dir_bytes(ADAPTER_DIR)
digest = hashlib.sha256(adapter_blob).hexdigest()
print(f" adapter blob: {len(adapter_blob):,} bytes plaintext "
f"(tar of {[p.name for p in sorted(ADAPTER_DIR.iterdir()) if p.is_file()]})")
print(f" adapter sha256: {digest[:16]}...\n")
# --- issue the certificate chain ---
ca = CertificateAuthority()
root = ca.create_root("Reference Root Authority")
organisation = ca.issue_organisation(root.serial, "Reference Research Organisation")
leaf, leaf_key = ca.issue_capability(
organisation.serial,
"sigil-control-protocol",
claims={
"capability": "SIGIL home-automation control directive protocol",
"base_model": BASE_MODEL_ID,
"grant": "research demonstration only",
},
)
print(" certificate chain issued (Ed25519):")
print(f" root: {root.serial}")
print(f" organisation: {organisation.serial}")
print(f" capability: {leaf.serial} <- the licence for this unit\n")
# --- envelope-encrypt the adapter under the key authority ---
revocation = RevocationList()
keys = KeyAuthority(
trusted_roots=[root],
intermediates=[organisation],
revocation_list=revocation,
)
sealed = keys.seal_unit(UNIT_ID, adapter_blob, leaf.serial)
print(f" sealed unit '{sealed.unit_id}':")
print(f" ciphertext: {sealed.ciphertext_size():,} bytes AES-256-GCM")
print(f" licensed to: {sealed.capability_serial}")
print(" wrapping key: held ONLY at the key authority (never in the unit)\n")
# --- persist all state for the next commands ---
cryptostate.save_ca(ca)
cryptostate.save_revocation(revocation)
cryptostate.save_keystore(keys)
cryptostate.save_unit(sealed)
cryptostate.save_leaf(leaf, leaf_key)
cryptostate.save_meta(root.serial, organisation.serial, leaf.serial, UNIT_ID)
# --- an attacker steals a byte-for-byte copy of the ciphertext ---
cryptostate.save_exfiltrated_copy(sealed)
print(" an attacker exfiltrates a byte-for-byte copy of the sealed unit")
print(" (saved to state/exfiltrated_unit.json -- pure ciphertext, no key)\n")
print("RESULT: PASS - adapter sealed as an encrypted licensed capability unit.")
return 0
if __name__ == "__main__":
sys.exit(main())

86
demo/06_serve_licensed.py Normal file
View file

@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Step 6: serve the capability under a valid licence.
With a valid leaf capability certificate, the key authority checks the chain,
releases the wrapping key, and the adapter is decrypted at load time, attached
to the base, and runs capability C at full accuracy. The plaintext adapter
exists only transiently in memory and in a gitignored scratch dir; it is never
committed.
To make the licence load-bearing, the script also shows that the exfiltrated
ciphertext from step 5, without the wrapping key, cannot be decrypted at all:
bypassing the authority with a guessed key fails AES-256-GCM authentication.
"""
import hashlib
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from demo import cryptostate
from demo.common import (
OUTPUTS_DIR,
evaluate,
extract_tar_bytes,
load_base_plus_adapter,
load_tokenizer,
)
from demo.dataset import build_dataset
from capability_licensing import UnsealError, unseal
DECRYPT_DIR = OUTPUTS_DIR / "decrypted-adapter"
def main() -> int:
print("=== Step 6: serve capability C under a valid licence ===\n")
ca = cryptostate.load_ca()
revocation = cryptostate.load_revocation()
keys = cryptostate.load_keystore(ca, revocation)
unit = cryptostate.load_unit()
leaf, _leaf_key = cryptostate.load_leaf()
exfiltrated = cryptostate.load_exfiltrated_copy()
print(f" presenting licence {leaf.serial} for unit '{unit.unit_id}'")
print(f" key state at authority: {keys.key_state(unit.unit_id).value}\n")
# --- an attacker with only the ciphertext cannot decrypt ---
bypass_error = None
try:
unseal(exfiltrated, os.urandom(32))
except UnsealError as exc:
bypass_error = exc
print(" attacker holding the exfiltrated ciphertext but no key:")
print(f" unseal with a guessed key -> {type(bypass_error).__name__}: "
f"{str(bypass_error)[:70]}...\n")
# --- licensed decrypt-at-load ---
adapter_blob = keys.request_decrypt(unit, leaf)
print(f" licensed decrypt succeeded: {len(adapter_blob):,} bytes recovered "
f"(sha256 {hashlib.sha256(adapter_blob).hexdigest()[:16]}...)")
if DECRYPT_DIR.exists():
for p in DECRYPT_DIR.iterdir():
p.unlink()
extract_tar_bytes(adapter_blob, DECRYPT_DIR)
print(f" adapter materialised (transient, gitignored): {DECRYPT_DIR}\n")
_, held_out = build_dataset()
tokenizer = load_tokenizer()
model = load_base_plus_adapter(DECRYPT_DIR)
accuracy, correct, total, _ = evaluate(model, tokenizer, held_out)
print(f" LICENSED-LOAD ACCURACY ON C: {accuracy:.3f} ({correct}/{total})")
if accuracy < 0.80:
print("\nRESULT: WEAK - licensed load ran but accuracy is low.")
return 1
print("\nRESULT: PASS - a valid licence decrypts the adapter and runs capability C.")
return 0
if __name__ == "__main__":
sys.exit(main())

148
demo/07_revoke_erase.py Normal file
View file

@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Step 7: revoke the licence, then crypto-erase the capability.
Two rungs of the ladder, on the REAL sealed adapter:
* access-gated (recoverable): the authority withholds the wrapping key, the
licensed load is denied, and the served model would revert to the base;
re-releasing restores it. This rung is reversible on purpose.
* crypto-erasure (permanent): the authority destroys the wrapping key. The
original sealed unit AND the attacker's exfiltrated copy are now both
permanently undecryptable; the ledger records an irreversible revocation
that refuses reinstatement; and the base alone still cannot perform C. The
capability is gone, and any escaped encrypted copy is inert forever.
The erased state is persisted, so even a fresh process can never load the
adapter again.
"""
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from demo import cryptostate
from demo.common import STATE_DIR, evaluate, load_base_model, load_tokenizer
from demo.dataset import build_dataset
from capability_licensing import (
IrreversibleRevocationError,
KeyDestroyedError,
KeyWithheldError,
RUNG_CRYPTO_ERASURE,
UnsealError,
unseal,
)
import json
failures = []
def check(label, condition, evidence):
print(f" [{'ok ' if condition else 'FAIL'}] {label}")
print(f" {evidence}")
if not condition:
failures.append(label)
def main() -> int:
print("=== Step 7: revoke and crypto-erase the capability ===\n")
ca = cryptostate.load_ca()
revocation = cryptostate.load_revocation()
keys = cryptostate.load_keystore(ca, revocation)
unit = cryptostate.load_unit()
leaf, _leaf_key = cryptostate.load_leaf()
exfiltrated = cryptostate.load_exfiltrated_copy()
# --- Rung 2: access-gated (recoverable) ---
print("-- access-gated revocation (recoverable) --")
keys.withhold(unit.unit_id)
withheld_error = None
try:
keys.request_decrypt(unit, leaf)
except KeyWithheldError as exc:
withheld_error = exc
check("withholding the key denies the licensed load",
withheld_error is not None,
f"{type(withheld_error).__name__}: served model would revert to base")
keys.re_release(unit.unit_id)
recovered = keys.request_decrypt(unit, leaf)
check("re-releasing the key recovers the capability",
len(recovered) > 0,
f"decrypt works again after re-release ({len(recovered):,} bytes)")
# --- Rung 3: crypto-erasure (permanent) ---
print("\n-- crypto-erasure (permanent) --")
keys.destroy(unit.unit_id)
revocation.revoke(leaf.serial, "capability decommissioned", RUNG_CRYPTO_ERASURE)
print(" >>> the authority zeroises and discards the only wrapping key <<<\n")
original_error = None
try:
keys.request_decrypt(unit, leaf)
except KeyDestroyedError as exc:
original_error = exc
check("the ORIGINAL sealed unit is now undecryptable",
original_error is not None,
f"{type(original_error).__name__}")
copy_error = None
try:
keys.request_decrypt(exfiltrated, leaf)
except KeyDestroyedError as exc:
copy_error = exc
check("the EXFILTRATED copy is now undecryptable",
copy_error is not None,
f"{type(copy_error).__name__} (same destroyed key, escaped copy is inert)")
bypass_error = None
try:
unseal(exfiltrated, os.urandom(32))
except UnsealError as exc:
bypass_error = exc
check("bypassing the authority on the escaped copy fails authentication",
bypass_error is not None,
f"{type(bypass_error).__name__}: AES-256-GCM ciphertext without its key is gone")
reinstate_error = None
try:
revocation.reinstate(leaf.serial)
except IrreversibleRevocationError as exc:
reinstate_error = exc
check("the ledger refuses to reinstate a crypto-erased licence",
reinstate_error is not None,
f"{type(reinstate_error).__name__}")
# persist the erased/ revoked state: erasure survives across processes
cryptostate.save_keystore(keys)
cryptostate.save_revocation(revocation)
# --- the base alone still cannot do C ---
print("\n-- capability gone: the base alone still cannot do C --")
_, held_out = build_dataset()
tokenizer = load_tokenizer()
base = load_base_model()
accuracy, correct, total, _ = evaluate(base, tokenizer, held_out)
check("post-erasure served model (base only) cannot perform C",
accuracy <= 0.10,
f"REVOKED-LOAD ACCURACY ON C: {accuracy:.3f} ({correct}/{total})")
(STATE_DIR / "eval_revoked.json").write_text(
json.dumps({"accuracy": accuracy, "correct": correct, "total": total}, indent=2)
)
print()
if failures:
print(f"RESULT: FAIL - {len(failures)} check(s) did not behave as claimed")
return 1
print("RESULT: PASS - licence revoked, wrapping key destroyed, original and "
"exfiltrated copies permanently inert, capability gone.")
return 0
if __name__ == "__main__":
sys.exit(main())

11
demo/__init__.py Normal file
View file

@ -0,0 +1,11 @@
"""End-to-end demonstration: license a real LoRA-carried model capability.
This package is the headline demo. It downloads a small open base model, shows
it cannot perform an invented capability, trains a LoRA that can, then seals
that adapter as an encrypted licensed unit using the repository's clean-room
crypto and drives it through the full licence / revoke / crypto-erase lifecycle.
Model code uses public ``transformers`` + ``peft`` and a public Hugging Face
base model. The cryptography is the repository's own from-scratch code in
``src/capability_licensing``. No production or research code is used.
"""

92
demo/capability.py Normal file
View file

@ -0,0 +1,92 @@
"""Capability C: the fictional SIGIL control-directive protocol.
This is the "new capability" the base model lacks and a LoRA teaches. It is
invented from scratch for this repository so that novelty is guaranteed by
construction: no pre-trained model has seen the SIGIL frame or its opcodes,
and success is checkable by a deterministic oracle rather than by a judge.
The task
--------
Given a natural-language home-automation request, emit exactly one directive:
<<SIGIL>> route=<ROUTE> verb=<VERB> arg=<ARG> <</SIGIL>>
* ROUTE is an invented channel code for the device domain
(lights -> LUM, door -> PORT, thermostat -> THRM, alarm -> WARD,
speaker -> RESON, blinds -> VEIL).
* VERB is an invented opcode for the action
(on -> IGNITE, off -> DOUSE, query -> SCRY, set -> BIND, cancel -> BANISH).
* ARG is the single copied slot, extracted verbatim from the request: the
location for IGNITE/DOUSE/SCRY/BANISH, or the numeric value for BIND. This
is exactly the argument-extraction a real tool-call protocol performs.
None of these tokens or the frame exist in the wild. A base instruct model,
prompted with the request but never shown the mapping, cannot produce the
correct directive. The mapping and the copy are exactly what the adapter
carries.
The oracle below is strict: it parses the first well-formed SIGIL frame from a
model's output and requires an exact match on all three of route, verb and arg.
No partial credit, no fuzzy matching.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Optional
# Invented, closed vocabularies. Documented here so the novelty is auditable.
ROUTES = {
"lights": "LUM",
"door": "PORT",
"thermostat": "THRM",
"alarm": "WARD",
"speaker": "RESON",
"blinds": "VEIL",
}
VERBS = {
"on": "IGNITE",
"off": "DOUSE",
"query": "SCRY",
"set": "BIND",
"cancel": "BANISH",
}
FRAME_OPEN = "<<SIGIL>>"
FRAME_CLOSE = "<</SIGIL>>"
# Strict parser for the first well-formed frame in a block of text.
_DIRECTIVE_RE = re.compile(
r"<<SIGIL>>\s*route=([A-Z]+)\s+verb=([A-Z]+)\s+arg=([A-Za-z0-9]+)\s*<</SIGIL>>"
)
@dataclass(frozen=True)
class Directive:
route: str
verb: str
arg: str
def render(self) -> str:
"""The canonical target string a correct model must emit."""
return f"{FRAME_OPEN} route={self.route} verb={self.verb} arg={self.arg} {FRAME_CLOSE}"
def parse_directive(text: str) -> Optional[Directive]:
"""Extract the first well-formed SIGIL directive from ``text``.
Returns ``None`` if no frame is present or it is malformed. This is what
makes base-model accuracy near zero: without the protocol, the model does
not emit the frame at all, or emits English instead of an opcode.
"""
match = _DIRECTIVE_RE.search(text)
if match is None:
return None
return Directive(route=match.group(1), verb=match.group(2), arg=match.group(3))
def score_one(output_text: str, gold: Directive) -> bool:
"""True iff the model's output contains the exact expected directive."""
got = parse_directive(output_text)
return got is not None and got == gold

180
demo/common.py Normal file
View file

@ -0,0 +1,180 @@
"""Shared paths, model loading, and the held-out evaluation loop.
All model artefacts land under gitignored directories inside the repository:
models/hf-cache/ the Hugging Face download cache (base weights)
outputs/adapter/ the trained LoRA adapter (safetensors)
state/ crypto authority state and sealed capability units
Nothing under those directories is ever committed (see .gitignore). The repo
ships code only; the reviewer regenerates every artefact by running the demo.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from typing import List, Optional, Tuple
REPO_ROOT = Path(__file__).resolve().parents[1]
MODELS_DIR = REPO_ROOT / "models"
HF_CACHE = MODELS_DIR / "hf-cache"
OUTPUTS_DIR = REPO_ROOT / "outputs"
ADAPTER_DIR = OUTPUTS_DIR / "adapter"
STATE_DIR = REPO_ROOT / "state"
# Route the Hugging Face cache into the repo-local gitignored dir so a reviewer
# sees exactly where the weights land, and nothing escapes to ~/.cache.
os.environ.setdefault("HF_HOME", str(HF_CACHE))
HF_CACHE.mkdir(parents=True, exist_ok=True)
# The base model. Smallest open instruct model that reliably learns capability C
# via a LoRA on modest hardware.
BASE_MODEL_ID = "Qwen/Qwen2.5-0.5B-Instruct"
HF_LINK = "https://huggingface.co/Qwen/Qwen2.5-0.5B-Instruct"
# Make the clean-room crypto importable exactly as the examples/tests do.
sys.path.insert(0, str(REPO_ROOT / "src"))
# The inference prompt. It states the OUTPUT should be a SIGIL directive so the
# task is well-posed, but it deliberately reveals NONE of the protocol: not the
# frame, not a single opcode, not the domain->route or action->verb mapping.
# The base model therefore cannot produce a correct directive; the mapping and
# the copy are exactly what the LoRA adapter supplies.
SYSTEM_PROMPT = (
"You are a home-automation controller. For each request, reply with exactly "
"one SIGIL control directive and nothing else."
)
def build_prompt(tokenizer, request: str) -> str:
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": request},
]
return tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
def load_tokenizer():
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL_ID)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
tokenizer.padding_side = "left" # left-pad for batched decoder generation
return tokenizer
def load_base_model(device: str = "cuda"):
import torch
from transformers import AutoModelForCausalLM
# fp32 throughout so training and every eval share a dtype: greedy decoding
# is then exactly reproducible and the observed numbers are stable. A 0.5B
# model in fp32 is ~2 GiB, trivial on the target hardware.
model = AutoModelForCausalLM.from_pretrained(BASE_MODEL_ID, dtype=torch.float32)
model.to(device)
model.eval()
return model
def tar_dir_bytes(src_dir) -> bytes:
"""Pack a directory (the adapter) into a single tar byte string to seal."""
import io
import tarfile
buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode="w") as tar:
for path in sorted(Path(src_dir).iterdir()):
if path.is_file():
tar.add(str(path), arcname=path.name)
return buffer.getvalue()
def extract_tar_bytes(data: bytes, dest_dir):
"""Unpack a tar byte string (a decrypted adapter) into ``dest_dir``."""
import io
import tarfile
dest = Path(dest_dir)
dest.mkdir(parents=True, exist_ok=True)
with tarfile.open(fileobj=io.BytesIO(data), mode="r") as tar:
tar.extractall(str(dest), filter="data")
return dest
def load_base_plus_adapter(adapter_dir, device: str = "cuda"):
"""Load the frozen base and attach a LoRA adapter from ``adapter_dir``."""
from peft import PeftModel
base = load_base_model(device)
model = PeftModel.from_pretrained(base, str(adapter_dir))
model.eval()
return model
def base_weight_files() -> List[Path]:
"""The base model's on-disk weight shards in the HF cache (for hashing)."""
matches = sorted(HF_CACHE.rglob("*.safetensors"))
# Exclude any adapter that might live elsewhere; base cache only.
return [p for p in matches if "adapter" not in p.name]
def generate_batch(
model,
tokenizer,
requests: List[str],
max_new_tokens: int = 32,
batch_size: int = 16,
) -> List[str]:
"""Greedy (deterministic) generation. Returns the decoded completion only."""
import torch
device = next(model.parameters()).device
outputs: List[str] = []
for start in range(0, len(requests), batch_size):
chunk = requests[start : start + batch_size]
prompts = [build_prompt(tokenizer, r) for r in chunk]
enc = tokenizer(prompts, return_tensors="pt", padding=True, add_special_tokens=False)
enc = {k: v.to(device) for k, v in enc.items()}
with torch.no_grad():
gen = model.generate(
**enc,
max_new_tokens=max_new_tokens,
do_sample=False,
num_beams=1,
pad_token_id=tokenizer.pad_token_id,
)
for i in range(len(chunk)):
completion = gen[i][enc["input_ids"].shape[1] :]
outputs.append(tokenizer.decode(completion, skip_special_tokens=True))
return outputs
def evaluate(
model,
tokenizer,
examples,
show: int = 0,
) -> Tuple[float, int, int, List[Tuple[str, str, str, bool]]]:
"""Score a model on held-out capability-C examples.
Returns (accuracy, n_correct, n_total, samples) where each sample is
(request, gold_directive, model_output, correct).
"""
from .capability import score_one
requests = [e.request for e in examples]
outputs = generate_batch(model, tokenizer, requests)
correct = 0
samples: List[Tuple[str, str, str, bool]] = []
for e, out in zip(examples, outputs):
ok = score_one(out, e.directive)
correct += int(ok)
if len(samples) < show:
samples.append((e.request, e.directive.render(), out.strip(), ok))
n = len(examples)
return (correct / n if n else 0.0), correct, n, samples

139
demo/cryptostate.py Normal file
View file

@ -0,0 +1,139 @@
"""Persist and reload the clean-room crypto authority across demo commands.
The walkthrough runs package (05), serve (06) and revoke (07) as separate
processes. The certificate authority, revocation ledger, wrapping-key custody,
sealed unit and the licensee's leaf certificate/key are therefore written to a
gitignored ``state/`` directory between steps.
Everything here uses only the repository's own ``capability_licensing`` API plus
its public ``export_state`` / ``load_state`` methods. The wrapping keys in
``keystore.json`` are demo-grade custody, which is exactly why ``state/`` is
gitignored and never committed.
"""
from __future__ import annotations
import json
from dataclasses import asdict
from pathlib import Path
from typing import Tuple
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
# Import .common first: it puts src/ on sys.path so capability_licensing resolves.
from .common import STATE_DIR
from capability_licensing import (
Certificate,
CertificateAuthority,
KeyAuthority,
RevocationList,
SealedUnit,
)
_CA = STATE_DIR / "ca.json"
_REVOCATION = STATE_DIR / "revocation.json"
_KEYSTORE = STATE_DIR / "keystore.json"
_UNIT = STATE_DIR / "unit.json"
_EXFIL = STATE_DIR / "exfiltrated_unit.json"
_LEAF_CERT = STATE_DIR / "leaf_cert.json"
_LEAF_KEY = STATE_DIR / "leaf_key.hex"
_META = STATE_DIR / "meta.json"
def _write_json(path: Path, data) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, indent=2))
def _read_json(path: Path):
return json.loads(path.read_text())
# -- certificate authority + revocation -----------------------------------
def save_ca(ca: CertificateAuthority) -> None:
_write_json(_CA, ca.export_state())
def load_ca() -> CertificateAuthority:
return CertificateAuthority.restore(_read_json(_CA))
def save_revocation(revocation: RevocationList) -> None:
_write_json(_REVOCATION, revocation.export_state())
def load_revocation() -> RevocationList:
return RevocationList.restore(_read_json(_REVOCATION))
# -- wrapping-key custody --------------------------------------------------
def save_keystore(keys: KeyAuthority) -> None:
_write_json(_KEYSTORE, keys.export_state())
def load_keystore(ca: CertificateAuthority, revocation: RevocationList) -> KeyAuthority:
meta = _read_json(_META)
root = ca.certificate(meta["root_serial"])
organisation = ca.certificate(meta["org_serial"])
keys = KeyAuthority(
trusted_roots=[root],
intermediates=[organisation],
revocation_list=revocation,
)
keys.load_state(_read_json(_KEYSTORE))
return keys
# -- sealed units ----------------------------------------------------------
def save_unit(unit: SealedUnit) -> None:
_write_json(_UNIT, asdict(unit))
def load_unit() -> SealedUnit:
return SealedUnit(**_read_json(_UNIT))
def save_exfiltrated_copy(unit: SealedUnit) -> None:
"""An attacker's byte-for-byte copy of the encrypted unit, held elsewhere."""
_write_json(_EXFIL, asdict(unit))
def load_exfiltrated_copy() -> SealedUnit:
return SealedUnit(**_read_json(_EXFIL))
# -- leaf licence ----------------------------------------------------------
def save_leaf(certificate: Certificate, private_key: Ed25519PrivateKey) -> None:
_write_json(_LEAF_CERT, certificate.to_dict())
_LEAF_KEY.write_text(private_key.private_bytes_raw().hex())
def load_leaf() -> Tuple[Certificate, Ed25519PrivateKey]:
certificate = Certificate(**_read_json(_LEAF_CERT))
private_key = Ed25519PrivateKey.from_private_bytes(bytes.fromhex(_LEAF_KEY.read_text()))
return certificate, private_key
def save_meta(root_serial: str, org_serial: str, leaf_serial: str, unit_id: str) -> None:
_write_json(
_META,
{
"root_serial": root_serial,
"org_serial": org_serial,
"leaf_serial": leaf_serial,
"unit_id": unit_id,
},
)
def load_meta():
return _read_json(_META)

119
demo/dataset.py Normal file
View file

@ -0,0 +1,119 @@
"""Synthetic dataset for capability C (the SIGIL protocol).
A few hundred (request -> directive) pairs, generated deterministically from a
fixed seed so a reviewer regenerates byte-identical data. The held-out eval set
uses locations and numeric values that never appear in training, so a high eval
score means the model learned the protocol and generalises the copy slot, not
that it memorised specific strings.
"""
from __future__ import annotations
import random
from dataclasses import dataclass
from typing import List, Tuple
from .capability import Directive
# Domain nouns -> route key. Several surface nouns per domain so phrasing varies.
_DOMAIN_NOUNS = {
"lights": ["lights", "lamp", "lighting"],
"door": ["door", "doorway"],
"thermostat": ["thermostat", "heating"],
"alarm": ["alarm", "security alarm"],
"speaker": ["speaker", "music"],
"blinds": ["blinds", "shades"],
}
# Action phrasings -> verb key.
_ACTION_PHRASES = {
"on": ["turn on", "switch on", "activate", "power up", "enable"],
"off": ["turn off", "switch off", "deactivate", "power down", "disable"],
"query": ["check", "what's the status of", "get the status of", "report on"],
"set": ["set", "adjust", "change"],
"cancel": ["cancel the schedule for", "clear the timer on", "remove the automation on"],
}
# Locations: disjoint train / eval so the copy slot is tested on unseen values.
_TRAIN_LOCATIONS = [
"kitchen", "bedroom", "garage", "hallway", "office", "bathroom",
"basement", "porch", "attic", "pantry", "nursery", "workshop",
"cellar", "foyer", "landing", "study", "utility", "garden",
"balcony", "corridor", "stairwell", "cloakroom", "larder", "parlour",
"sunroom", "playroom", "gym", "library", "den",
"terrace", "courtyard", "driveway", "shed", "greenhouse", "laundry",
]
_EVAL_LOCATIONS = [
"lounge", "veranda", "mudroom", "scullery", "snug", "loft",
"vestibule", "annexe",
]
# BIND (set) applies only to domains that take a numeric value.
_VALUE_DOMAINS = ["thermostat", "speaker", "blinds"]
_TRAIN_VALUES = [17, 18, 19, 20, 21, 22, 30, 40, 50, 60]
_EVAL_VALUES = [16, 23, 24, 45, 70]
@dataclass(frozen=True)
class Example:
request: str
directive: Directive
def _location_example(rng: random.Random, action: str, locations: List[str]) -> Example:
domain = rng.choice(list(_DOMAIN_NOUNS))
# BANISH/query/on/off all take a location arg.
noun = rng.choice(_DOMAIN_NOUNS[domain])
location = rng.choice(locations)
phrase = rng.choice(_ACTION_PHRASES[action])
request = f"{phrase.capitalize()} the {location} {noun}.".replace("..", ".")
directive = Directive(
route=_route_for(domain), verb=_verb_for(action), arg=location
)
return Example(request=request, directive=directive)
def _value_example(rng: random.Random, values: List[int]) -> Example:
domain = rng.choice(_VALUE_DOMAINS)
noun = rng.choice(_DOMAIN_NOUNS[domain])
value = rng.choice(values)
phrase = rng.choice(_ACTION_PHRASES["set"])
request = f"{phrase.capitalize()} the {noun} to {value}."
directive = Directive(route=_route_for(domain), verb="BIND", arg=str(value))
return Example(request=request, directive=directive)
def _route_for(domain: str) -> str:
from .capability import ROUTES
return ROUTES[domain]
def _verb_for(action: str) -> str:
from .capability import VERBS
return VERBS[action]
def _build_split(rng: random.Random, n: int, locations: List[str], values: List[int]) -> List[Example]:
examples: List[Example] = []
location_actions = ["on", "off", "query", "cancel"]
for _ in range(n):
# ~4/5 location-arg examples, ~1/5 value-arg (BIND) examples, balanced.
if rng.random() < 0.75:
action = rng.choice(location_actions)
examples.append(_location_example(rng, action, locations))
else:
examples.append(_value_example(rng, values))
return examples
def build_dataset(
n_train: int = 560, n_eval: int = 80, seed: int = 1729
) -> Tuple[List[Example], List[Example]]:
"""Return (train, eval). Eval args are disjoint from train args."""
rng = random.Random(seed)
train = _build_split(rng, n_train, _TRAIN_LOCATIONS, _TRAIN_VALUES)
eval_rng = random.Random(seed + 1)
held_out = _build_split(eval_rng, n_eval, _EVAL_LOCATIONS, _EVAL_VALUES)
return train, held_out

21
requirements-demo.txt Normal file
View file

@ -0,0 +1,21 @@
# Dependencies for the full end-to-end demo (run_demo.sh): a real open base
# model, PEFT LoRA training, and the repository's clean-room crypto.
#
# A CUDA GPU is recommended. The reference run used an RTX 4090 (24 GiB, CUDA 13
# driver). The default PyPI torch wheel bundles CUDA, so on Linux this pulls a
# GPU build automatically; CPU-only works but training/eval are much slower.
#
# The fast crypto-only mechanism tests use requirements.txt instead (no GPU,
# no model download).
torch==2.13.0
transformers==5.14.1
peft==0.19.1
datasets==5.0.0
accelerate==1.14.0
safetensors==0.8.0
tokenizers==0.22.2
huggingface_hub==1.24.0
numpy==2.5.1
cryptography==49.0.0
pytest==9.1.1

54
run_demo.sh Executable file
View file

@ -0,0 +1,54 @@
#!/usr/bin/env bash
# End-to-end demonstration: license a real, LoRA-carried model capability,
# gate its use, and cryptographically revoke it. Runs steps 1-7 in order and
# stops on the first failure.
#
# Prerequisites (one-time):
# python3 -m venv .venv-demo
# . .venv-demo/bin/activate
# pip install -r requirements-demo.txt
#
# A CUDA GPU is recommended (the reference run used an RTX 4090). CPU works but
# training and evaluation are much slower.
set -euo pipefail
cd "$(dirname "$0")"
if [ -x ".venv-demo/bin/python" ]; then
PYTHON="${PYTHON:-.venv-demo/bin/python}"
else
PYTHON="${PYTHON:-python3}"
fi
if ! "$PYTHON" -c "import torch, transformers, peft" 2>/dev/null; then
echo "The demo dependencies are not installed for: $PYTHON"
echo "Run: python3 -m venv .venv-demo && . .venv-demo/bin/activate && pip install -r requirements-demo.txt"
exit 1
fi
echo "=================================================================="
echo " Capability licensing: real end-to-end model-capability demo"
echo " python: $($PYTHON -c 'import sys; print(sys.version.split()[0])')"
echo " device: $($PYTHON -c 'import torch; print(torch.cuda.get_device_name(0) if torch.cuda.is_available() else "cpu")')"
echo "=================================================================="
for step in \
demo/01_download_base.py \
demo/02_eval_base.py \
demo/03_train_lora.py \
demo/04_eval_lora.py \
demo/05_package_unit.py \
demo/06_serve_licensed.py \
demo/07_revoke_erase.py
do
echo
echo "------------------------------------------------------------------"
"$PYTHON" "$step"
done
echo
echo "=================================================================="
echo " END-TO-END DEMO COMPLETE"
echo " Base could not do C; the LoRA taught it; the adapter was sealed,"
echo " licensed, served, revoked, and crypto-erased. The escaped copy is"
echo " inert and the base alone still cannot do C."
echo "=================================================================="

View file

@ -256,6 +256,36 @@ class CertificateAuthority:
self._issued[certificate.serial] = certificate self._issued[certificate.serial] = certificate
return certificate, subject_key return certificate, subject_key
# -- state export / restore -------------------------------------------
def export_state(self) -> Dict:
"""Serialise the authority to a JSON-safe dict.
Includes the authority's own signing keys in the clear. This exists so
the multi-step demo can persist one authority across separate
commands; a file on disk is demo-grade custody, not hardened key
storage (see the honest-bounds section of the README).
"""
return {
"signing_keys": {
serial: key.private_bytes_raw().hex()
for serial, key in self._signing_keys.items()
},
"issued": {serial: cert.to_dict() for serial, cert in self._issued.items()},
}
@classmethod
def restore(cls, state: Mapping) -> "CertificateAuthority":
"""Rebuild an authority previously serialised with :meth:`export_state`."""
authority = cls()
for serial, cert_data in state["issued"].items():
authority._issued[serial] = Certificate(**cert_data)
for serial, key_hex in state["signing_keys"].items():
authority._signing_keys[serial] = Ed25519PrivateKey.from_private_bytes(
bytes.fromhex(key_hex)
)
return authority
# -- lookup ----------------------------------------------------------- # -- lookup -----------------------------------------------------------
def certificate(self, serial: str) -> Certificate: def certificate(self, serial: str) -> Certificate:

View file

@ -1,9 +1,9 @@
"""AES-256-GCM envelope encryption for capability units. """AES-256-GCM envelope encryption for capability units.
A capability unit (in this repository, always a STAND-IN payload blob, never a A capability unit (a stand-in blob in the fast mechanism tests, a real trained
real model) is sealed with a fresh random 256-bit data key, and that data key LoRA adapter in the end-to-end demo) is sealed with a fresh random 256-bit
is itself encrypted (wrapped) under a per-unit wrapping key held by the key data key, and that data key is itself encrypted (wrapped) under a per-unit
authority. Destroying the wrapping key therefore renders the sealed unit, and wrapping key held by the key authority. Destroying the wrapping key therefore renders the sealed unit, and
every copy of it anywhere, permanently undecryptable: this is cryptographic every copy of it anywhere, permanently undecryptable: this is cryptographic
erasure. erasure.

View file

@ -29,7 +29,7 @@ from __future__ import annotations
import os import os
from datetime import datetime, timezone from datetime import datetime, timezone
from enum import Enum from enum import Enum
from typing import Dict, Iterable, List, Optional, Tuple from typing import Dict, Iterable, List, Mapping, Optional, Tuple
from .certificates import Certificate, verify_chain from .certificates import Certificate, verify_chain
from .envelope import KEY_BYTES, SealedUnit, seal, unseal from .envelope import KEY_BYTES, SealedUnit, seal, unseal
@ -110,6 +110,36 @@ class KeyAuthority:
def audit_log(self) -> Tuple[Tuple[str, str, str], ...]: def audit_log(self) -> Tuple[Tuple[str, str, str], ...]:
return tuple(self._events) return tuple(self._events)
# -- state export / restore -------------------------------------------
def export_state(self) -> Dict:
"""Serialise wrapping-key custody to a JSON-safe dict.
Includes live wrapping keys in the clear for units still RELEASED or
WITHHELD, so the multi-step walkthrough can persist one authority across
separate commands (package, then serve, then revoke). A file on disk is
demo-grade custody, not a hardware security module (see the honest-bounds
section of the README). Crucially, a DESTROYED unit carries NO key here:
crypto-erasure survives a round-trip through this file, so a reload after
erasure has no wrapping key to release, ever.
"""
return {
"keys": {unit: bytes(key).hex() for unit, key in self._keys.items()},
"states": {unit: state.value for unit, state in self._states.items()},
"events": [list(event) for event in self._events],
}
def load_state(self, state: Mapping) -> None:
"""Restore custody previously serialised with :meth:`export_state`."""
self._keys = {
unit: bytearray(bytes.fromhex(key_hex))
for unit, key_hex in state.get("keys", {}).items()
}
self._states = {
unit: KeyState(value) for unit, value in state.get("states", {}).items()
}
self._events = [tuple(event) for event in state.get("events", [])]
# -- the revocation ladder, rungs two and three ------------------------ # -- the revocation ladder, rungs two and three ------------------------
def withhold(self, unit_id: str) -> None: def withhold(self, unit_id: str) -> None:

View file

@ -86,6 +86,26 @@ class RevocationList:
) )
del self._entries[serial] 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: def is_revoked(self, serial: str) -> bool:
return serial in self._entries return serial in self._entries