Sell software licenses without a licensing service. Sign offline-verifiable license keys with Ed25519, and run a single-binary web shop that sells them through Stripe Checkout. Your apps ship only the public key and verify locally; nothing phones home.
I wrote Signet to sell licenses for my own self-hosted apps, then realized the machinery is generic: it doesn't know or care what your product is. A license is a small signed blob carrying who bought it, which product line, which features, and when it expires. The app that consumes it needs a few lines to verify a signature. That's the whole idea, and it turns out that's most of what a small software business needs.
There's no license server to run, no runtime dependency, no per-check network call. You keep a signing key offline, your app bakes in the matching public key, and verification is a local Ed25519 check. If you want to sell the licenses too, the shop is a single binary you point at Stripe.
A Cargo workspace with three crates:
| Crate | What it is |
|---|---|
signetlib |
The shared library: license claims, Ed25519 signing, and the wire format. Depend on it (or copy the decoder into your app) to check licenses. |
signet-issuer |
An offline CLI: generate per-product keypairs, issue license blobs, verify/inspect them. Runs on an air-gapped box if you like. |
signet-shop |
A single-binary web shop: a storefront, Stripe Checkout, and idempotent license fulfillment, all configured from files. |
Two words for one thing: signetlib and the CLI call a product line a product; the shop's catalog calls it a category. A category id selects the signing key and is stamped into the license as its product claim. So a purchase in one product line only ever verifies against that line's public key, never another's.
Full documentation is published at https://franzos.github.io/signet/: the operator guide (issuing licenses and running the shop) and the integration guide (verifying licenses in your app).
signet-issuer keygen --product acme
# copy keys/acme/public.bin into your app (e.g. src/commercial/pubkey.bin), rebuild
The public half is what you ship in the app; the private half signs licenses and never leaves your keychain.
If you run the shop, generate a second, revocable keypair for it:
signet-issuer keygen --product acme --web
# copy keys/acme/web-public.bin into your app alongside public.bin
The shop process loads web-private.bin; the root private.bin stays offline. Your app's verifier accepts both public keys (via decode_and_verify_any), so if the shop host is ever compromised you rotate the web key and re-issue only web-sold licenses, while root-signed licenses keep working.
signet-issuer issue \
--product acme \
--customer "Acme GmbH" \
--email admin@acme.example \
--tier business \
--expires 2027-07-05 \
--feature orgs --max-orgs 50 \
--feature saml
Prints the base64 blob to stdout (paste it into your app's license screen) and appends a row to ledger/<product>.jsonl. Omit --expires for a lifetime license. Repeat --feature once per gated feature your app recognizes; --feature names and the optional numeric caps are yours to define.
echo "<blob>" | signet-issuer verify --product acme -
keys/acme/{private,public}.bin ledger/acme.jsonl
keys/acme/web-{private,public}.bin (shop signing key, optional)
keys/globex/{private,public}.bin ledger/globex.jsonl
Back up keys/ offline. Rotating a product's root key invalidates every license already issued for that product (there's no overlap window), so plan to re-issue if you rotate. The web key is the exception: since verifiers accept both keys, it can be rotated without touching root-signed licenses.
A single binary that serves a storefront, sends buyers to Stripe Checkout, and issues plus tracks licenses. It reuses signetlib for signing, so a license bought from the shop is byte-for-byte the same shape as a CLI-issued one.
Everything site-specific lives in files, not the binary, so one build runs any shop:
catalog.toml(per-site, git-ignored; path viaCATALOG_PATH, default./catalog.toml) - shop title,[[category]]product lines,[[sku]]purchasable plans,[[page]]/[[footer_link]]footer entries, and optional[analytics]. Start fromcatalog.example.toml.content/<slug>.md(per-site, git-ignored; path viaCONTENT_DIR, default./content) - markdown for Terms, Privacy, and the like, served at/p/<slug>and read per request so edits need no restart. Starters incontent.example/.keys/<category>/web-private.bin- the shop's signing key for each category, fromkeygen --webabove (mount it; the rootprivate.binstays offline).
Environment for serve: STRIPE_API_KEY, STRIPE_WEBHOOK_SECRET, DATABASE_URL (SQLite), BASE_URL, and optional KEYS_DIR, CONTENT_DIR, CATALOG_PATH, BIND_ADDR (default 127.0.0.1:8080).
Email on purchase (optional, off by default): configure a provider to email the buyer their license and notify the operator of each sale. Settings live in an [email] table in catalog.toml (built on polymail's ProviderConfig); every field is overridable by SIGNET_EMAIL__<FIELD> env vars, and env always wins, so keep secrets blank in the file and inject them at runtime. Set from_address (and optional from_name), operator_address (optional operator_name) for the sale notice, and one provider: lettermint with token, or smtp with host/port/tls (none|start_tls|implicit)/user/pass. Both providers are compiled in; pick one per instance. Omit the section (and set no SIGNET_EMAIL__PROVIDER) or set enabled = false to disable. See catalog.example.toml for the full shape.
Define categories and SKUs in catalog.toml, then:
STRIPE_API_KEY=sk_... signet-shop provision-stripe
It creates (or reuses) a Stripe Product + Price per SKU and writes each resolved stripe_price_id back into catalog.toml (comments preserved). It's idempotent; changing a SKU's amount_cents supersedes the price (archiving the old one) and rewrites the id on the next run.
signet-shop serve
The buy flow: a Buy button POSTs to /checkout, which redirects to Stripe's hosted Checkout. Stripe collects the buyer's email and a required "Company name" custom field, both embedded in the issued license. After payment the buyer lands on /success, which queries Stripe directly and mints the license, so it works even without a webhook; a /success/download link serves the license as a file. The /webhook endpoint (verified with STRIPE_WEBHOOK_SECRET) is the fallback that fulfills when the buyer never returns, or for delayed payment methods. Fulfillment is idempotent, keyed on the Stripe session id, so a license mints exactly once. Issued licenses are recorded in SQLite for tracking and reissue. When email is configured, the buyer is emailed their license (with expiry) and the operator gets a sale notice, sent once on the fresh mint and off the request path; a mail failure is logged and never blocks or reverses license delivery.
The UI is self-hosted (strict CSP), with a system/light/dark theme toggle and a configurable footer (content pages, links, and a payment-data notice). A configured [analytics] host is added to the CSP automatically.
A prebuilt image is published to the GitHub Container Registry on every tag:
docker run --rm -p 8080:8080 \
-e STRIPE_API_KEY=sk_... \
-e STRIPE_WEBHOOK_SECRET=whsec_... \
-e BASE_URL=https://shop.example \
-e DATABASE_URL=sqlite:///data/signet.db \
-v "$PWD/catalog.toml:/app/catalog.toml:ro" \
-v "$PWD/content:/app/content:ro" \
-v "$PWD/keys:/app/keys:ro" \
-v signet-data:/data \
ghcr.io/franzos/signet serve
The container runs as uid 10001, not root. Bind mounts must be readable by that uid; keygen writes private keys 0600 owned by you, so chown 10001 keys/*/web-private.bin (or copy the web keys into a dir the container user can read) before starting. The signet-data volume is owned by the right uid automatically on first use.
Your app needs the public key(s) and a signature check. Add the library with cargo add signetlib; its codec::decode_and_verify_any does it in one call, and the claims are a plain struct you match on. Embed both the root and web public keys so either signer verifies. The gist:
let claims = signetlib::codec::decode_and_verify_any(&blob, &[root_key, web_key])?;
if claims.expired(now) { /* grace / lock */ }
for feature in &claims.features { /* unlock */ }
Unknown feature strings and unknown claim fields are ignored on decode, so a newer issuer can add features an older binary hasn't learned yet without breaking verification. The product claim is defense-in-depth: the per-product signing key is the real gate, so a blob signed for one product simply won't verify against another's public key.
Each blob is base64(OPLB || v1 || cbor(SignedBlob)), where SignedBlob carries the canonical CBOR-encoded claims plus an Ed25519 signature over those exact claim bytes. The OPLB magic and version byte let a verifier reject obvious garbage before spending CBOR-decode work, and keep future format changes detectable. The signature is checked against the observed claim bytes (not a re-serialized copy), so CBOR map-ordering differences across implementations can't break verification.
Rust stable, standard Cargo:
cargo build --release # both binaries
cargo test --workspace
The shop links a bundled SQLite (needs a C toolchain) and talks to Stripe over rustls (no OpenSSL, no libpq).
MIT. See LICENSE. Copyright (c) 2026 Franz Geffke.

