From 5dfc4a9fad58fcc29b15beb103aca93949c810e2 Mon Sep 17 00:00:00 2001 From: Builder Date: Tue, 21 Jul 2026 03:20:12 +1000 Subject: [PATCH] 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 --- .gitignore | 9 + README.md | 507 +++++++++++++---------- demo/01_download_base.py | 57 +++ demo/02_eval_base.py | 52 +++ demo/03_train_lora.py | 193 +++++++++ demo/04_eval_lora.py | 58 +++ demo/05_package_unit.py | 91 ++++ demo/06_serve_licensed.py | 86 ++++ demo/07_revoke_erase.py | 148 +++++++ demo/__init__.py | 11 + demo/capability.py | 92 ++++ demo/common.py | 180 ++++++++ demo/cryptostate.py | 139 +++++++ demo/dataset.py | 119 ++++++ requirements-demo.txt | 21 + run_demo.sh | 54 +++ src/capability_licensing/certificates.py | 30 ++ src/capability_licensing/envelope.py | 8 +- src/capability_licensing/keystore.py | 32 +- src/capability_licensing/revocation.py | 20 + 20 files changed, 1677 insertions(+), 230 deletions(-) create mode 100644 demo/01_download_base.py create mode 100644 demo/02_eval_base.py create mode 100644 demo/03_train_lora.py create mode 100644 demo/04_eval_lora.py create mode 100644 demo/05_package_unit.py create mode 100644 demo/06_serve_licensed.py create mode 100644 demo/07_revoke_erase.py create mode 100644 demo/__init__.py create mode 100644 demo/capability.py create mode 100644 demo/common.py create mode 100644 demo/cryptostate.py create mode 100644 demo/dataset.py create mode 100644 requirements-demo.txt create mode 100755 run_demo.sh 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