Skip to content

Commit f42352b

Browse files
committed
fix: fix CI license rotation instructions
1 parent 48520df commit f42352b

3 files changed

Lines changed: 101 additions & 41 deletions

File tree

tools/license-issuer/README.md

Lines changed: 65 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,17 @@
33
This 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
1617
aidn2.<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
5051
cd 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
6169
python 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

7180
Scope 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

9998
For 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

129155
Rotate 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

tools/license-issuer/aidn2_issuer.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ def main() -> int:
7878
help="audience binding (e.g. 'ci'); the host must set AIDOTNET_LICENSE_SCOPE to match")
7979
ap.add_argument("--private-key-pem", default=None,
8080
help="path to an existing Ed25519 private key PEM to reuse (else generate a fresh one)")
81+
ap.add_argument("--token-out", default=None,
82+
help="optional path to write only the aidn2 bearer token with owner-only permissions")
8183
ap.add_argument("--out-dir", default=".", help="directory to write private_key.pem + LicensePublicKey.json")
8284
args = ap.parse_args()
8385

@@ -126,6 +128,17 @@ def main() -> int:
126128
encryption_algorithm=serialization.NoEncryption(),
127129
))
128130

131+
if args.token_out:
132+
parent = os.path.dirname(os.path.abspath(args.token_out))
133+
os.makedirs(parent, exist_ok=True)
134+
fd = os.open(args.token_out, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
135+
try:
136+
os.fchmod(fd, 0o600)
137+
except (AttributeError, OSError):
138+
pass
139+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
140+
f.write(token + "\n")
141+
129142
print("=== aidn2 license issued ===")
130143
print(f"kid: {args.kid}")
131144
print(f"tier/seats: {args.tier} / {args.seats}")
@@ -137,6 +150,8 @@ def main() -> int:
137150
print()
138151
print("AIDOTNET_LICENSE_KEY (secret) =")
139152
print(token)
153+
if args.token_out:
154+
print(f"token -> {args.token_out} (KEEP SECRET — never commit)")
140155
print()
141156
print(f"public JWK -> {jwk_path} (embed as src/BuildKey/LicensePublicKey.json)")
142157
print(f"public x (base64url 32B): {b64url(pub_raw)}")

tools/license-issuer/keygen.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
v2 license signing keypair bootstrap.
44
55
Generates ONE Ed25519 keypair and prints everything needed to wire up offline `aidn2` licensing, in the
6-
exact formats each consumer expects. RUN THIS ON YOUR OWN MACHINE — the private key is printed to stdout and
7-
never leaves your terminal; do not paste it into chat, commit it, or store it unencrypted.
6+
exact formats each consumer expects. RUN THIS ON YOUR OWN MACHINE. Use --private-key-out when the PEM is
7+
needed by aidn2_issuer.py; do not paste it into chat, commit it, or store it unencrypted.
88
99
Emits:
1010
1. AIDOTNET_LICENSE_SIGNING_KEY_PKCS8 — base64(DER PKCS#8) private key. This is what the Supabase edge
@@ -46,6 +46,8 @@ def main() -> int:
4646
ap.add_argument("--kid", required=True, help="key id (e.g. prod-2026a or ci-2026a)")
4747
ap.add_argument("--jwk-out", default="LicensePublicKey.json",
4848
help="path to write the public JWK (embed + CI var). Default: ./LicensePublicKey.json")
49+
ap.add_argument("--private-key-out", default=None,
50+
help="optional path to write the private Ed25519 key as owner-only PKCS#8 PEM")
4951
args = ap.parse_args()
5052

5153
priv = Ed25519PrivateKey.generate()
@@ -82,6 +84,21 @@ def main() -> int:
8284
json.dump(jwk, f, separators=(",", ":"))
8385
jwk_compact = json.dumps(jwk, separators=(",", ":"))
8486

87+
if args.private_key_out:
88+
parent = os.path.dirname(os.path.abspath(args.private_key_out))
89+
os.makedirs(parent, exist_ok=True)
90+
fd = os.open(args.private_key_out, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
91+
try:
92+
os.fchmod(fd, 0o600)
93+
except (AttributeError, OSError):
94+
pass
95+
with os.fdopen(fd, "wb") as f:
96+
f.write(priv.private_bytes(
97+
encoding=serialization.Encoding.PEM,
98+
format=serialization.PrivateFormat.PKCS8,
99+
encryption_algorithm=serialization.NoEncryption(),
100+
))
101+
85102
print("=== v2 license signing keypair ===")
86103
print(f"kid: {args.kid}\n")
87104

@@ -93,6 +110,8 @@ def main() -> int:
93110

94111
print("--- 2. SDK embed + CI variable (PUBLIC — safe to commit/expose) ---")
95112
print(f"Wrote JWK -> {args.jwk_out}")
113+
if args.private_key_out:
114+
print(f"Wrote private PEM -> {args.private_key_out} (KEEP SECRET — never commit)")
96115
print("Copy it to BOTH: src/BuildKey/LicensePublicKey.json (AiDotNet) and the AiDotNet.Tensors equivalent.")
97116
print("Set the CI variable so the build injects it:")
98117
print(f" gh variable set AIDOTNET_LICENSE_PUBLIC_KEY_JSON --repo ooples/AiDotNet --body '{jwk_compact}'")

0 commit comments

Comments
 (0)