@buildproven/license-core
v1.0.2
Published
Shared license signing & verification primitives for BuildProven products (RSA-SHA256, signed registry).
Maintainers
Readme
@buildproven/license-core
Tiny, frozen-contract license signing & verification primitives. Deterministic JSON, RSA-SHA256, signed registry. Use it to issue and verify software licenses without running a license server on the hot path — clients fetch a signed JSON registry, verify it locally with a bundled public key, and decide.
npm install @buildproven/license-coreWhy this exists
If you're shipping a desktop app, CLI tool, or developer plugin and want to sell licenses, you need:
- A way to sign a license entry on your server (when a customer pays)
- A way for the client to verify that signature locally — without making a network call on every launch
That's what this is. ~14 functions, no runtime dependencies. Sign on the server, distribute a signed registry as a static JSON file, verify on the client. Works offline, no license server to keep alive, no per-call latency.
Quick example
Server side (when a customer purchases)
import {
buildLicensePayload,
buildSignedRegistry,
hashEmail,
signPayload,
type Registry,
} from '@buildproven/license-core';
const PRIVATE_KEY = process.env.LICENSE_PRIVATE_KEY!; // PEM, RSA 2048+
const issued = new Date().toISOString();
const payload = buildLicensePayload({
licenseKey: 'MYAPP-A1B2-C3D4-E5F6-7890',
tier: 'PRO',
isFounder: false,
issued,
emailHash: hashEmail('[email protected]') ?? undefined,
});
const entry = {
tier: 'PRO' as const,
isFounder: false,
issued,
emailHash: hashEmail('[email protected]'),
signature: signPayload(payload, PRIVATE_KEY),
customerId: 'cus_abc123',
keyId: 'default',
};
const registry: Registry = { 'MYAPP-A1B2-C3D4-E5F6-7890': entry };
const signedRegistry = buildSignedRegistry(registry, PRIVATE_KEY);
// Serve `signedRegistry` as JSON at e.g. https://yoursite.com/api/licenses.jsonClient side (when the user starts your app)
import {
validateRegistryEntry,
verifyRegistryMetadata,
hashEmail,
isValidLicenseKey,
} from '@buildproven/license-core';
const PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
...
-----END PUBLIC KEY-----`; // bundled with your app
const userKey = process.env.MYAPP_LICENSE_KEY ?? '';
const userEmail = '...'; // from your activation flow
if (!isValidLicenseKey(userKey, 'MYAPP')) {
throw new Error('Bad key format');
}
const res = await fetch('https://yoursite.com/api/licenses.json');
const signedRegistry = await res.json();
const entries = verifyRegistryMetadata(signedRegistry, PUBLIC_KEY); // throws on tampering
const entry = entries[userKey.toUpperCase()];
if (!entry) throw new Error('License not found');
const result = validateRegistryEntry({
licenseKey: userKey.toUpperCase(),
entry,
publicKeyPem: PUBLIC_KEY,
userEmailHash: hashEmail(userEmail) ?? undefined,
});
if (!result.valid) throw new Error(result.error);
console.log(`Licensed: ${result.tier}, Founder: ${result.isFounder}`);API reference
Crypto primitives
stableStringify(value)— deterministic JSON stringify (sorted keys, circular-ref detection)signPayload(payload, privateKeyPem)— RSA-SHA256 sign, returns base64verifyPayload(payload, signature, publicKeyPem)— RSA-SHA256 verify, returns booleancomputeHash(data)— SHA-256 hextimingSafeStringEqual(a, b)— constant-time string comparison
Payload construction
normalizeEmail(email)— trim + lowercase + format-validate, ornullhashEmail(email)— SHA-256 hex of normalized email, ornullbuildLicensePayload({ licenseKey, tier, isFounder, issued, emailHash? })— the frozen payload shape
Registry
buildSignedRegistry(entries, privateKeyPem, keyId?)— wraps entries with_metadatacontaining the registry signature and hash
Validation helpers (pure — no I/O)
validateRegistryEntry({ licenseKey, entry, publicKeyPem, userEmailHash? })— verifies one entry's signature and optional email matchverifyRegistryMetadata(signedRegistry, publicKeyPem)— verifies the registry-level signature and hash, returns extracted entries (throws on failure)
Key format
licenseKeyPattern(prefix)— RegExp forPREFIX-XXXX-XXXX-XXXX-XXXXisValidLicenseKey(key, prefix)— booleannormalizeLicenseKey(key)— trim + uppercase
Types
Tier, LicensePayload, RegistryEntry, Registry, RegistryMetadata, SignedRegistry, ValidatedEntry, ValidationFailure, ValidationResult
Frozen contract
The shape of LicensePayload and RegistryEntry is frozen for the entire 1.x line. Shipped clients in the field rebuild these payloads from registry entries to verify signatures — adding a field would silently break verification for every existing customer.
To evolve the schema: bump major and ship as a new package name. The 1.x line is the contract for already-deployed clients.
How keys work
You generate one RSA-2048 keypair per product:
openssl genpkey -algorithm RSA -out private.pem -pkeyopt rsa_keygen_bits:2048
openssl rsa -in private.pem -pubout -out public.pem- Private key lives in your fulfillment server's env vars. It signs license entries.
- Public key is bundled with your client. It verifies signatures.
The package never handles key generation, storage, or rotation — that's your call. Use whatever secret manager you already have.
Rotating a signing key
Every entry in the registry has a keyId field, and _metadata.keyId records which key signed the registry as a whole. To rotate without breaking already-shipped clients:
Generate a new keypair. Add the new private key to your fulfillment env vars (e.g.
LICENSE_PRIVATE_KEY_V2).Update fulfillment to sign new entries with the new key, passing
keyId: 'v2':buildSignedRegistry(entries, NEW_PRIVATE_KEY, 'v2');Bundle BOTH public keys with your client. When verifying, look up the right key based on
entry.keyId:const publicKeys = { default: OLD_PUBLIC_KEY, v2: NEW_PUBLIC_KEY }; const result = validateRegistryEntry({ licenseKey, entry, publicKeyPem: publicKeys[entry.keyId] ?? publicKeys.default, });Existing entries (signed with
keyId: 'default') continue to verify under the old public key. New entries verify under the new key.Once all old entries have expired or been re-issued, you can ship a client release that drops the old public key.
The package leaves keyId as a free-form string, so use whatever convention you like (v1/v2, dates, fingerprints).
Recurring billing & subscriptions
The frozen-contract v1.x API doesn't include expiresAt on payloads, so subscriptions need a layer above the package. Two patterns work:
Pattern A — short-lived registries (re-sign on a schedule)
Your fulfillment service holds the source-of-truth subscription state (Stripe, paddle, whatever). On a cron/interval (e.g. once an hour), it walks active customers, builds a fresh Registry, calls buildSignedRegistry, and serves the result. Cancelled customers simply stop appearing in the next signed registry.
The client refreshes the registry once per launch + once per N hours/days, and falls back to the locally-cached signed JSON if offline. A 7-day grace period (registry valid 7 days offline) is typical — long enough that flaky internet doesn't lock paying customers out, short enough that cancellations propagate within a week.
// Client side
async function getRegistry() {
try {
const res = await fetch(REGISTRY_URL, { signal: AbortSignal.timeout(5000) });
const signed = await res.json();
const entries = verifyRegistryMetadata(signed, PUBLIC_KEY);
await fs.writeFile(CACHE_PATH, JSON.stringify(signed)); // cache for offline
return entries;
} catch {
const cached = JSON.parse(await fs.readFile(CACHE_PATH, 'utf8'));
return verifyRegistryMetadata(cached, PUBLIC_KEY); // offline path
}
}Pattern B — long-lived entries + revocation list
Entries are signed once and stay valid forever. A separate signed revoked.json lists keys that should no longer verify. The client fetches both and rejects any license that appears in the revocations file.
This is faster on the fulfillment side (you don't re-sign the whole registry every hour) but requires more client logic. Tracked in the v2.x roadmap because v1.x doesn't ship a revocation helper yet.
Which pattern when?
- Few customers, frequent cancellations: Pattern A — re-signing daily is cheap.
- Many customers, rare cancellations: Pattern B — appending to a small revocations list is cheaper than re-signing thousands of entries.
- Lifetime licenses only: neither — sign once on purchase, never re-sign.
Publishing your own fork (Trusted Publishing setup)
If you fork this and want to publish under your own scope via npm Trusted Publishing (no NPM_TOKEN), there's one gotcha worth documenting because it cost an hour during the initial release here:
Don't pass registry-url to actions/setup-node. It auto-generates an .npmrc with a placeholder ${NODE_AUTH_TOKEN} value, which makes npm publish authenticate via that fake token instead of falling through to OIDC. Result: a 404 Not Found from the registry that looks like a misconfigured trusted publisher.
The minimal working workflow:
name: Publish to npm
on:
push:
tags: ['v*.*.*']
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # required for OIDC
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
# NO registry-url here — it would inject a fake NODE_AUTH_TOKEN
- run: npm install -g npm@latest # need >=11.5.1 for Trusted Publishing
- run: npm ci
- run: npm test
- run: npm publish --access public --provenanceOn the npm side, configure the trusted publisher under your package's settings page after the package exists (chicken-and-egg: do the first publish via a granular access token, then switch to OIDC for everything after).
License
MIT © Vibe Build Lab LLC
Contributing
See CONTRIBUTING.md. The 1.x line has a frozen contract; most contributions should be bug fixes, doc improvements, or test coverage.
Security
See SECURITY.md for the threat model and how to report vulnerabilities.
