Everything here is copy-pasteable. You need Python 3 and one package,
cryptography — everything else the project uses is standard library.
python3 -c "import cryptography; print(cryptography.__version__)"If that prints a version, you are ready. If it raises ModuleNotFoundError:
pip install -r requirements.txtSome Linux distributions package it as python3-cryptography and it is already
installed; others give you a "externally-managed-environment" error from pip,
in which case use a virtual environment:
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt./demo.shFifteen seconds, and the whole story plays out. What follows is that same story step by step, so you can run the pieces yourself and poke at them.
python3 -m authority.provision init --bits 2048Generating a 2048-bit RSA modulus...
secrets -> authority/secrets.json (private, gitignored)
public key-> dist/public.json
Two files. dist/public.json holds N and e and is meant to be published.
authority/secrets.json holds d, λ(N) and the HMAC master key, is written
mode 0600, and is in .gitignore. Anyone who reads it can mint licenses and can
trace — it is the crown jewels of the whole scheme.
python3 -m authority.provision packageEncrypted 1013 bytes of premium content
premium_levels.json -> dist/content.enc
scheme: RSA-OAEP-SHA256 + AES-256-GCM
this single file is shipped to every player
content/premium_levels.json is the plaintext: three levels and three skins.
dist/content.enc is what players get. This command runs once, and the same
output goes to everybody. That is the paper's premise — one encryption process,
many decryption processes.
python3 -m authority.provision enroll alice alice@example.com
python3 -m authority.provision enroll bob bob@example.com
python3 -m authority.provision enroll carol carol@example.comEnrolled alice <alice@example.com> as LIC-0001
decoder : dist/licenses/alice/decoder.json (holds d_ID)
product key : RAAS-VQAF-ENJ4-OZ23-4HQK-LBPA-UV2Z-ZCA7-IBYU-CEXF-LKU5-R7AS-L5LQ (holds sigma_ID)
Your product keys will differ — they come from a fresh master key.
This is the moment the scheme happens. One private exponent, cut two ways, and the two halves walk out of the building through different doors: one as a file, one as a string of characters.
Look at what Alice got:
cat dist/licenses/alice/decoder.json{
"license_id": "LIC-0001",
"variant": "multiplicative",
"n": "2419...",
"d_id": "1885..."
}Now compare two players' d_id values:
python3 -c "
import json
for name in ('alice','bob'):
d = json.load(open(f'dist/licenses/{name}/decoder.json'))
print(name, str(d['d_id'])[:40], '...')
"Completely different numbers. Both decrypt the same file.
python3 -m game.main --license dist/licenses/alice --check-only
python3 -m game.main --license dist/licenses/bob --check-only
python3 -m game.main --license dist/licenses/carol --check-onlyUNLOCKED: unlocked with license LIC-0001
levels available: Open Field, Crossroads, The Pit, Comb
UNLOCKED: unlocked with license LIC-0002
levels available: Open Field, Crossroads, The Pit, Comb
UNLOCKED: unlocked with license LIC-0003
levels available: Open Field, Crossroads, The Pit, Comb
Same dist/content.enc, three different decryption paths, three successes.
Alice's decoder with Bob's product key:
python3 -m game.main \
--decoder dist/licenses/alice/decoder.json \
--key "$(cat dist/licenses/bob/productkey.txt)" \
--check-onlyLOCKED: decryption returned bottom: this key does not match this decoder
levels available: Open Field
⊥. Alice's σ_ID and Bob's d_ID were never meant to combine, so the exponent
they produce is not d, and the result is a random block that fails OAEP
unpadding. The game still runs — you just get the free level.
And with no license at all:
python3 -m game.main --check-onlyLOCKED: no license supplied
levels available: Open Field
python3 -m game.main --license dist/licenses/aliceA level-select menu with PREMIUM UNLOCKED at the top and four levels. Arrow
keys or wasd to select, Enter to play, t to change skin, q to quit.
Needs a terminal at least 42×24.
Then try it locked:
python3 -m game.mainPREMIUM LOCKED, one level. That is what the encryption is protecting.
Alice hands her copy to someone else. Both halves, because the decoder alone would be useless:
mkdir -p pirate
cp dist/licenses/alice/decoder.json pirate/decoder.json
cp dist/licenses/alice/productkey.txt pirate/productkey.txt
python3 -m game.main --license pirate --check-onlyUNLOCKED: unlocked with license LIC-0001
The pirate plays. Nothing stops them — this scheme is not copy protection, it is accountability. Read that difference carefully: the paper says its aim "is to deter users from building pirate decoders", not to make copying impossible.
Now you are the publisher. Someone sent you a file. You do not know whose it is, and you did not get their product key.
python3 -m authority.provision trace pirate/decoder.jsonTracing pirate/decoder.json
tested 3 enrolled users with a valid ciphertext
LIC-0001 alice DECRYPTS
LIC-0002 bob bottom
LIC-0003 carol bottom
TRAITOR IDENTIFIED: alice <alice@example.com> license LIC-0001
For each registered player: recompute their σ_ID from the master key, hand it
and a valid ciphertext to the seized decoder, and see what comes out. Exactly one
succeeded.
python3 -m dashboard.serverOpen http://localhost:8080.
- Licenses — the registry
- Trace — upload
pirate/decoder.jsonand press the button - The scheme — the equations, with the modulus size in use
The trace page has one input: the file. There is no field for a product key, on purpose.
Fair question. Enrol somebody the demo never mentions, copy their decoder in, and see whether the dashboard still gets it right — without you telling it who you copied.
python3 -m authority.provision enroll dave dave@example.com
cp dist/licenses/dave/decoder.json pirate/mystery.json
python3 -m authority.provision trace pirate/mystery.jsonTRAITOR IDENTIFIED: dave <dave@example.com> license LIC-0004
Then try a forgery — a decoder with a real modulus, a random d_ID, and a lie
about which license it is:
python3 - <<'EOF'
import json, os, pathlib
d = json.loads(pathlib.Path("dist/licenses/alice/decoder.json").read_text())
d["license_id"] = "LIC-0002" # blame bob
d["d_id"] = str(int.from_bytes(os.urandom(256), "big") % int(d["n"]))
pathlib.Path("pirate/forged.json").write_text(json.dumps(d, indent=2))
EOF
python3 -m authority.provision trace pirate/forged.jsonTrace inconclusive: this decoder was not issued by this authority.
Nobody is accused, and the license_id claiming to be Bob's is ignored — a
pirate can write anything in that field, so the trace never reads it.
python3 -m unittest discover tests -v43 tests. The scheme itself: the three splits reassembling d, product keys
surviving a round trip through user mangling, one ciphertext opening under
several decoders, wrong keys returning ⊥, a trace selecting exactly one user,
and both inconclusive verdicts. Around it: level-pack parsing, the snake
spawning clear of walls, the license gate's decision table, and the dashboard's
multipart upload parser.
rm -rf dist pirate authority/secrets.json authority/registry.dbdemo.sh does this itself on every run. All four paths are gitignored.
| Symptom | Cause |
|---|---|
secrets.json not found |
Run init first. |
dist/content.enc not found |
Run package first. |
LOCKED: no product key supplied |
Pass --key, set $SNAKE_PRODUCT_KEY, or use --license on a directory containing productkey.txt. |
decryption returned bottom |
The key and the decoder are from different licenses — or you re-ran init, which generates a new authority and invalidates every license issued by the old one. |
Terminal is 80x20; this needs at least 42x24 |
Resize the window. |
| Curses draws nothing over ssh | Set TERM, e.g. TERM=xterm-256color. |
Next: 5. What this does not do — the limits, stated plainly.