# 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
}
```

| 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](/docs/client-api/#errors) 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

| 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](#test-vectors).

```js title="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`](/docs/vendor-api/#signing-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`](/docs/vendor-api/#signing-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](#format) with the [reference implementation](#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](#key-rotation).

What Keyrail commits to if the service is ever discontinued is on the [security page](/security/#continuity) and in the terms of service.

## Test vectors

[`test-vectors.json`](/docs/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`.

<div class="table-wrap"><table>
<thead><tr><th scope="col">Case</th><th scope="col">Expected outcome</th><th scope="col"><code>now</code></th><th scope="col">App major</th></tr></thead>
<tbody><tr><td>valid</td><td><code>valid</code></td><td><code>1793876400</code></td><td>1</td></tr><tr><td>expired</td><td><code>expired</code></td><td><code>1796464801</code></td><td>1</td></tr><tr><td>tampered payload</td><td><code>invalid_signature</code></td><td><code>1793876400</code></td><td>1</td></tr><tr><td>unknown kid</td><td><code>invalid_signature</code></td><td><code>1793876400</code></td><td>1</td></tr><tr><td>wrong product</td><td><code>wrong_product</code></td><td><code>1793876400</code></td><td>1</td></tr><tr><td>wrong machine</td><td><code>wrong_machine</code></td><td><code>1793876400</code></td><td>1</td></tr><tr><td>revoked status</td><td><code>not_usable</code></td><td><code>1793876400</code></td><td>1</td></tr><tr><td>perpetual v1 used by app v2</td><td><code>version_not_covered</code></td><td><code>1793876400</code></td><td>2</td></tr><tr><td>perpetual v1 used by app v1</td><td><code>valid</code></td><td><code>1793876400</code></td><td>1</td></tr><tr><td>clock rolled back</td><td><code>clock_tampered</code></td><td><code>1793700000</code></td><td>1</td></tr><tr><td>malformed</td><td><code>malformed</code></td><td><code>1793872800</code></td><td>1</td></tr><tr><td>bad prefix</td><td><code>malformed</code></td><td><code>1793876400</code></td><td>1</td></tr></tbody>
</table></div>

The file also contains a license key normalization case (input, normalized key and SHA-256), for implementations of the key lookup.
