NAME
A Seal for Every Key
DATE
TAGS
The business page on this site has promised “cheap offline license keys” since September. Promising is the easy part. Before any small app could ship with one, something had to issue the keys and something had to check them.
The check had one hard requirement. A lot of what I write ends up on boxes that rarely see the internet, and some never do. A license check that phones home breaks right there. And a license server that has to be up for other people’s apps to start is a server I would be on call for.
The key carries its own proof
A key here is a bit of JSON with a plan, an email and an expiry date, signed with an Ed25519 key that belongs to the product. The app ships with the matching public key and checks the signature locally. No network, no secret in the binary. Anyone can read a key, nobody can forge one.
Holding those signing keys is the job of inkan. It keeps one per product, issues keys by hand and tells you what a pasted key really contains. The name fits. An inkan is the personal seal that makes a document valid in Japan.
A key looks like this, prefixed with the product:
MYAPP-AXsid-iI6MS-wiYXB-wIjoi-...
Three lines on the app side
Start the dashboard, add a product with a key prefix like MYAPP, and copy its public key into the app:
inkan # dashboard on http://127.0.0.1:3000
// The public key from the Products page. It is no secret.
const publicKey = "03a107bf…"
lic := license.NewKeeper("MYAPP", publicKey, "")
_, err := lic.Set(os.Getenv("MYAPP_LICENSE_KEY"))
ok := lic.Valid()
Valid checks the expiry on every call, so a running app notices when its key runs out. Set swaps in a new key while the app runs, which is what a settings page calls with a pasted key. The license package is a Go module of its own with no dependencies. TypeScript and Python get one file each with the same Keeper, and FORMAT.md covers every other language.
Stripe stays in charge
Keys issued by hand need nothing else. For selling, a worker sits on the public side of one product. It runs on Cloudflare Workers, Scaleway Functions or anywhere Node runs. It reads Stripe, signs a key for every paid subscription or one time purchase, mails it to the customer and hands fresh keys to apps that ask. It keeps no state. Stripe says what is paid.
The dashboard never talks to Stripe. It stays private and pulls the paid licenses from the worker once an hour. Plans come from price metadata. plan=team on a price gives its subscribers team keys, and quantity=seats signs the bought quantity into the key.
Apps that are online renew in the background:
go lic.Renew(ctx, "https://<worker>/refresh", save, nil)
It is a plain POST with the key as the body, so curl can do it too. Airgapped installs skip this and paste the key from the mail.
Once a key is out, it is out
Offline keys have a price. inkan cannot revoke a key. Deleting a license only forgets it in the dashboard, and the key works until it expires. A setting called max key days caps how long a paid key lives, so the app has to fetch a fresh one that reflects what Stripe says now. With 30 days, a refunded key keeps working for at most a month in an app that is kept from refreshing.
The other thing to take seriously is the signing key. It cannot be made again, and without it no app that ships its public key takes a new license. inkan keys prints them for the password manager.
The dashboard is one binary with SQLite and a web UI, and inkan is Apache-2.0 like the rest of the current tools.