|
| 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. |
0 commit comments