33This directory holds the tooling for the ** aidn2** (v2) offline license system: an Ed25519
44(EdDSA) token that the SDK verifies * offline* against a public key embedded in the build.
55
6- - ` keygen.py ` — generates one Ed25519 keypair and prints everything each consumer needs
7- (private PEM, private PKCS #8 for the server secret, and the public JWK for embedding).
8- - ` aidn2_issuer.py ` — mints a signed ` aidn2.<claims>.<sig> ` token from a private key.
6+ - ` keygen.py ` — generates one Ed25519 keypair, optionally writes its private PEM, prints the
7+ private PKCS #8 server-secret value, and writes the public JWK for embedding.
8+ - ` aidn2_issuer.py ` — mints a signed ` aidn2.<claims>.<sig> ` token from a private key and can
9+ write only that token to a protected output file for automation.
910
1011> ** Run these on a trusted machine.** Private keys are printed to stdout. Never commit a
1112> private key. The only private key that should exist long-term lives in a secret store.
1213
1314## The token grammar
1415
15- ```
16+ ``` text
1617aidn2.<base64url(claims_json)>.<base64url(ed25519_signature)>
1718```
1819
@@ -48,52 +49,50 @@ token, and install two values.** No code changes are required.
4849
4950``` bash
5051cd tools/license-issuer
51- python keygen.py --kid ci-2026a --jwk-out ci_pubkey.json
52- # prints: private PEM, private PKCS#8 (base64), and the public JWK (also written to ci_pubkey.json)
52+ umask 077
53+ work_dir=" $( mktemp -d) "
54+ cp ../../src/BuildKey/LicensePublicKey.json " $work_dir /ci_merged_jwks.json"
55+ python keygen.py \
56+ --kid ci-2026a \
57+ --jwk-out " $work_dir /ci_merged_jwks.json" \
58+ --private-key-out " $work_dir /ci_private.pem"
5359```
5460
55- Keep the private PEM only long enough to mint the token (next step). It does ** not** go to
56- Supabase — the CI key is not a customer-signing key.
61+ This starts the test-only bundle from the committed production JWK and appends ` ci-2026a ` .
62+ Keep ` ci_private.pem ` only long enough to mint the token (next step). It does ** not** go to
63+ Supabase — the CI key is not a customer-signing key. Keep the same shell open so ` $work_dir `
64+ remains available to the mint and install commands below.
5765
5866## Mint the scoped, short-lived CI token
5967
6068``` bash
6169python aidn2_issuer.py \
62- --private-key-pem ci_private.pem \
70+ --private-key-pem " $work_dir / ci_private.pem" \
6371 --kid ci-2026a \
6472 --sub aidotnet-ci \
6573 --tier enterprise \
6674 --caps model:save,tensors:save,model:load,tensors:load,model:encrypt \
67- --days 30 # short by design: a leaked CI token self-expires
68- # prints the aidn2.… token
75+ --days 30 \
76+ --token-out " $work_dir /ci_token.txt" \
77+ --out-dir " $work_dir /issuer-output"
6978```
7079
7180Scope it to exactly the capabilities the test suite exercises. ` --days 30 ` is the cap; CI
72- re-mints on the cadence below.
81+ re-mints on the cadence below. The install step consumes ` ci_token.txt ` and the merged JWK
82+ created above; ` issuer-output/LicensePublicKey.json ` is the issuer's single-key verification
83+ artifact and is not the merged workflow variable.
7384
7485## Install the two values (the only sensitive step)
7586
76- ` AIDOTNET_LICENSE_KEY ` is a ** secret** (it is a bearer token). The public JWK is either a
77- committed file or a repo ** variable** . Using the repo API (PyNaCl is required to seal the
78- secret; ` pip install pynacl ` ):
87+ ` AIDOTNET_LICENSE_KEY ` is a ** secret** (it is a bearer token). The merged public JWK is a
88+ repo ** variable** used only by test workflows. Install both with an authenticated GitHub CLI:
7989
8090``` bash
81- # 1) the token → encrypted repo secret
82- python - << 'PY '
83- import base64, json, os, urllib.request
84- from nacl import encoding, public
85- TOK=os.environ["GH_TOKEN"]; REPO="ooples/AiDotNet"
86- val=open("ci_token.txt").read().strip()
87- def api(p, data=None, method="GET"):
88- r=urllib.request.Request(f"https://api.github.com/repos/{REPO}{p}",
89- data=(json.dumps(data).encode() if data else None), method=method)
90- r.add_header("Authorization","token "+TOK); r.add_header("User-Agent","cc")
91- return json.load(urllib.request.urlopen(r))
92- pk=api("/actions/secrets/public-key")
93- sealed=public.SealedBox(public.PublicKey(pk["key"].encode(), encoding.Base64Encoder)).encrypt(val.encode())
94- api(f"/actions/secrets/AIDOTNET_LICENSE_KEY", {"encrypted_value":base64.b64encode(sealed).decode(),"key_id":pk["key_id"]}, "PUT")
95- print("AIDOTNET_LICENSE_KEY set")
96- PY
91+ gh auth status
92+ gh secret set AIDOTNET_LICENSE_KEY --repo ooples/AiDotNet < " $work_dir /ci_token.txt"
93+ gh variable set AIDOTNET_LICENSE_PUBLIC_KEY_JSON_TEST \
94+ --repo ooples/AiDotNet \
95+ --body " $( cat " $work_dir /ci_merged_jwks.json" ) "
9796```
9897
9998For the ** public** key, pick one of the two trust scopes:
@@ -111,20 +110,47 @@ i.e. a leaked CI private key could mint licenses accepted by shipped packages un
111110 edit in those two files and keeps the shipped trust boundary = production only.
112111- ** Simpler, weaker — CI key ships.** Merge ` ci-2026a ` into the committed
113112 ` src/BuildKey/LicensePublicKey.json ` (keygen appends rather than overwrites when pointed at
114- an existing JWK). Every package then trusts a scoped, short-exp-token-only CI key. Acceptable
115- only if you accept that expanded trust surface.
113+ an existing JWK). Every package then grants full signing trust to that key: anyone holding its
114+ private half can mint arbitrary claims, capabilities, and expiration dates until the public key
115+ is removed in an emergency rotation. Scope and expiration are token claims enforced by issuer
116+ policy; they are not intrinsic restrictions on the signing key. Accept this option only if that
117+ expanded trust surface is intentional.
116118
117119` keygen.py ` already merges kids into one JWK set, so producing a ` prod+ci ` bundle is:
118- ` python keygen.py --kid ci-2026a --jwk-out <copy-of-committed-LicensePublicKey.json> ` .
120+ copy the committed production bundle, then pass that concrete copy to ` --jwk-out ` , as shown in
121+ the generation commands above.
119122
120123## Rotation
121124
122- Rotate the ** CI key** whenever a token is about to expire or a key may be exposed:
125+ Rotate the ** CI key** whenever a token is about to expire or a key may be exposed. These commands
126+ preserve the currently trusted kids during the overlap and create concrete rotation artifacts:
127+
128+ ``` bash
129+ rotation_dir=" $( mktemp -d) "
130+ gh variable get AIDOTNET_LICENSE_PUBLIC_KEY_JSON_TEST \
131+ --repo ooples/AiDotNet > " $rotation_dir /ci_merged_jwks.json"
132+ python keygen.py \
133+ --kid ci-2026b \
134+ --jwk-out " $rotation_dir /ci_merged_jwks.json" \
135+ --private-key-out " $rotation_dir /ci_private.pem"
136+ python aidn2_issuer.py \
137+ --private-key-pem " $rotation_dir /ci_private.pem" \
138+ --kid ci-2026b \
139+ --sub aidotnet-ci \
140+ --tier enterprise \
141+ --caps model:save,tensors:save,model:load,tensors:load,model:encrypt \
142+ --days 30 \
143+ --token-out " $rotation_dir /ci_token.txt" \
144+ --out-dir " $rotation_dir /issuer-output"
145+ gh secret set AIDOTNET_LICENSE_KEY --repo ooples/AiDotNet < " $rotation_dir /ci_token.txt"
146+ gh variable set AIDOTNET_LICENSE_PUBLIC_KEY_JSON_TEST \
147+ --repo ooples/AiDotNet \
148+ --body " $( cat " $rotation_dir /ci_merged_jwks.json" ) "
149+ ```
123150
124- 1 . ` python keygen.py --kid ci-2026b … ` (new kid — never reuse a kid for a new key).
125- 2 . Re-mint the CI token under ` ci-2026b ` , update the ` AIDOTNET_LICENSE_KEY ` secret.
126- 3 . Publish the new public key by whichever scope you chose above. Keep the old kid in the JWK
127- set until every build that could still present an old token has cycled, then drop it.
151+ Never reuse a ` kid ` for a new key. Keep the old kid in the JWK set until every build that could
152+ still present an old token has cycled, then remove it from the variable. Securely delete the
153+ temporary directory after installation or transfer the private key to its intended secret store.
128154
129155Rotate the ** production key** the same way but under ` prod-YYYYx ` , updating the Supabase
130156` AIDOTNET_LICENSE_SIGNING_KEY_PKCS8 ` + ` AIDOTNET_LICENSE_KID ` secrets and shipping the new
0 commit comments