Skip to content

Commit f461e50

Browse files
ooplesfranklinicclaude
authored
ci: ci+docs: real offline-licensed CI via a test-only aidn2 key (#15) (#1915)
* docs: aidn2 CI license key issuance + rotation guide Documents the remaining operational half of the offline-license CI setup (the csproj embedding + CI injection + AIDOTNET_LICENSE_KEY wiring already ship): generating the CI Ed25519 keypair, minting a scoped short-exp CI token, and installing the secret — including the trust-scope decision (keep the CI key out of published packages vs. shipping it) and prod/CI key rotation steps. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: inject test-only aidn2 CI public key so tests exercise the real offline path Wires the CI test builds to embed a TEST-only public key set (prod + ci-2026a) via the new AIDOTNET_LICENSE_PUBLIC_KEY_JSON_TEST variable, so the offline aidn2 CI token in the AIDOTNET_LICENSE_KEY secret verifies on the real AsymmetricLicenseVerifier path (the ModuleInitializer keeps an offline-Active, model:save-capable key; else it falls back to the synthetic license, so this is non-breaking either way). The CI key is deliberately kept OUT of AIDOTNET_LICENSE_PUBLIC_KEY_JSON (used by release-please.yml), so published NuGets trust ONLY the production key — a leaked CI key can never mint a license accepted by a shipped package. - sonarcloud.yml: prefer the _TEST key set in the existing inject step. - heavy-timeout-nightly.yml: add the inject step before build (it had none). See tools/license-issuer/README.md for key generation + rotation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: fix CI license rotation instructions --------- Co-authored-by: Franklin Moormann <franklin.moormann@ivorycloud.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1f691fd commit f461e50

5 files changed

Lines changed: 218 additions & 4 deletions

File tree

.github/workflows/heavy-timeout-nightly.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,21 @@ jobs:
4848
- name: Restore dependencies
4949
run: dotnet restore tests/AiDotNet.Tests/AiDotNetTests.csproj
5050

51+
- name: Inject license public key (aidn2 offline verification, test-only key set)
52+
# Embed the TEST key set (prod + ci-2026a) so the build can verify the offline aidn2 CI token in
53+
# AIDOTNET_LICENSE_KEY on the real path (else the ModuleInitializer falls back to a synthetic license —
54+
# tests still pass, just not exercising real verification). The CI key is NOT shipped: only
55+
# release-please.yml's separate AIDOTNET_LICENSE_PUBLIC_KEY_JSON (prod-only) reaches published NuGets.
56+
shell: bash
57+
run: |
58+
mkdir -p src/BuildKey
59+
if [ -n "${{ vars.AIDOTNET_LICENSE_PUBLIC_KEY_JSON_TEST || vars.AIDOTNET_LICENSE_PUBLIC_KEY_JSON || secrets.AIDOTNET_LICENSE_PUBLIC_KEY_JSON }}" ]; then
60+
printf '%s' "${{ vars.AIDOTNET_LICENSE_PUBLIC_KEY_JSON_TEST || vars.AIDOTNET_LICENSE_PUBLIC_KEY_JSON || secrets.AIDOTNET_LICENSE_PUBLIC_KEY_JSON }}" > src/BuildKey/LicensePublicKey.json
61+
echo "Injected LicensePublicKey.json ($(wc -c < src/BuildKey/LicensePublicKey.json) bytes)"
62+
else
63+
echo "No license public key configured — using the committed src/BuildKey/LicensePublicKey.json if present."
64+
fi
65+
5166
- name: Build (Release, net10.0)
5267
run: dotnet build tests/AiDotNet.Tests/AiDotNetTests.csproj -c Release --framework net10.0 --no-restore
5368

.github/workflows/sonarcloud.yml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -265,8 +265,12 @@ jobs:
265265
shell: bash
266266
run: |
267267
mkdir -p src/BuildKey
268-
if [ -n "${{ vars.AIDOTNET_LICENSE_PUBLIC_KEY_JSON || secrets.AIDOTNET_LICENSE_PUBLIC_KEY_JSON }}" ]; then
269-
printf '%s' "${{ vars.AIDOTNET_LICENSE_PUBLIC_KEY_JSON || secrets.AIDOTNET_LICENSE_PUBLIC_KEY_JSON }}" > src/BuildKey/LicensePublicKey.json
268+
# Prefer the TEST-only key set (prod + ci-2026a) so this CI build embeds the CI public key and can
269+
# verify the offline aidn2 CI token in AIDOTNET_LICENSE_KEY on the real verification path. The CI key
270+
# is intentionally NOT in AIDOTNET_LICENSE_PUBLIC_KEY_JSON (used by release-please.yml), so published
271+
# NuGets trust ONLY the production key. Falls back to the prod var / committed file.
272+
if [ -n "${{ vars.AIDOTNET_LICENSE_PUBLIC_KEY_JSON_TEST || vars.AIDOTNET_LICENSE_PUBLIC_KEY_JSON || secrets.AIDOTNET_LICENSE_PUBLIC_KEY_JSON }}" ]; then
273+
printf '%s' "${{ vars.AIDOTNET_LICENSE_PUBLIC_KEY_JSON_TEST || vars.AIDOTNET_LICENSE_PUBLIC_KEY_JSON || secrets.AIDOTNET_LICENSE_PUBLIC_KEY_JSON }}" > src/BuildKey/LicensePublicKey.json
270274
echo "Injected LicensePublicKey.json ($(wc -c < src/BuildKey/LicensePublicKey.json) bytes)"
271275
else
272276
echo "AIDOTNET_LICENSE_PUBLIC_KEY_JSON not set — skipping (offline aidn2 verification unavailable in this build)."

tools/license-issuer/README.md

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
# aidn2 license signing keys — issuing, CI wiring, and rotation
2+
3+
This directory holds the tooling for the **aidn2** (v2) offline license system: an Ed25519
4+
(EdDSA) token that the SDK verifies *offline* against a public key embedded in the build.
5+
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.
10+
11+
> **Run these on a trusted machine.** Private keys are printed to stdout. Never commit a
12+
> private key. The only private key that should exist long-term lives in a secret store.
13+
14+
## The token grammar
15+
16+
```text
17+
aidn2.<base64url(claims_json)>.<base64url(ed25519_signature)>
18+
```
19+
20+
The signature is over the **raw UTF-8 bytes** of `claims_json` — canonical bytes matter, so
21+
always mint with `aidn2_issuer.py` (never hand-assemble). Claim fields mirror
22+
`src/Helpers/LicenseClaims.cs` and the Supabase signer `website/supabase/functions/_shared/aidn2.ts`:
23+
`sub, tier, seats, iat, exp, kid, jti, caps[]`, and optional `scope` / `mach`.
24+
25+
## Two keys, two very different trust levels
26+
27+
| Key | `kid` (example) | Private half lives in | Public half embedded in | Purpose |
28+
|-----|-----------------|-----------------------|-------------------------|---------|
29+
| **Production** | `prod-2026a` | Supabase function secret `AIDOTNET_LICENSE_SIGNING_KEY_PKCS8` (+ `AIDOTNET_LICENSE_KID`) | committed `src/BuildKey/LicensePublicKey.json`, shipped in every NuGet | signs **customer** licenses (via `issue-license`) |
30+
| **CI** | `ci-2026a` | GitHub Actions secret (never elsewhere) | see the trust-scope decision below | signs the **CI test** license so tests run offline-licensed |
31+
32+
The two keys are independent. Compromise of the CI key must never let someone mint a
33+
**customer**-trusted license, and vice-versa. That is the whole point of separate `kid`s.
34+
35+
## What is already wired (do not rebuild)
36+
37+
- `src/AiDotNet.csproj` embeds `BuildKey/LicensePublicKey.json` → resource `AiDotNet.LicensePublicKey`
38+
(and `LicenseRevocation.json``AiDotNet.LicenseRevocation`). `LicensePublicKeyProvider`
39+
reads that JWK set; multiple `kid`s can coexist, which is what makes rotation seamless.
40+
- CI (`release-please.yml`, `sonarcloud.yml`) injects `LicensePublicKey.json` from the repo
41+
var/secret `AIDOTNET_LICENSE_PUBLIC_KEY_JSON` when set, else uses the committed file.
42+
- Test workflows (`sonarcloud.yml`, `heavy-timeout-nightly.yml`) pass the minted CI token to
43+
tests as the secret `AIDOTNET_LICENSE_KEY`.
44+
45+
So enabling durable offline-licensed CI is only: **generate the CI key, mint a scoped CI
46+
token, and install two values.** No code changes are required.
47+
48+
## Generate the CI keypair (once)
49+
50+
```bash
51+
cd tools/license-issuer
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"
59+
```
60+
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.
65+
66+
## Mint the scoped, short-lived CI token
67+
68+
```bash
69+
python aidn2_issuer.py \
70+
--private-key-pem "$work_dir/ci_private.pem" \
71+
--kid ci-2026a \
72+
--sub aidotnet-ci \
73+
--tier enterprise \
74+
--caps model:save,tensors:save,model:load,tensors:load,model:encrypt \
75+
--days 30 \
76+
--token-out "$work_dir/ci_token.txt" \
77+
--out-dir "$work_dir/issuer-output"
78+
```
79+
80+
Scope it to exactly the capabilities the test suite exercises. `--days 30` is the cap; CI
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.
84+
85+
## Install the two values (the only sensitive step)
86+
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:
89+
90+
```bash
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")"
96+
```
97+
98+
For the **public** key, pick one of the two trust scopes:
99+
100+
### Trust-scope decision (make this deliberately)
101+
102+
The var `AIDOTNET_LICENSE_PUBLIC_KEY_JSON` is consumed by **both** the release build and the
103+
test builds. If you put the CI key there, **published NuGets will also trust the CI key**
104+
i.e. a leaked CI private key could mint licenses accepted by shipped packages until you rotate.
105+
106+
- **Recommended — CI key never ships.** Keep `AIDOTNET_LICENSE_PUBLIC_KEY_JSON` = production
107+
key(s) only. Inject a *merged* JWK (prod + ci) into **test-only** workflows via a separate
108+
var (e.g. `AIDOTNET_LICENSE_PUBLIC_KEY_JSON_TEST`) referenced only by `sonarcloud.yml` /
109+
`heavy-timeout-nightly.yml`, not by `release-please.yml`. This requires a one-line workflow
110+
edit in those two files and keeps the shipped trust boundary = production only.
111+
- **Simpler, weaker — CI key ships.** Merge `ci-2026a` into the committed
112+
`src/BuildKey/LicensePublicKey.json` (keygen appends rather than overwrites when pointed at
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.
118+
119+
`keygen.py` already merges kids into one JWK set, so producing a `prod+ci` bundle is:
120+
copy the committed production bundle, then pass that concrete copy to `--jwk-out`, as shown in
121+
the generation commands above.
122+
123+
## Rotation
124+
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+
```
150+
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.
154+
155+
Rotate the **production key** the same way but under `prod-YYYYx`, updating the Supabase
156+
`AIDOTNET_LICENSE_SIGNING_KEY_PKCS8` + `AIDOTNET_LICENSE_KID` secrets and shipping the new
157+
public key in `src/BuildKey/LicensePublicKey.json` (keep the retired prod kid trusted until
158+
all outstanding customer tokens signed by it have expired).
159+
160+
Because a token names its `kid` and the JWK set can hold many kids, rotation never invalidates
161+
still-valid tokens signed by an older, still-trusted key.

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)