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#

text
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#

json
{
  "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
}
FieldMeaning
vFormat version, 1.
kidID of the signing key (k1, k2…).
product, licenseProduct and license IDs.
planThe plan code, not its ID.
entitlementsStrings defined on the plan.
statusLicense status at issue time.
seats, licenseeSeats on the license and the customer’s name.
fingerprintThe machine fingerprint this certificate is bound to.
paidUntil, validUntilEnd of the paid period, and the same plus the grace period. null for perpetual licenses.
maxMajorHighest app major version covered, or null.
issuedAtIssue time.
refreshAfterissuedAt + 1 day. After this, the SDK revalidates online at its next check, silently; if that fails, it keeps the cached certificate.
expiresAtmin(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:

  1. The prefix is KR1 and there are three dot-separated parts.
  2. kid matches one of the public keys configured in the app, and the signature is valid.
  3. v is 1 and product equals the configured product ID.
  4. fingerprint equals the fingerprint of the current machine.
  5. now < expiresAt.
  6. status is one of active, past_due, canceled, perpetual.
  7. If maxMajor is not null, the app’s major version is <= maxMajor.
  8. Clock rollback: the SDK stores the highest issuedAt it has seen. If now < highestIssuedAt - 86400, the result is clock_tampered.

Outcomes#

OutcomeMeaning
validAll rules pass.
invalid_signatureUnknown kid or bad signature.
wrong_productCertificate for another product.
wrong_machineCertificate copied from another machine.
expiredPast expiresAt: the app needs one successful online validation.
not_usableThe status in the certificate is not usable.
version_not_coveredThe app’s major version is above maxMajor.
clock_tamperedThe system clock is more than a day before a certificate already seen.
malformedNot 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.

verify.mjs
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:

  1. 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 by GET /v1/products/{id}/keys with "status": "pending", but it signs nothing. k1 keeps signing every certificate. Add the public key of k2 to your app, keeping k1: { k1: "…", k2: "…" }, and ship that update.
  2. 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 with k2. k1 becomes 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 a KeyrailApiError; 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 k1 certificate (offline: true). Access ends when that cached certificate expires: at most offlineDays (30 by default, or the longest plan override) after its last refresh before the activation. From then on, check() reports expired until 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:

  1. Make sure your app reads its public keys as a map (kid → key), so adding one is a one-line change.
  2. 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.
  3. Ship a release with every key: { k1: "…", k2: "…" }.
  4. 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 within offlineDays.
  5. After a compromise, activate as soon as the update is out, and drop k1 from a later release once every certificate signed with it has expired (offlineDays after 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:

json
{
  "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, fingerprint 7a1acf765768ebbc09bfbbac0878d5e729d655a4be4db7510d2188f630881134

Each case gives a certificate, the now to use, the app’s major version and, for the clock case, highestIssuedAt.

CaseExpected outcomenowApp major
validvalid17938764001
expiredexpired17964648011
tampered payloadinvalid_signature17938764001
unknown kidinvalid_signature17938764001
wrong productwrong_product17938764001
wrong machinewrong_machine17938764001
revoked statusnot_usable17938764001
perpetual v1 used by app v2version_not_covered17938764002
perpetual v1 used by app v1valid17938764001
clock rolled backclock_tampered17937000001
malformedmalformed17938728001
bad prefixmalformed17938764001

The file also contains a license key normalization case (input, normalized key and SHA-256), for implementations of the key lookup.

View as Markdown

Need help? See Troubleshooting or write to support@keyrail.tech.