Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ jobs:
KEYMINT_TEST_READONLY_API_KEY: ${{ secrets.KEYMINT_TEST_READONLY_API_KEY }}
KEYMINT_TEST_CLIENT_API_KEY: ${{ secrets.KEYMINT_TEST_CLIENT_API_KEY }}
KEYMINT_TEST_PRODUCT_ID: ${{ vars.KEYMINT_TEST_PRODUCT_ID }}
KEYMINT_TEST_PUBLIC_KEY: ${{ vars.KEYMINT_TEST_PUBLIC_KEY }}
KEYMINT_TEST_BASE_URL: https://api.keymint.dev
run: npm run test:live

Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## Unreleased

### Added

- `verifyOfflineLicense(license, options)` (also `KeyMint.verifyOfflineLicense`): verifies a signed offline license (`.lic` file or bare token) locally with `node:crypto`, no new dependencies. Requires the trusted public keys, `productId` and `machineCode`; optional `now` and `clockSkewSeconds` (default 60, max 3600). Returns `OfflineLicense`; failures throw `OfflineLicenseError` with a `code` (`malformed`, `unsupported_algorithm`, `unknown_key`, `bad_signature`, `wrong_type`, `wrong_product`, `wrong_machine`, `expired`). Invalid options throw `TypeError`/`RangeError`.
- Tests against the shared Keymint offline-license test vectors (34 cases).

## 1.6.0 - 2026-10-26

### Added
Expand Down
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ Keymint provides utilities to uniquely identify machines for node-locking:
| `floatingCheckout` | Checks out a floating license seat. |
| `floatingHeartbeat`| Sends a heartbeat to keep a session alive. |
| `floatingCheckin` | Checks in a session, releasing the seat. |
| `verifyOfflineLicense` | Verifies a signed offline license locally, without network access. Standalone export, also `KeyMint.verifyOfflineLicense`. |

### Customer Management

Expand Down Expand Up @@ -106,6 +107,67 @@ if (res.expirationDate !== undefined) saveLastExpirationDate(res.expirationDate)

Store the last `expirationDate` your app received. An expired license fails activation with the same generic error as an invalid key, so the stored date is what lets your app tell the user their license expired on that date instead of calling the key invalid.

## Offline Licenses

An offline license is a `.lic` file holding an Ed25519-signed token bound to one product and one machine. Your app verifies it locally with `verifyOfflineLicense`, with no network access.

1. **Get the `.lic` file.** Download it from the dashboard (Offline License) or create it with `signKey` (admin API key) and ship `result.file` to the machine. `hostId` there is the machine code the license is bound to.
2. **Bundle your public key.** Copy the PEM from **Developer → Public Keys** in the dashboard into your app. It is a public key, so it is safe to ship. Never bundle a private key; the verifier rejects anything that isn't an Ed25519 public key PEM.
3. **Verify** with the product ID and this machine's code:

```typescript
import { readFileSync } from 'fs';
import { KeyMint, verifyOfflineLicense, OfflineLicenseError } from 'keymint';

const PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEA...
-----END PUBLIC KEY-----`;

try {
const license = verifyOfflineLicense(readFileSync('license.lic', 'utf8'), {
publicKeys: [PUBLIC_KEY],
productId: 'prod_123', // required
machineCode: KeyMint.getOrCreateInstallationId(), // the hostId the file was signed for
// now: new Date(), // optional, see clock rollback below
// clockSkewSeconds: 60, // optional leeway on expiry, 0..3600, default 60
});
console.log(`Licensed until ${license.expiresAt.toISOString()}`);
} catch (error) {
if (!(error instanceof OfflineLicenseError)) throw error; // bad options are a programming error
switch (error.code) {
case 'expired': /* ask the user to renew / re-sign the file */ break;
case 'wrong_machine': /* the file was issued for another machine */ break;
case 'wrong_product': /* the file is for a different product */ break;
case 'malformed':
case 'unsupported_algorithm':
case 'unknown_key':
case 'bad_signature':
case 'wrong_type': /* not a genuine Keymint offline license for this app */ break;
}
}
```

`verifyOfflineLicense` is synchronous. It accepts the `.lic` file contents or the bare token, and returns `{ licenseKey, productId, machineCode, keyId, issuedAt, expiresAt }` (`issuedAt` is a `Date` or `null`, `expiresAt` a `Date`). On failure it throws `OfflineLicenseError` whose `code` is one of:

| Code | Meaning |
|---|---|
| `malformed` | Not a `.lic` file or token, or required claims (`exp`, `sub`) are missing. |
| `unsupported_algorithm` | The token isn't signed with EdDSA (e.g. `none`, `HS256`). |
| `unknown_key` | Signed by a key that isn't in `publicKeys`. |
| `bad_signature` | The signature doesn't match: the file was altered or forged. |
| `wrong_type` | The token isn't an offline license. |
| `wrong_product` | The license is for another product. |
| `wrong_machine` | The license is for another machine. |
| `expired` | Past `expiresAt` plus `clockSkewSeconds`. |

Error messages never contain the license key. Invalid options (no public keys, a key that isn't an Ed25519 SPKI PEM, empty `productId` or `machineCode`, `clockSkewSeconds` outside 0..3600, an invalid `now`) throw `TypeError` or `RangeError` instead.

**Why `productId` is required.** One signing key covers all of your organization's products, so the signature alone doesn't say which product a file unlocks. Without the product check, a license for a cheap product would unlock an expensive one.

**Clock rollback.** An offline app can't tell on its own if the user set the clock back. Store the latest time you trust (for example the last successful verification or online check) and pass `now: new Date(Math.max(Date.now(), lastTrustedTime))`.

**Key rotation.** `publicKeys` accepts several PEMs, so you can ship the old and new keys side by side during a rotation.

## Idempotency

All mutating SDK calls (such as creating keys, activations, customer management, etc.) support idempotency keys to safely retry requests in case of network drops. Pass a unique key (recommended: UUID v4) as the optional request options:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"scripts": {
"build": "tsc",
"lint": "eslint . --ext .ts",
"test": "npm run build && node --test tests/contracts.test.cjs",
"test": "npm run build && node --test tests/contracts.test.cjs tests/offline.test.cjs",
"test:live": "npm run build && node --test tests/live.test.cjs",
"prepublishOnly": "npm run build"
},
Expand Down
14 changes: 14 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,12 @@ import {
UpdateKeyParams, UpdateKeyResponse, SignKeyParams, SignKeyResponse,
RequestOptions
} from './types';
import { verifyOfflineLicense, OfflineVerifyOptions, OfflineLicense } from './offline';

// Export all types from types.ts to make them available to SDK users
export * from './types';
export { verifyOfflineLicense, OfflineLicenseError } from './offline';
export type { OfflineVerifyOptions, OfflineLicense, OfflineLicenseErrorCode } from './offline';

export class KeyMint {

Expand Down Expand Up @@ -547,6 +550,17 @@ export class KeyMint {
}
}

/**
* Verifies an offline license locally. Same as the standalone {@link verifyOfflineLicense}.
* @param license - The `.lic` file contents or the bare token.
* @param options - Trusted public keys, the expected product ID and machine code.
* @returns The verified license.
* @throws {OfflineLicenseError} When the license fails verification.
*/
static verifyOfflineLicense(license: string, options: OfflineVerifyOptions): OfflineLicense {
return verifyOfflineLicense(license, options);
}

/**
* Verifies a webhook payload signature received from Keymint.
* @param payload - The raw request body as string.
Expand Down
207 changes: 207 additions & 0 deletions src/offline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { createHash, createPublicKey, verify, KeyObject } from 'crypto';

/**
* Why an offline license failed verification. The first failing check decides the code.
*/
export type OfflineLicenseErrorCode =
| 'malformed'
| 'unsupported_algorithm'
| 'unknown_key'
| 'bad_signature'
| 'wrong_type'
| 'wrong_product'
| 'wrong_machine'
| 'expired';

/**
* Options for {@link verifyOfflineLicense}.
*/
export interface OfflineVerifyOptions {
/** Trusted public keys (SPKI PEM) from Developer → Public Keys. At least one. */
publicKeys: string[];
/** Must equal the license's product ID. Required: one signing key covers every product in an org. */
productId: string;
/** Must equal the machine code the license was signed for. */
machineCode: string;
/** Time to check expiry against. Default: the system clock. Pass max(system time, last trusted time) to resist clock rollback. */
now?: Date;
/** Leeway on expiry in seconds, 0 to 3600. Default: 60. */
clockSkewSeconds?: number;
}

/**
* The verified contents of an offline license.
*/
export interface OfflineLicense {
licenseKey: string;
productId: string;
machineCode: string;
/** The `kid` of the trusted public key that signed the license. */
keyId: string;
issuedAt: Date | null;
expiresAt: Date;
}

const ERROR_MESSAGES: Record<OfflineLicenseErrorCode, string> = {
malformed: 'Offline license is malformed',
unsupported_algorithm: 'Offline license uses an unsupported algorithm',
unknown_key: 'Offline license was not signed by a trusted public key',
bad_signature: 'Offline license signature is invalid',
wrong_type: 'Token is not an offline license',
wrong_product: 'Offline license is for a different product',
wrong_machine: 'Offline license is for a different machine',
expired: 'Offline license has expired',
};

/**
* Thrown by {@link verifyOfflineLicense} when a license fails verification.
* The message never contains the license key.
*/
export class OfflineLicenseError extends Error {
readonly code: OfflineLicenseErrorCode;

constructor(code: OfflineLicenseErrorCode) {
super(ERROR_MESSAGES[code]);
this.name = 'OfflineLicenseError';
this.code = code;
Object.setPrototypeOf(this, OfflineLicenseError.prototype);
}
}

const SEGMENT = /^[A-Za-z0-9_-]*$/;
const DEFAULT_CLOCK_SKEW_SECONDS = 60;
const MAX_CLOCK_SKEW_SECONDS = 3600;

// Manual base64url so this works on Node versions without the 'base64url' encoding.
// Callers only pass strings that already matched SEGMENT.
function fromBase64Url(segment: string): Buffer {
const base64 = segment.replace(/-/g, '+').replace(/_/g, '/');
return Buffer.from(base64 + '='.repeat((4 - (base64.length % 4)) % 4), 'base64');
}

function toBase64Url(buffer: Buffer): string {
return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

const isObject = (v: unknown): v is Record<string, unknown> =>
typeof v === 'object' && v !== null && !Array.isArray(v);

function decodeJson(segment: string): Record<string, unknown> | null {
try {
const value: unknown = JSON.parse(fromBase64Url(segment).toString('utf8'));
return isObject(value) ? value : null;
} catch {
return null;
}
}

function loadPublicKey(pem: unknown, index: number): [string, KeyObject] {
// Only SPKI public keys: createPublicKey would also derive a public key from a
// private key or certificate, which must never be bundled for this.
if (typeof pem !== 'string' || !pem.trim().startsWith('-----BEGIN PUBLIC KEY-----')) {
throw new TypeError(`publicKeys[${index}] must be an Ed25519 public key in SPKI PEM format`);
}
let key: KeyObject;
try {
key = createPublicKey(pem);
} catch {
throw new TypeError(`publicKeys[${index}] is not a valid public key PEM`);
}
if (key.asymmetricKeyType !== 'ed25519') {
throw new TypeError(`publicKeys[${index}] must be an Ed25519 public key`);
}
const der = key.export({ type: 'spki', format: 'der' });
return [toBase64Url(createHash('sha256').update(der).digest()), key];
}

/**
* Verifies an offline license (`.lic` file contents or the bare token) locally,
* without calling the Keymint API.
*
* @param license - The `.lic` file contents (JSON) returned by `signKey` or the dashboard, or the bare token.
* @param options - Trusted public keys, the expected product ID and machine code, and optional time settings.
* @returns The verified license.
* @throws {OfflineLicenseError} When the license fails verification; `code` says why.
* @throws {TypeError | RangeError} When the options are invalid (a programming error).
*/
export function verifyOfflineLicense(license: string, options: OfflineVerifyOptions): OfflineLicense {
if (!options || typeof options !== 'object') throw new TypeError('options are required');
const { publicKeys, productId, machineCode } = options;
if (!Array.isArray(publicKeys) || publicKeys.length === 0) {
throw new TypeError('publicKeys must contain at least one key');
}
if (typeof productId !== 'string' || productId.length === 0) throw new TypeError('productId is required');
if (typeof machineCode !== 'string' || machineCode.length === 0) throw new TypeError('machineCode is required');
const clockSkew = options.clockSkewSeconds ?? DEFAULT_CLOCK_SKEW_SECONDS;
if (typeof clockSkew !== 'number' || !(clockSkew >= 0 && clockSkew <= MAX_CLOCK_SKEW_SECONDS)) {
throw new RangeError('clockSkewSeconds must be between 0 and 3600');
}
let nowSeconds: number;
if (options.now === undefined) {
nowSeconds = Math.floor(Date.now() / 1000);
} else {
if (!(options.now instanceof Date) || isNaN(options.now.getTime())) {
throw new TypeError('now must be a valid Date');
}
nowSeconds = Math.floor(options.now.getTime() / 1000);
}
const keys = new Map(publicKeys.map((pem, i) => loadPublicKey(pem, i)));
if (typeof license !== 'string') throw new TypeError('license must be a string');

const fail = (code: OfflineLicenseErrorCode): never => {
throw new OfflineLicenseError(code);
};

// 1. Parse: a .lic file (JSON) or a bare token.
let token = license.trim();
if (token.startsWith('{')) {
let file: unknown;
try {
file = JSON.parse(token);
} catch {
return fail('malformed');
}
if (!isObject(file) || typeof file.signedKey !== 'string') return fail('malformed');
token = file.signedKey;
}
const parts = token.split('.');
if (parts.length !== 3 || !parts.every((p) => SEGMENT.test(p))) return fail('malformed');
const [headerB64, payloadB64, signatureB64] = parts;
const header = decodeJson(headerB64);
const payload = decodeJson(payloadB64);
if (!header || !payload) return fail('malformed');

// 2. Algorithm: exactly EdDSA, no critical extensions.
if (header.alg !== 'EdDSA' || 'crit' in header) return fail('unsupported_algorithm');

// 3. Key: the kid must name a trusted key.
const kid = header.kid;
const key = typeof kid === 'string' ? keys.get(kid) : undefined;
if (!key) return fail('unknown_key');

// 4. Signature over the segments exactly as received.
const signature = fromBase64Url(signatureB64);
if (signature.length !== 64 || !verify(null, Buffer.from(`${headerB64}.${payloadB64}`), key, signature)) {
return fail('bad_signature');
}

// 5-7. Claims, compared exactly.
if (payload.type !== 'offline') return fail('wrong_type');
if (payload.productId !== productId) return fail('wrong_product');
if (payload.machineCode !== machineCode) return fail('wrong_machine');

// 8. Shape of the remaining claims, then 9. expiry.
const { exp, iat, sub } = payload;
if (typeof exp !== 'number' || !Number.isFinite(exp)) return fail('malformed');
if (typeof sub !== 'string' || sub.length === 0) return fail('malformed');
if (!(nowSeconds < exp + clockSkew)) return fail('expired');

return {
licenseKey: sub,
productId,
machineCode,
keyId: kid as string,
issuedAt: typeof iat === 'number' ? new Date(iat * 1000) : null,
expiresAt: new Date(exp * 1000),
};
}
Loading
Loading