diff --git a/.gitignore b/.gitignore index 17cd2fc..5d2ed3e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,13 @@ .venv/ +.venv-demo/ __pycache__/ *.pyc .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/ diff --git a/README.md b/README.md index 490bbba..3de4026 100644 --- a/README.md +++ b/README.md @@ -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 -mechanisms behind the Meanwhile capability-licensing research line. Every primitive here -was written from scratch for this repository, on top of the standard Python -`cryptography` package, and shares no code with any production or research system. Every -checkable claim in this README is backed by a runnable example and a test you can execute -locally in minutes, on any machine, with no GPU and two pinned dependencies. +This repository licenses a real capability on a real open model, gates its use +behind a certificate, and then cryptographically revokes it so the capability +is gone and any escaped copy is inert. You download a small open instruct model, +watch it fail at an invented capability, train a LoRA adapter that teaches it, +seal that adapter as an encrypted licensed unit, serve it under a valid licence, +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 -interface to help make a consequential decision is a person the system has already -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. +Requires Python 3.10+ and, for a comfortable run, a CUDA GPU. The reference run +used an RTX 4090 (24 GiB). CPU works but training and evaluation are much slower. -Every professional in that list is licensed. The system in the middle of their decisions -is not. +```sh +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 -it. The model is a commodity. The accountability layer around it is the value, and it can -be built on hardware the customer already controls. None of the individual mechanisms is -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. +`run_demo.sh` runs steps 1 to 7 below in order and stops on the first failure. +On the first run it downloads the base model (about 1 GiB) into a gitignored +directory. Each step is also a standalone script you can run and read on its own: -This repository demonstrates the cryptographic core of that assembly, small enough to -audit in an afternoon. +```sh +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 -asserts it. +**1. Download a base model.** `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 | -|---|-------|---------|-------| -| 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` | -| 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` | +> 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 +> you try one, so the list of confirmed-working bases can grow. -Claim 4 is the strongest and cleanest in the set 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. +**2. Show the base cannot do capability C.** Capability C is the *SIGIL* control +protocol, an invented tool-call format defined in `demo/capability.py`. Given a +home-automation request, the correct output is exactly: + +``` +<> route= verb= arg= <> +``` + +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 -`pip install`. +The cryptography that steps 5 to 7 use is also covered by a fast, GPU-free unit +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 python3 -m venv .venv @@ -69,197 +157,149 @@ pip install -r requirements.txt ./run_all.sh ``` -`run_all.sh` runs all four examples (each prints its evidence and a final -`RESULT: PASS`) and then the full test suite. Everything can also be run individually: +`run_all.sh` runs four examples (each prints its evidence and `RESULT: PASS`) +and the pytest suite: -```sh -python examples/issue_certificate.py # claim 1 -python examples/verify_output_provenance.py # claim 2 -python examples/revoke_three_rungs.py # claim 3 -python examples/crypto_erasure_undecryptable.py # claim 4 -python -m pytest tests/ -v # all claims, asserted -``` +| Primitive | Example | Tests | +|-----------|---------|-------| +| Ed25519 three-tier certificate chain, offline verification, tamper/expiry/role checks | `examples/issue_certificate.py` | `tests/test_certificates.py` | +| Signed output-to-composition provenance, invalidated live by revocation or erasure | `examples/verify_output_provenance.py` | `tests/test_provenance.py` | +| Three-rung revocation ladder (soft, access-gated, crypto-erasure) with recovery paths | `examples/revoke_three_rungs.py` | `tests/test_revocation.py` | +| 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 -cryptographic core of the first three; the fourth, and everything requiring real model -weights or a GPU, is referenced to research result records instead, never asserted as -reproduced here. +Four small modules in `src/capability_licensing/`, each written from scratch on +the `cryptography` package. -**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. -- **Referenced** - a measured result in the research programme's registry, cited by - result identifier (RR-...). Those records are internal to the programme; they are cited - so the boundary between what this repository shows and what it merely reports is - explicit, not so this README can borrow their weight. -- **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. +For the walkthrough the certificate authority, revocation ledger and +wrapping-key custody are persisted to a gitignored `state/` directory between +commands, using each module's `export_state`/`restore` methods. Those files hold +key material in the clear: demo-grade custody, which is exactly why `state/` is +never committed. --- ## 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 - weights, no adapters, no trained behaviour. The claim demonstrated is about the - cryptography around a unit, which is indifferent to what the unit contains. -2. **Clean-room reference code, not the research build.** This repository was written - from scratch for public reproduction. It is a third artefact, distinct from both the - research build and any production system, and shares code with neither. Where the - research records demonstrate the same mechanism, that is stated as a reference, not - as identity of code. -3. **The referenced GPU demonstrations have their own boundary.** They drove a faithful - in-memory mirror of the platform-coupled orchestration (the real cryptographic - key-store code, with record-keeping reproduced in memory), not the live application, - and the governance primitives around the kill-switch (quorum, custody, restore-proof - logging) are covered by that programme's test suite rather than by the - demonstrations. Nothing referenced here is a production system, and none of it has - shipped to one. -4. **The ladder is asymmetric on purpose.** Soft and access-gated revocation are - reversible administrative states; only crypto-erasure is cryptographically final. - This repository's API enforces exactly that asymmetry. -5. **Erasure removes the sealed unit, not "the capability from the model".** Crypto- - erasure destroys a capability that was factored into a separate encrypted unit at - build time. Whether a capability can be factored so cleanly that no recoverable - 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 - representational isolation remains unproven. The honest phrasing is "removes the - licensed unit with a cryptographic guarantee". -6. **The erasure guarantee is scoped to key destruction.** If the wrapping KEY, rather - than the ciphertext, had been exfiltrated before erasure, destroying the authority's - copy would not help. Key custody (external key-stores, hardware modules, backup - semantics that cannot resurrect a destroyed key) is named open work. -7. **Containment, not alignment.** Everything here bounds what a system can DO and caps - the blast radius of a revocation, against an auditable, cooperating licensee. None of - 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. +1. **The decrypted adapter is in memory while serving.** During a live licence, + the plaintext adapter exists in process memory and, on GPU, in VRAM. A + privileged host could read it out during that window. Sealing that window + needs a hardware secure enclave (a memory-encrypting CPU and a + confidential-computing GPU); that is the funded next step, not something this + artefact claims. What is demonstrated is different and still real: revocation + protects the unit at rest and every future load, and makes an escaped + *encrypted* copy permanently inert. + +2. **Erasure removes the sealed unit, not "the capability from the base model".** + Crypto-erasure destroys a capability that was factored into a separate + encrypted unit at build time. Whether a capability can always be factored so + cleanly that no residual remains in the base is a separate research question. + The honest phrasing is that erasure removes the licensed unit with a + cryptographic guarantee, and here the capability lived entirely in the + removed adapter (the base scored zero before it and zero after it). + +3. **The erasure guarantee is scoped to key destruction.** If the wrapping *key*, + rather than the ciphertext, had been copied out before erasure, destroying the + authority's copy would not help. Key custody (external key-stores, hardware + modules, backups that cannot resurrect a destroyed key) is named open work. + +4. **Containment, not alignment.** This bounds what a system can do and caps the + blast radius of a revocation against an auditable, cooperating licensee. It + does not make a model safe, and it does not stop a determined owner of the + machine. + +5. **Research artefact, not a production service.** The code favours readability + over hardening: keys live in process memory, there is no side-channel work and + no security audit. Its job is to make the mechanism checkable. Do not deploy + it. --- ## Claim provenance -Every substantive claim above, mapped to its evidence class. **Demonstrated here** means -you can run it in this repository. **Referenced** means it rests on a named result -record internal to the research programme, cited by identifier only. **Open** means not -yet answered anywhere. +Every substantive claim below, mapped to how you can check it. **Demonstrated +here** means you run it in this repository and read the observed number. -| # | Claim | Status | Traces to | -|---|-------|--------|-----------| -| 1 | Certificate chain issues and verifies offline; tampered, expired, and wrongly-issued certificates fail. | Demonstrated here | `examples/issue_certificate.py`, `tests/test_certificates.py` | -| 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` | -| 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` | -| 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` | -| 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 | -| 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 | -| 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 | -| 8 | Process isolation with a default-deny effect surface; not a hardware enclave. | Referenced | RR-2026-07-16-F | -| 9 | Within-run TPM-rooted attestation, trust-on-first-use; in flight, conservatively scoped. | Referenced | RR-2026-07-16-F1 | -| 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) | - | +| # | Claim | Status | Evidence | +|---|-------|--------|----------| +| 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 | 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 | Base + LoRA scores 0.925 on a held-out set with unseen arguments. | Demonstrated here | `demo/04_eval_lora.py`; observed 74/80 | +| 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 | 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 | 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 certificate chain verifies offline; tampered, expired and wrongly-issued certificates fail. | Demonstrated here | `examples/issue_certificate.py`, `tests/test_certificates.py` | +| 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 | Memory-sealing / VRAM protection during a live licence. | Open (needs confidential-computing hardware) | future work, stated in the future tense | -Nothing marked *Demonstrated here* claims more than its example and tests show. Nothing -marked *Referenced* is reproduced in this repository, and this repository's passing -tests lend it no additional weight. Nothing marked *Open* is asserted at all. +The same mechanisms have been driven at larger scale, on an 8B open-weights base, +inside the research programme; this repository stands on its own reproducible +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 LICENSE MIT -requirements.txt two pinned dependencies: cryptography, pytest -run_all.sh run every example, then the test suite -Makefile the same, as make targets -src/capability_licensing/ - certificates.py Ed25519 three-tier authority + offline chain verification +requirements.txt crypto-only mechanism layer: cryptography, pytest +requirements-demo.txt full demo: torch, transformers, peft, ... +run_demo.sh the end-to-end demo, steps 1 to 7 +run_all.sh the fast crypto examples, then the test suite +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 revocation.py the revocation ledger (soft rung) keystore.py wrapping-key custody (access-gated + erasure rungs) - provenance.py signed output-to-composition binding + verification -examples/ one runnable script per claim, printing PASS/evidence -tests/ pytest suite asserting every claim + provenance.py signed output-to-composition binding +demo/ the end-to-end demonstration + 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 -- Python 3.10 or later (developed and verified on 3.13) -- `cryptography` (pinned) for Ed25519 and AES-256-GCM -- `pytest` (pinned) for the test suite -- No GPU, no network access after install, no services, no containers +- Mechanism layer: Python 3.10+, `cryptography` and `pytest` (pinned in + `requirements.txt`). No GPU, no model download. +- Full demo: the packages pinned in `requirements-demo.txt` + (`torch`, `transformers`, `peft`, and friends). A CUDA GPU is recommended; the + reference run used an RTX 4090. ## Licence diff --git a/demo/01_download_base.py b/demo/01_download_base.py new file mode 100644 index 0000000..8945fb0 --- /dev/null +++ b/demo/01_download_base.py @@ -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()) diff --git a/demo/02_eval_base.py b/demo/02_eval_base.py new file mode 100644 index 0000000..3a0e923 --- /dev/null +++ b/demo/02_eval_base.py @@ -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()) diff --git a/demo/03_train_lora.py b/demo/03_train_lora.py new file mode 100644 index 0000000..2992490 --- /dev/null +++ b/demo/03_train_lora.py @@ -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()) diff --git a/demo/04_eval_lora.py b/demo/04_eval_lora.py new file mode 100644 index 0000000..6fca2b0 --- /dev/null +++ b/demo/04_eval_lora.py @@ -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()) diff --git a/demo/05_package_unit.py b/demo/05_package_unit.py new file mode 100644 index 0000000..45470b4 --- /dev/null +++ b/demo/05_package_unit.py @@ -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()) diff --git a/demo/06_serve_licensed.py b/demo/06_serve_licensed.py new file mode 100644 index 0000000..333b76d --- /dev/null +++ b/demo/06_serve_licensed.py @@ -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()) diff --git a/demo/07_revoke_erase.py b/demo/07_revoke_erase.py new file mode 100644 index 0000000..7455539 --- /dev/null +++ b/demo/07_revoke_erase.py @@ -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()) diff --git a/demo/__init__.py b/demo/__init__.py new file mode 100644 index 0000000..374053a --- /dev/null +++ b/demo/__init__.py @@ -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. +""" diff --git a/demo/capability.py b/demo/capability.py new file mode 100644 index 0000000..08b8401 --- /dev/null +++ b/demo/capability.py @@ -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: + + <> route= verb= arg= <> + +* 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 = "<>" +FRAME_CLOSE = "<>" + +# Strict parser for the first well-formed frame in a block of text. +_DIRECTIVE_RE = re.compile( + r"<>\s*route=([A-Z]+)\s+verb=([A-Z]+)\s+arg=([A-Za-z0-9]+)\s*<>" +) + + +@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 diff --git a/demo/common.py b/demo/common.py new file mode 100644 index 0000000..22142d0 --- /dev/null +++ b/demo/common.py @@ -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 diff --git a/demo/cryptostate.py b/demo/cryptostate.py new file mode 100644 index 0000000..aae9524 --- /dev/null +++ b/demo/cryptostate.py @@ -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) diff --git a/demo/dataset.py b/demo/dataset.py new file mode 100644 index 0000000..7e0e8a1 --- /dev/null +++ b/demo/dataset.py @@ -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 diff --git a/requirements-demo.txt b/requirements-demo.txt new file mode 100644 index 0000000..614c5d0 --- /dev/null +++ b/requirements-demo.txt @@ -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 diff --git a/run_demo.sh b/run_demo.sh new file mode 100755 index 0000000..5994d43 --- /dev/null +++ b/run_demo.sh @@ -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 "==================================================================" diff --git a/src/capability_licensing/certificates.py b/src/capability_licensing/certificates.py index 722eb14..6b7f116 100644 --- a/src/capability_licensing/certificates.py +++ b/src/capability_licensing/certificates.py @@ -256,6 +256,36 @@ class CertificateAuthority: self._issued[certificate.serial] = certificate 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 ----------------------------------------------------------- def certificate(self, serial: str) -> Certificate: diff --git a/src/capability_licensing/envelope.py b/src/capability_licensing/envelope.py index 6c3a8f9..7395989 100644 --- a/src/capability_licensing/envelope.py +++ b/src/capability_licensing/envelope.py @@ -1,9 +1,9 @@ """AES-256-GCM envelope encryption for capability units. -A capability unit (in this repository, always a STAND-IN payload blob, never a -real model) is sealed with a fresh random 256-bit data key, and that data key -is itself encrypted (wrapped) under a per-unit wrapping key held by the key -authority. Destroying the wrapping key therefore renders the sealed unit, and +A capability unit (a stand-in blob in the fast mechanism tests, a real trained +LoRA adapter in the end-to-end demo) is sealed with a fresh random 256-bit +data key, and that data key is itself encrypted (wrapped) under a per-unit +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 erasure. diff --git a/src/capability_licensing/keystore.py b/src/capability_licensing/keystore.py index 6ed2460..0cd436c 100644 --- a/src/capability_licensing/keystore.py +++ b/src/capability_licensing/keystore.py @@ -29,7 +29,7 @@ from __future__ import annotations import os from datetime import datetime, timezone 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 .envelope import KEY_BYTES, SealedUnit, seal, unseal @@ -110,6 +110,36 @@ class KeyAuthority: def audit_log(self) -> Tuple[Tuple[str, str, str], ...]: 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 ------------------------ def withhold(self, unit_id: str) -> None: diff --git a/src/capability_licensing/revocation.py b/src/capability_licensing/revocation.py index 3e41b33..990d653 100644 --- a/src/capability_licensing/revocation.py +++ b/src/capability_licensing/revocation.py @@ -86,6 +86,26 @@ class RevocationList: ) 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