From 85fed8274e3937785dc2a62f9eb7408731fa58a5 Mon Sep 17 00:00:00 2001 From: catsika Date: Mon, 26 Oct 2026 02:27:32 +0000 Subject: [PATCH] feat: verify offline licenses locally (verifyOfflineLicense) Add verifyOfflineLicense(license, options), also exposed as KeyMint.verifyOfflineLicense. It checks a signed offline license (.lic file or bare token) with node:crypto only, following the shared Keymint offline-verifier spec: parse, EdDSA-only, trusted kid, Ed25519 signature, type, productId, machineCode, claim shape, expiry. productId is required because one signing key covers every product in an org. Failures throw OfflineLicenseError with a reason code; invalid options throw TypeError/RangeError. Error messages never contain the license key. Tests run the 34 shared test vectors plus argument-error cases. The live test verifies the real /key/sign output when KEYMINT_TEST_PUBLIC_KEY is set, wired from a repository variable in CI. --- .github/workflows/ci-cd.yml | 1 + CHANGELOG.md | 7 + README.md | 62 +++ package.json | 2 +- src/index.ts | 14 + src/offline.ts | 207 ++++++++ tests/fixtures/offline-license-fixtures.json | 487 +++++++++++++++++++ tests/live.test.cjs | 16 +- tests/offline.test.cjs | 121 +++++ 9 files changed, 915 insertions(+), 2 deletions(-) create mode 100644 src/offline.ts create mode 100644 tests/fixtures/offline-license-fixtures.json create mode 100644 tests/offline.test.cjs diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index a0d8ddb..b7feb3b 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 966ccf8..e2016c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 4f2e4d1..00bda98 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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: diff --git a/package.json b/package.json index ddbedf3..7d674ba 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/index.ts b/src/index.ts index 4b510a9..e862221 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 { @@ -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. diff --git a/src/offline.ts b/src/offline.ts new file mode 100644 index 0000000..0af7cbc --- /dev/null +++ b/src/offline.ts @@ -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 = { + 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 => + typeof v === 'object' && v !== null && !Array.isArray(v); + +function decodeJson(segment: string): Record | 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), + }; +} diff --git a/tests/fixtures/offline-license-fixtures.json b/tests/fixtures/offline-license-fixtures.json new file mode 100644 index 0000000..63cf758 --- /dev/null +++ b/tests/fixtures/offline-license-fixtures.json @@ -0,0 +1,487 @@ +{ + "description": "Shared test vectors for Keymint SDK offline-license verifiers. Generated by keymint-codebase scripts/offline-license-fixtures.mjs; spec in docs/internals/offline-sdk-verifiers.md. Keys are throwaway test keys. Times are Unix seconds.", + "defaults": { + "clockSkew": 60 + }, + "publicKeys": [ + "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAByGIA6xLMplivs4DVsZ5om7vFAIB5bVwAU/XWDDpvpQ=\n-----END PUBLIC KEY-----\n", + "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAKieZSKuGKB563QLn9aj+GIQKfR6MkQMDDzaeKFhgSwU=\n-----END PUBLIC KEY-----\n" + ], + "keyIds": [ + "shQl3bcJV8xf8xNG2F_anFs81OAxHFusIaaVMo6MryQ", + "FWSAs02SsqD6PpW6_N_nYfciRCGARq3htpqYX-Dvb-w" + ], + "cases": [ + { + "name": "valid .lic file", + "input": "{\n \"signedDate\": \"2027-01-14T08:00:00.000Z\",\n \"signedKey\": \"eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg\",\n \"keyId\": \"shQl3bcJV8xf8xNG2F_anFs81OAxHFusIaaVMo6MryQ\",\n \"publicKeyFingerprint\": \"shQl3bcJV8xf8xNG2F_anFs81OAxHFusIaaVMo6MryQ\"\n}", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": true, + "license": { + "licenseKey": "KM-ABCD-1234-EFGH-5678", + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "issuedAt": 1799913600, + "expiresAt": 1802592000, + "keyId": "shQl3bcJV8xf8xNG2F_anFs81OAxHFusIaaVMo6MryQ" + } + } + }, + { + "name": "valid bare token", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": true, + "license": { + "licenseKey": "KM-ABCD-1234-EFGH-5678", + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "issuedAt": 1799913600, + "expiresAt": 1802592000, + "keyId": "shQl3bcJV8xf8xNG2F_anFs81OAxHFusIaaVMo6MryQ" + } + } + }, + { + "name": "valid with the second trusted key", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6IkZXU0FzMDJTc3FENlBwVzZfTl9uWWZjaVJDR0FScTNodHBxWVgtRHZiLXcifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.bmGf_lC-y5DfEKUQtMjm29ytJXCoZQ_hyZzSONlp3IYihRWBAQ4v0x1Lz-AoBMMyZM6URexRwYpu5yXqee0zDg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": true, + "license": { + "licenseKey": "KM-ABCD-1234-EFGH-5678", + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "issuedAt": 1799913600, + "expiresAt": 1802592000, + "keyId": "FWSAs02SsqD6PpW6_N_nYfciRCGARq3htpqYX-Dvb-w" + } + } + }, + { + "name": "valid within clock skew after exp", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1802592030 + }, + "expect": { + "valid": true, + "license": { + "licenseKey": "KM-ABCD-1234-EFGH-5678", + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "issuedAt": 1799913600, + "expiresAt": 1802592000, + "keyId": "shQl3bcJV8xf8xNG2F_anFs81OAxHFusIaaVMo6MryQ" + } + } + }, + { + "name": "expired beyond clock skew", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1802592061 + }, + "expect": { + "valid": false, + "error": "expired" + } + }, + { + "name": "expired exactly at exp + skew", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1802592060 + }, + "expect": { + "valid": false, + "error": "expired" + } + }, + { + "name": "expired with clockSkew 0", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1802592000, + "clockSkew": 0 + }, + "expect": { + "valid": false, + "error": "expired" + } + }, + { + "name": "tampered payload", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kX2V4cGVuc2l2ZSIsIm1hY2hpbmVDb2RlIjoibWFjaGluZS0zZjlhMmM3ZSIsInR5cGUiOiJvZmZsaW5lIiwiaWF0IjoxNzk5OTEzNjAwLCJleHAiOjE4MDI1OTIwMDAsInN1YiI6IktNLUFCQ0QtMTIzNC1FRkdILTU2NzgifQ.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg", + "options": { + "productId": "prod_expensive", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "bad_signature" + } + }, + { + "name": "tampered signature", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yyicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "bad_signature" + } + }, + { + "name": "signature of wrong length", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "bad_signature" + } + }, + { + "name": "signed by an untrusted key", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InBMa2VTM3NINjRsLVM2QXN5U0pjdGZOeHRGb3J0ekVTVncwZVAzWGRyR2cifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.k2WNZjQM3px13jGW7Qdzt7YnbvdG4NSKLZ3uTKUOeAz6FHbbSxxIexFwUcMK8gjzxC8c_SBfvVp5Q7CSqNfFCw", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "unknown_key" + } + }, + { + "name": "untrusted key claiming a trusted kid", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.tIcbaVpKdrnBLRrlrfx6swM3FBuV7P2P7uNm0lPkz00athIirQTSAApnxHTgVYg6SBX0tf3o_kjqcr1emULdBA", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "bad_signature" + } + }, + { + "name": "missing kid", + "input": "eyJhbGciOiJFZERTQSJ9.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.MS4fUklB_83MTvXDYsrzzYD7fZCkK2y_sMuoIx3krmVGa_a8UG10mxeUiAafpuRbBUrqaifbk-aK-Dw7uVDhBg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "unknown_key" + } + }, + { + "name": "alg none", + "input": "eyJhbGciOiJub25lIiwia2lkIjoic2hRbDNiY0pWOHhmOHhORzJGX2FuRnM4MU9BeEhGdXNJYWFWTW82TXJ5USJ9.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "unsupported_algorithm" + } + }, + { + "name": "alg HS256 keyed with the public PEM", + "input": "eyJhbGciOiJIUzI1NiIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.87sbQA6z8sstO_zLc_67lxOQWr4yw8QK3fPNRTCki54", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "unsupported_algorithm" + } + }, + { + "name": "alg in a different case", + "input": "eyJhbGciOiJlZGRzYSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.C2EOw4vDrIHtLI-84HEhxWHS50zQwoW61Pm31pBmO1_6PjjTcJ12mExUwRo7s0NoH2uGKUNy9z9kaE7aVwClBQ", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "unsupported_algorithm" + } + }, + { + "name": "crit header", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEiLCJjcml0IjpbIngtZXh0Il0sIngtZXh0IjoxfQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.5zwJQHfajAjYzu0YaXKweL9PkAcGUgd8PjYgdCU4ZsttT2hwzNQD6H0Z4cap5xLaYlTNrardbDd_TD43MT83Dg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "unsupported_algorithm" + } + }, + { + "name": "wrong type", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9ubGluZSIsImlhdCI6MTc5OTkxMzYwMCwiZXhwIjoxODAyNTkyMDAwLCJzdWIiOiJLTS1BQkNELTEyMzQtRUZHSC01Njc4In0.tMygXZYXEY3-pxFh6vK9S9uqA7_pW2TSzQg4bjOD7i4-Wl2qAkJ4hikbOGS4GHfSPAOgsLyHXP9unftxH41WBg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "wrong_type" + } + }, + { + "name": "missing type", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwiaWF0IjoxNzk5OTEzNjAwLCJleHAiOjE4MDI1OTIwMDAsInN1YiI6IktNLUFCQ0QtMTIzNC1FRkdILTU2NzgifQ.CWBCyUNYB9eVr-sPv-QqDTr5we_qQ-ce-e9PptTdHkKBkEcMHGAbTxNB7TlMllMMMZSEC0WEklKQwY3BnNTiDQ", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "wrong_type" + } + }, + { + "name": "wrong product", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg", + "options": { + "productId": "prod_other", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "wrong_product" + } + }, + { + "name": "product compared exactly (case)", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg", + "options": { + "productId": "PROD_7HQ2LMX9VT4KPZ1AB3CD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "wrong_product" + } + }, + { + "name": "wrong machine", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-other", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "wrong_machine" + } + }, + { + "name": "machine compared exactly (whitespace)", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": " machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "wrong_machine" + } + }, + { + "name": "missing exp", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsInN1YiI6IktNLUFCQ0QtMTIzNC1FRkdILTU2NzgifQ.tucRm_7_BRKjbyv6QxWwTCff0g8i1YY9XmJfN7ihuPEbmrVb4cmjZiHVbVYV1cYbxIwZ7XtkYO18Uzye2cdrBA", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "malformed" + } + }, + { + "name": "exp is a string", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6IjE4MDI1OTIwMDAiLCJzdWIiOiJLTS1BQkNELTEyMzQtRUZHSC01Njc4In0._AVqmX5hc2nPsWqI7JJmJ49z5Tj729V8BA44as2sqqZl3tr8_K3IcJTIJfW3CwaM9a_OGjOdBavTdlL8inQ5Cw", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "malformed" + } + }, + { + "name": "missing sub", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMH0.c3YxlwPzwME4kTL63bmHVUbOWo-s7-eAT3RSu9Dk5MS9NGSAwltJ3sEXS0bgi2uGeiy36YdTbtfpbr1fdL2rAw", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "malformed" + } + }, + { + "name": "payload is not an object", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.WzEsMl0.6V5rHGuWVCPVB9bPRm6OPpH_CYyo8PbGAI_5ff2Ztw3Fz_L8cQRPA4h2l-cS_qVOb8BJE_qoU4P8FSVoNZh8BA", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "malformed" + } + }, + { + "name": "two segments", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "malformed" + } + }, + { + "name": "four segments", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg.x", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "malformed" + } + }, + { + "name": "padded base64", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9=.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "malformed" + } + }, + { + "name": "non-base64url characters", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_+/", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "malformed" + } + }, + { + "name": ".lic that is not JSON", + "input": "{not json", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "malformed" + } + }, + { + "name": ".lic without signedKey", + "input": "{\"keyId\":\"shQl3bcJV8xf8xNG2F_anFs81OAxHFusIaaVMo6MryQ\"}", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "malformed" + } + }, + { + "name": "empty input", + "input": "", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "malformed" + } + } + ] +} diff --git a/tests/live.test.cjs b/tests/live.test.cjs index e140e9e..970f664 100644 --- a/tests/live.test.cjs +++ b/tests/live.test.cjs @@ -1,6 +1,6 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { KeyMint } = require('../dist/index.js'); +const { KeyMint, verifyOfflineLicense } = require('../dist/index.js'); function required(name) { const value = process.env[name]; @@ -75,6 +75,20 @@ test('current license and customer workflows work end to end', async () => { const signed = await admin.signKey({ productId, licenseKey: nodeKey, hostId: nodeHost, ttl: 300 }); assert.equal(typeof signed.file, 'string'); assert.match(signed.file, /signedKey/); + const publicKey = process.env.KEYMINT_TEST_PUBLIC_KEY; + if (publicKey) { + const offline = verifyOfflineLicense(signed.file, { publicKeys: [publicKey], productId, machineCode: nodeHost }); + assert.equal(offline.licenseKey, nodeKey); + assert.equal(offline.productId, productId); + assert.equal(offline.machineCode, nodeHost); + assert.ok(offline.expiresAt.getTime() > Date.now()); + assert.throws( + () => verifyOfflineLicense(signed.file, { publicKeys: [publicKey], productId: `${productId}-other`, machineCode: nodeHost }), + { code: 'wrong_product' } + ); + } else { + console.log('KEYMINT_TEST_PUBLIC_KEY not set; skipping offline license verification'); + } const customer = await admin.createCustomer({ name: 'Node SDK CI', email: `node-ci-${runId}@example.com` }); customerId = customer.data.id; diff --git a/tests/offline.test.cjs b/tests/offline.test.cjs new file mode 100644 index 0000000..de64657 --- /dev/null +++ b/tests/offline.test.cjs @@ -0,0 +1,121 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const fixtures = require('./fixtures/offline-license-fixtures.json'); +const { KeyMint, verifyOfflineLicense, OfflineLicenseError } = require('../dist/index.js'); + +const seconds = date => (date === null ? null : date.getTime() / 1000); + +function optionsFor(testCase) { + const options = { + publicKeys: fixtures.publicKeys, + productId: testCase.options.productId, + machineCode: testCase.options.machineCode, + now: new Date(testCase.options.now * 1000) + }; + if (testCase.options.clockSkew !== undefined) options.clockSkewSeconds = testCase.options.clockSkew; + return options; +} + +test('fixture file has the expected 34 cases', () => { + assert.equal(fixtures.cases.length, 34); +}); + +for (const testCase of fixtures.cases) { + test(`fixture: ${testCase.name}`, () => { + const options = optionsFor(testCase); + if (testCase.expect.valid) { + const license = verifyOfflineLicense(testCase.input, options); + const expected = testCase.expect.license; + assert.deepEqual({ + licenseKey: license.licenseKey, + productId: license.productId, + machineCode: license.machineCode, + keyId: license.keyId, + issuedAt: seconds(license.issuedAt), + expiresAt: seconds(license.expiresAt) + }, { + licenseKey: expected.licenseKey, + productId: expected.productId, + machineCode: expected.machineCode, + keyId: expected.keyId, + issuedAt: expected.issuedAt, + expiresAt: expected.expiresAt + }); + } else { + assert.throws(() => verifyOfflineLicense(testCase.input, options), error => { + assert.ok(error instanceof OfflineLicenseError, `expected OfflineLicenseError, got ${error}`); + assert.equal(error.code, testCase.expect.error); + assert.equal(error.name, 'OfflineLicenseError'); + return true; + }); + } + }); +} + +const firstValid = fixtures.cases.find(c => c.expect.valid); + +test('errors never include the license key', () => { + const testCase = fixtures.cases.find(c => c.name === 'wrong product'); + try { + verifyOfflineLicense(testCase.input, optionsFor(testCase)); + assert.fail('expected an error'); + } catch (error) { + assert.ok(error instanceof OfflineLicenseError); + assert.ok(!error.message.includes(firstValid.expect.license.licenseKey)); + assert.ok(!String(error.stack).includes(firstValid.expect.license.licenseKey)); + } +}); + +test('KeyMint.verifyOfflineLicense is the same verifier', () => { + const license = KeyMint.verifyOfflineLicense(firstValid.input, optionsFor(firstValid)); + assert.equal(license.licenseKey, firstValid.expect.license.licenseKey); +}); + +test('now defaults to the system clock', () => { + const options = optionsFor(firstValid); + delete options.now; + const expiresAt = firstValid.expect.license.expiresAt; + if (Date.now() / 1000 < expiresAt) { + assert.equal(verifyOfflineLicense(firstValid.input, options).expiresAt.getTime() / 1000, expiresAt); + } else { + assert.throws(() => verifyOfflineLicense(firstValid.input, options), { code: 'expired' }); + } +}); + +test('argument errors are TypeError/RangeError, not verification results', () => { + const base = optionsFor(firstValid); + const rsaPem = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }) + .publicKey.export({ type: 'spki', format: 'pem' }); + const ed25519Private = crypto.generateKeyPairSync('ed25519') + .privateKey.export({ type: 'pkcs8', format: 'pem' }); + const cases = [ + [{ ...base, publicKeys: [] }, TypeError], + [{ ...base, publicKeys: undefined }, TypeError], + [{ ...base, publicKeys: ['not a pem'] }, TypeError], + [{ ...base, publicKeys: ['-----BEGIN PUBLIC KEY-----\ngarbage\n-----END PUBLIC KEY-----\n'] }, TypeError], + [{ ...base, publicKeys: [rsaPem] }, TypeError], + [{ ...base, publicKeys: [fixtures.publicKeys[0], rsaPem] }, TypeError], + [{ ...base, publicKeys: [ed25519Private] }, TypeError], + [{ ...base, productId: '' }, TypeError], + [{ ...base, productId: undefined }, TypeError], + [{ ...base, machineCode: '' }, TypeError], + [{ ...base, machineCode: undefined }, TypeError], + [{ ...base, clockSkewSeconds: -1 }, RangeError], + [{ ...base, clockSkewSeconds: 3601 }, RangeError], + [{ ...base, clockSkewSeconds: NaN }, RangeError], + [{ ...base, now: new Date(NaN) }, TypeError], + [{ ...base, now: 1800000000 }, TypeError] + ]; + for (const [options, ErrorType] of cases) { + assert.throws(() => verifyOfflineLicense(firstValid.input, options), error => { + assert.ok(error instanceof ErrorType, `expected ${ErrorType.name}, got ${error}`); + assert.ok(!(error instanceof OfflineLicenseError)); + return true; + }); + } + // Bounds are inclusive. + for (const clockSkewSeconds of [0, 3600]) { + assert.ok(verifyOfflineLicense(firstValid.input, { ...base, clockSkewSeconds })); + } +});