Signet
Signet sells software licenses without a licensing service. It signs offline-verifiable license keys with Ed25519, and runs a single-binary web shop that sells them through Stripe Checkout. Your apps ship only the public key and verify locally; nothing phones home.
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. There’s no license server to run, no runtime dependency, and 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.
The workspace is three crates:
signetlib— the shared library: license claims, Ed25519 signing, and the wire format. Depend on it (or copy the decoder) to check licenses in your app.signet-issuer— an offline CLI: generate per-product keypairs, issue license blobs, verify and inspect them.signet-shop— a single-binary web shop: a storefront, Stripe Checkout, and idempotent license fulfillment, all configured from files.
These docs are split by what you’re here to do:
- Operator guide — issuing licenses and running the shop.
- Integration guide — verifying licenses in your app with
signetlib.
The source lives on GitHub. Signet is MIT-licensed.
Operator Guide
Running Signet has two sides: issuing licenses offline with signet-issuer, and (optionally) selling them with signet-shop. You can use the issuer on its own and hand licenses out however you like; the shop is there when you want Stripe to do the selling and fulfillment for you.
Keys and the trust model
Each product line gets its own Ed25519 keypair. There are two keys per product:
- A root key (
keys/<product>/private.bin+public.bin). Keep the private half offline; it’s the ultimate authority for that product. The app that consumes licenses bakes in the public half. - A web key (
keys/<product>/web-private.bin+web-public.bin). This is the key the shop signs with, so it can live on the server. Because it’s separate from the root key, you can rotate it without touching the root of trust: an app that embeds both public keys keeps verifying old and new licenses through a rotation.
Keys and the ledger are written relative to the current working directory, so run the issuer from a consistent location (an operator’s laptop or an air-gapped box).
Issuing licenses (signet-issuer)
signet-issuer is an offline CLI with three subcommands: keygen, issue, and verify.
keygen
Generate a keypair for a product:
signet-issuer keygen --product acme # root keypair
signet-issuer keygen --product acme --web # the shop's web keypair
signet-issuer keygen --product acme --force # overwrite existing keys
--productis required.--webwrites the revocable web keypair instead of the root pair.--forceis required to overwrite; without it, keygen refuses to clobber existing keys. Private keys are written0600.
issue
Sign a license, print the base64 blob to stdout, and append a row to the ledger:
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
Required: --product, --customer, --email, --tier. Optional: --expires YYYY-MM-DD (omit for a lifetime license; the date is taken as end-of-day UTC), --feature (repeatable, one per feature flag), --max-orgs, --max-seats (omit either for unlimited), --private-key (defaults to keys/<product>/private.bin), --ledger (defaults to ledger/<product>.jsonl), and --note (recorded in the ledger only).
The blob is the only thing on stdout; the human-readable summary goes to stderr, so signet-issuer issue ... > license.txt captures just the license. --tier is a free-form marketing label; what an app actually unlocks is driven by --feature, not the tier string.
The ledger (ledger/<product>.jsonl) is your record of what was issued: one JSON line per license with the id, customer, email, tier, expiry, features, limits, and note.
verify
Verify a blob against a public key and print its claims as JSON (this doubles as inspect):
signet-issuer verify --product acme "<blob>"
echo "<blob>" | signet-issuer verify --product acme -
--public-key defaults to keys/<product>/public.bin. Pass - as the blob to read it from stdin. Verification failure exits with an error.
Running the shop (signet-shop)
signet-shop is a single binary. It needs the web signing key for every product line in your catalog: at startup it loads <KEYS_DIR>/<category>/web-private.bin for each category and refuses to start if one is missing, so run signet-issuer keygen --product <cat> --web for each first.
Configuration: environment
The shop reads its runtime configuration from the environment:
Required:
STRIPE_API_KEY— your Stripe secret key.STRIPE_WEBHOOK_SECRET— the signing secret for the webhook endpoint.DATABASE_URL— SQLite database URL (fulfillment records live here).BASE_URL— the public URL of the shop. Must behttps://, excepthttp://localhost/http://127.0.0.1are allowed for local runs.
Optional (with defaults):
KEYS_DIR(default./keys)CONTENT_DIR(default./content)CATALOG_PATH(default./catalog.toml)BIND_ADDR(default127.0.0.1:8080)TRUST_PROXY(default off) — set to1/trueonly when the shop sits behind a trusted reverse proxy, so the rate limiter reads the client IP fromX-Forwarded-For. Never enable it when the shop is directly exposed, or clients can spoof their IP.
catalog.toml
The catalog defines what’s for sale. Its main sections:
[shop]—title, and an optionalpayment_noticeshown in the footer.[[category]]— a product line.id(a lowercase slug, unique, and not one of the reserved namescheckout/success/webhook/static),name, optionaldescriptionandurl. Theidselects the signing key and becomes the license’sproductclaim.[[sku]]— a purchasable plan.id,category(matching a[[category]]id),display_name,amount_cents(> 0),currency(lowercase ISO, e.g.eur),tier, andterm(lifetimeor<days>d). Optionaldescription,url,price_label,features(list),max_orgs,max_seats.stripe_price_idis filled in for you byprovision-stripe.[[page]]— a content page served at/p/<slug>fromcontent/<slug>.md; setfooter = trueto link it in the footer.[[footer_link]]— an extra footer link (title,url).[analytics]— an optional analytics script (srcmust behttps://).
See catalog.example.toml for a complete, commented example.
Content
Markdown pages live in CONTENT_DIR as content/<slug>.md and render per request, so you can edit them without a restart. The starter set (content.example/) includes terms.md and privacy.md, served at /p/terms and /p/privacy.
Stripe
Once your SKUs have amounts and currencies, provision them in Stripe:
signet-shop provision-stripe
This creates or updates the Stripe products and one-time prices for each SKU and writes the resolved stripe_price_id back into catalog.toml (preserving your comments). It’s idempotent: unchanged SKUs are left alone, and a changed amount archives the old price and writes a new one.
At runtime the shop exposes a Stripe webhook at POST /webhook (configure this URL in your Stripe dashboard, pointing at BASE_URL/webhook). On a completed checkout it mints the license with that category’s web key and stores it, keyed on the Stripe session id so fulfillment is idempotent (the webhook and the success page can’t double-issue). Before minting it cross-checks the payment: live/test mode must match your key, and the amount and currency must match the SKU, otherwise it holds off rather than issuing.
Email (optional)
If you want buyers emailed their license (and yourself notified of sales), add an [email] table to catalog.toml or set SIGNET_EMAIL__* environment variables (env wins). It supports a lettermint provider (a token) or smtp (host/port/tls/user/pass), plus the sender and operator addresses. With no provider configured, email is simply off; send failures are logged, not fatal.
Running and deployment
signet-shop with no argument (or serve) starts the server on BIND_ADDR. A prebuilt container is published at ghcr.io/franzos/signet: mount your catalog, content, and keys, set the environment above, and put it behind a reverse proxy that terminates TLS (and set TRUST_PROXY=1 there).
Integration Guide
For developers adding license checks to an app that Signet issues licenses for. Your app ships the public key and verifies licenses locally: no license server, no runtime dependency on Signet, no network call. This guide covers verifying a license blob and reading its claims. For issuing licenses and running the shop, see the operator guide.
How it fits together
A license is a small base64 blob carrying signed claims (who it’s for, which product line, which features, when it expires). Each product line has its own Ed25519 keypair. You bake the public half into your app; verification is a local signature check against that key.
The key is the real gate. Because each product signs with a distinct key, a license for one product will not verify against another product’s key. The product claim inside the license is defense in depth, not the boundary, so your app only ever holds and checks its own product’s key.
Adding signetlib
Depend on signetlib and ed25519-dalek v2 (you construct the verifying key with the latter):
[dependencies]
signetlib = { git = "https://github.com/franzos/signet" } # or a path / published version
ed25519-dalek = "2"
If you’d rather not take the dependency, the wire format is small enough to reimplement (see the end of this guide), but signetlib is the supported path.
Getting the public key into your app
signet-issuer keygen writes the public key as a raw 32-byte file. The simplest thing is to embed it at compile time:
#![allow(unused)]
fn main() {
use ed25519_dalek::VerifyingKey;
// keys/acme/public.bin from `signet-issuer keygen --product acme`
const PUBLIC_KEY: &[u8; 32] = include_bytes!("../keys/acme/public.bin");
fn verifying_key() -> VerifyingKey {
VerifyingKey::from_bytes(PUBLIC_KEY).expect("valid public key")
}
}
If you’d rather load it from a file at runtime, signetlib::codec::load_verifying_key(path) reads the same 32-byte format.
Verifying a blob
decode_and_verify checks the signature and returns the claims:
#![allow(unused)]
fn main() {
use signetlib::claims::Claims;
use signetlib::codec::{decode_and_verify, DecodeError};
fn verify(blob: &str) -> Result<Claims, DecodeError> {
decode_and_verify(blob.trim(), &verifying_key())
}
}
DecodeError has two cases, and both mean “don’t trust this license”: Malformed(..) (not a valid blob: bad base64, wrong format, corrupt) and BadSignature (well-formed, but no key verified it). Trim the input; whitespace around a pasted blob is common.
The claims you get back:
#![allow(unused)]
fn main() {
pub struct Claims {
pub v: u8, // schema version
pub license_id: String, // correlates with the issuer's ledger
pub customer: String,
pub email: String,
pub tier: String, // free-form marketing label
pub product: String, // product line id
pub issued_at: i64, // Unix seconds
pub expires_at: Option<i64>, // Unix seconds; None = lifetime
pub features: Vec<String>, // the flags to gate on
pub max_orgs: Option<u32>, // None = unlimited
pub max_seats: Option<u32>, // None = unlimited
pub note: String, // ledger-only
}
}
Checking expiry and features
signetlib verifies the signature; deciding what a valid license grants is up to you. Claims is plain data with no helper methods, so check the fields directly:
#![allow(unused)]
fn main() {
fn now_unix() -> i64 { /* your clock, in seconds */ }
let claims = verify(blob)?;
// Expiry: None means lifetime.
let expired = claims.expires_at.is_some_and(|exp| now_unix() >= exp);
// Gate on features and limits.
let has_orgs = claims.features.iter().any(|f| f == "orgs");
let seat_cap = claims.max_seats; // None = unlimited
}
Gate on features (and max_orgs / max_seats), not on tier: the tier string is a label, while the features are what the issuer actually granted.
Key rotation
The two-key setup (an offline root key, a server-side web key the shop signs with) lets you rotate the web key without reissuing every license. To stay verifiable across a rotation, embed both public keys and accept a match from either:
#![allow(unused)]
fn main() {
use signetlib::codec::decode_and_verify_any;
const ROOT_PUB: &[u8; 32] = include_bytes!("../keys/acme/public.bin");
const WEB_PUB: &[u8; 32] = include_bytes!("../keys/acme/web-public.bin");
let keys = [
VerifyingKey::from_bytes(ROOT_PUB).expect("valid key"),
VerifyingKey::from_bytes(WEB_PUB).expect("valid key"),
];
let claims = decode_and_verify_any(blob.trim(), &keys)?;
}
Note there’s no revocation: a signed license is valid until it expires. To cut one off early you rotate the signing key and reissue, so plan expiries accordingly.
Wire format (if you’re not using signetlib)
You don’t need this if you use signetlib, but for a reimplementation in another language: a blob is base64_standard( "OPLB" + version_byte(1) + CBOR(envelope) ), where the envelope is { c: <CBOR of the claims>, s: <64-byte Ed25519 signature> }. The signature is over the exact CBOR claim bytes as they appear in the blob (so map ordering can’t break verification), and unknown claim fields are ignored for forward compatibility. Verify the signature first, then decode the claims.