Concepts
Offline certificates
The KR1 certificate format, the verification rules every SDK applies, key rotation and the public test vectors.
Every successful activation or validation returns a certificate: a signed, self-contained statement about one license on one machine. Your app verifies it offline with the product’s public key. The SDKs do this for you; this page is for the curious, for auditors, and for anyone writing a verifier in another language.
Format#
KR1.<base64url(payload JSON)>.<base64url(Ed25519 signature)>- base64url without padding.
- The signature covers the ASCII bytes of
KR1.<base64url(payload JSON)>: the prefix, the dot and the encoded payload. - Verify the signature on the exact string you received, then use the parsed JSON. Never re-serialize the payload to verify it.
Payload#
{
"v": 1,
"kid": "k1",
"product": "prd_7k2m9q4x1c8d3f6h0j5n",
"license": "lic_3n8f2k5m9q1x4c7d0h6j",
"plan": "pro",
"entitlements": ["export", "ocr"],
"status": "active",
"seats": 1,
"licensee": "Jane Doe",
"fingerprint": "7a1acf765768ebbc09bfbbac0878d5e729d655a4be4db7510d2188f630881134",
"paidUntil": 1798761600,
"validUntil": 1799971200,
"maxMajor": null,
"issuedAt": 1793872800,
"refreshAfter": 1793959200,
"expiresAt": 1796464800
}| Field | Meaning |
|---|---|
v | Format version, 1. |
kid | ID of the signing key (k1, k2…). |
product, license | Product and license IDs. |
plan | The plan code, not its ID. |
entitlements | Strings defined on the plan. |
status | License status at issue time. |
seats, licensee | Seats on the license and the customer’s name. |
fingerprint | The machine fingerprint this certificate is bound to. |
paidUntil, validUntil | End of the paid period, and the same plus the grace period. null for perpetual licenses. |
maxMajor | Highest app major version covered, or null. |
issuedAt | Issue time. |
refreshAfter | issuedAt + 1 day. After this, the SDK revalidates online at its next check, silently; if that fails, it keeps the cached certificate. |
expiresAt | min(validUntil, issuedAt + offlineDays × 86400). For perpetual licenses: issuedAt + offlineDays × 86400. offlineDays is the plan’s override when it has one, else the product’s value. |
Times are Unix seconds. Certificates are only issued for usable licenses: for others, the API returns an error and no certificate.
Verification rules#
All must pass, in this order:
- The prefix is
KR1and there are three dot-separated parts. kidmatches one of the public keys configured in the app, and the signature is valid.vis1andproductequals the configured product ID.fingerprintequals the fingerprint of the current machine.now < expiresAt.statusis one ofactive,past_due,canceled,perpetual.- If
maxMajoris not null, the app’s major version is<= maxMajor. - Clock rollback: the SDK stores the highest
issuedAtit has seen. Ifnow < highestIssuedAt - 86400, the result isclock_tampered.
Outcomes#
| Outcome | Meaning |
|---|---|
valid | All rules pass. |
invalid_signature | Unknown kid or bad signature. |
wrong_product | Certificate for another product. |
wrong_machine | Certificate copied from another machine. |
expired | Past expiresAt: the app needs one successful online validation. |
not_usable | The status in the certificate is not usable. |
version_not_covered | The app’s major version is above maxMajor. |
clock_tampered | The system clock is more than a day before a certificate already seen. |
malformed | Not a certificate, or a signed payload in an unknown format (v other than 1, missing field, unknown status). |
Reference implementation#
A complete verifier in plain Node.js, with node:crypto only. It passes all the test vectors.
import { createHash, createPublicKey, verify } from 'node:crypto';
const USABLE = new Set(['active', 'past_due', 'canceled', 'perpetual']);
const STATUSES = new Set([...USABLE, 'expired', 'suspended', 'refunded', 'revoked']);
export function machineFingerprint(productId, rawMachineId) {
return createHash('sha256').update(`keyrail:${productId}:${rawMachineId}`).digest('hex');
}
export function verifyCertificate(certificate, opts) {
const { productId, publicKeys, fingerprint, now, appMajor, highestIssuedAt = 0 } = opts;
// 1. Prefix and shape.
const parts = certificate.split('.');
if (parts.length !== 3 || parts[0] !== 'KR1') return 'malformed';
let payload;
try {
payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
} catch {
return 'malformed';
}
if (payload === null || typeof payload !== 'object') return 'malformed';
// 2. Known kid and valid signature over the exact received bytes "KR1.<payload>".
const x = publicKeys[payload.kid];
if (typeof x !== 'string') return 'invalid_signature';
const key = createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x }, format: 'jwk' });
const signed = Buffer.from(`${parts[0]}.${parts[1]}`, 'ascii');
if (!verify(null, signed, key, Buffer.from(parts[2], 'base64url'))) return 'invalid_signature';
// 3 to 8: the payload is now trusted. A signed payload in an unknown format is malformed.
const ints = ['seats', 'issuedAt', 'refreshAfter', 'expiresAt'];
if (payload.v !== 1 || !STATUSES.has(payload.status) || !ints.every((f) => Number.isSafeInteger(payload[f]))) {
return 'malformed';
}
if (payload.product !== productId) return 'wrong_product';
if (payload.fingerprint !== fingerprint) return 'wrong_machine';
if (!(now < payload.expiresAt)) return 'expired';
if (!USABLE.has(payload.status)) return 'not_usable';
if (payload.maxMajor != null && appMajor > payload.maxMajor) return 'version_not_covered';
if (now < highestIssuedAt - 86400) return 'clock_tampered';
return 'valid';
}publicKeys maps each kid to its public key: the base64url encoding of the raw 32-byte Ed25519 key, as returned by GET /v1/products/{id}/keys.
Key rotation#
Each product starts with one signing key, k1. A rotation has two phases, so that the new public key reaches your users' machines before any certificate is signed with it:
- Create the next key (
POST /v1/products/{id}/keys, or Rotate key on the product page of the dashboard). The new key (k2) is pending: it is published byGET /v1/products/{id}/keyswith"status": "pending", but it signs nothing.k1keeps signing every certificate. Add the public key ofk2to your app, keepingk1:{ k1: "…", k2: "…" }, and ship that update. - Activate it once the update is in your users' hands (
POST /v1/products/{id}/keys/k2/activate, or Activate k2 in the dashboard). From then on every certificate the service issues, at activation and at refresh, is signed withk2.k1becomes retired: it stays listed and certificates it signed stay verifiable, but it never signs again.
There is at most one pending key at a time; activation cannot be undone and a retired key cannot be activated again. Key states: pending (published, not signing), active (the signing key), retired (verify only).
An app version that does not embed the active key rejects its certificates with invalid_signature. After the activation, on such a version:
- New activations fail. The service records the activation, but the SDK rejects the certificate and does not store it: every SDK fails with the code
invalid_certificate(Node:activate()throws aKeyrailApiError; Rust:Err(LicenseError::InvalidCertificate(_)); Tauri: rejects with{ code: "invalid_certificate" }). - Machines already activated keep working for a while. At each refresh the SDK receives a certificate it cannot verify, ignores it, and keeps using its cached
k1certificate (offline: true). Access ends when that cached certificate expires: at mostofflineDays(30 by default, or the longest plan override) after its last refresh before the activation. From then on,check()reportsexpireduntil the app is updated. - Definitive refusals still apply: a revoked or refunded license is refused by the service, whatever the key.
Rotate for a suspected compromise or a planned migration. The procedure:
- Make sure your app reads its public keys as a map (
kid→ key), so adding one is a one-line change. - Create the pending key and copy its public key from the dashboard (the SDK snippets on the product page already include it) or from
GET /v1/products/{id}/keys. - Ship a release with every key:
{ k1: "…", k2: "…" }. - Wait until most of your users run it (your update channel tells you), then activate
k2. Older versions cannot activate new machines any more and lose access withinofflineDays. - After a compromise, activate as soon as the update is out, and drop
k1from a later release once every certificate signed with it has expired (offlineDaysafter the activation).
Private keys are stored encrypted with AES-256-GCM. The only way to get them out is the owner-only export below; they never appear in lists, in the account export or in logs.
Signing without Keyrail#
You should never have to depend on Keyrail being there. The account owner can download the private signing keys of a product from the product page of the dashboard (Export private keys, or POST /v1/products/{id}/keys/export with the owner's dashboard session; never with an API key). The file lists every key of the product, pending, active and retired:
{
"format": "keyrail-signing-keys-v1",
"exportedAt": "2026-10-13T09:00:00Z",
"product": { "id": "prd_7k2m9q4x1c8d3f6h0j5n", "name": "Nimbo PDF", "keyPrefix": "NIMBO" },
"certificateFormat": "KR1",
"keys": [
{ "kid": "k1", "status": "active",
"publicKey": "pZ3vC0x6hYjQ8m1K4tR9wN2bF7sL5dA0eUqTgHiOcVk",
"privateKey": "<base64url, raw 32-byte Ed25519 seed>",
"privateKeyPkcs8": "<base64, PKCS#8 DER>",
"createdAt": "2026-10-13T09:00:00Z", "activatedAt": "2026-10-13T09:00:00Z" }
]
}privateKey is the raw seed (what ed25519-dalek, libsodium or PyNaCl take); privateKeyPkcs8 is the same key as PKCS#8 (what WebCrypto, Node's crypto.createPrivateKey and OpenSSL take). With either one you can sign certificates in the format above with the reference implementation or any Ed25519 library: your app, unchanged, verifies them with the public key it already embeds. The SDKs take any API base URL, so an app update can point to your own issuer.
Two things to know:
- Anyone holding this file can issue licenses for your product. Keep it in a vault, never in a repository, a ticket or an e-mail. Each export is written to your audit log (
signing_key.exported), and the route is limited to 10 downloads per hour. - Export when you create the product, not when you need it: the file is your insurance against Keyrail disappearing, and it does not depend on anyone at Keyrail answering. Keyrail keeps signing as usual after an export; if you suspect the file leaked, rotate the key.
What Keyrail commits to if the service is ever discontinued is on the security page and in the terms of service.
Test vectors#
test-vectors.json contains certificates signed with a test-only key pair (the RFC 8032 test 1 seed: never use it in production) and the outcome every verifier must produce. The Rust and Node SDKs and the service’s own tests run against it.
- Product ID:
prd_testvectors0000000000 - Public key
k1:11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo - Raw machine ID:
4c4c4544-0042-3510-8048-b4c04f4e3732, fingerprint7a1acf765768ebbc09bfbbac0878d5e729d655a4be4db7510d2188f630881134
Each case gives a certificate, the now to use, the app’s major version and, for the clock case, highestIssuedAt.
| Case | Expected outcome | now | App major |
|---|---|---|---|
| valid | valid | 1793876400 | 1 |
| expired | expired | 1796464801 | 1 |
| tampered payload | invalid_signature | 1793876400 | 1 |
| unknown kid | invalid_signature | 1793876400 | 1 |
| wrong product | wrong_product | 1793876400 | 1 |
| wrong machine | wrong_machine | 1793876400 | 1 |
| revoked status | not_usable | 1793876400 | 1 |
| perpetual v1 used by app v2 | version_not_covered | 1793876400 | 2 |
| perpetual v1 used by app v1 | valid | 1793876400 | 1 |
| clock rolled back | clock_tampered | 1793700000 | 1 |
| malformed | malformed | 1793872800 | 1 |
| bad prefix | malformed | 1793876400 | 1 |
The file also contains a license key normalization case (input, normalized key and SHA-256), for implementations of the key lookup.