From d903068ca55b9dabb1f420df92158138e449c940 Mon Sep 17 00:00:00 2001 From: catsika Date: Thu, 12 Nov 2026 00:41:49 +0000 Subject: [PATCH] fix(identity): warn when the installation ID can't be saved; add requirePersisted getOrCreateInstallationId swallowed write errors and returned an unsaved ID, so every launch on a read-only or sandboxed disk produced a new ID and used another activation slot, with no signal to the app. - Default behaviour is unchanged: an unsaved ID is still returned, but one KeymintInstallationIdWarning (code KEYMINT_INSTALLATION_ID_NOT_SAVED) is now emitted per process via process.emitWarning. - New opt-in { requirePersisted: true } (second argument, or a single options object with storagePath) throws InstallationIdError with the storage path when the ID can't be saved and read back, or an existing file can't be read. - The saved value is read back to confirm it persisted. - Use path.dirname for the storage directory (Windows paths). - Tests, type checks, README and CHANGELOG. --- CHANGELOG.md | 11 ++++ README.md | 19 ++++++ package.json | 2 +- src/index.ts | 114 +++++++++++++++++++++++++++++---- tests/identity.test.cjs | 116 ++++++++++++++++++++++++++++++++++ tests/types/identity.check.ts | 21 ++++++ 6 files changed, 270 insertions(+), 13 deletions(-) create mode 100644 tests/identity.test.cjs create mode 100644 tests/types/identity.check.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 591e6a1..813ef57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Unreleased + +### Added + +- `getOrCreateInstallationId` accepts `{ requirePersisted: true }`, either as a second argument after `storagePath` or as a single options object with `storagePath`. It throws the new `InstallationIdError` (with `path`) when the ID can't be saved and read back, or an existing file can't be read. Recommended for desktop apps that activate devices. The default is `false` and the return type is unchanged. + +### Fixed + +- `getOrCreateInstallationId` no longer fails silently when it can't save the ID. By default it still returns an unsaved ID, and now emits one `KeymintInstallationIdWarning` per process (`process.emitWarning`, code `KEYMINT_INSTALLATION_ID_NOT_SAVED`), because the next launch will get a different ID and use another activation slot. +- The storage directory is found with `path.dirname`, so Windows paths with backslashes are created correctly. + ## 1.8.0 - 2026-11-11 ### Added diff --git a/README.md b/README.md index 0c1acad..6383c79 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,25 @@ Keymint provides utilities to uniquely identify machines for node-locking: - `KeyMint.getOrCreateInstallationId()`: **Recommended.** Generates a stable UUID anchored to hardware and persists it to `~/.keymint/installation-id`. - `KeyMint.getMachineId()`: Generates a SHA-256 fingerprint based on BIOS UUID, OS machine ID, and MAC address. +### When the installation ID can't be saved + +If `getOrCreateInstallationId()` can't save the ID (read-only disk, sandbox, permissions), by default it still returns an ID and emits one `KeymintInstallationIdWarning` per process through `process.emitWarning` (code `KEYMINT_INSTALLATION_ID_NOT_SAVED`). That ID isn't saved, so the next launch gets a different one and the same machine uses another activation slot. + +Desktop apps that activate devices should pass `requirePersisted: true`. It throws `InstallationIdError` (with the storage path in `err.path`) when the ID can't be saved and read back, or an existing ID file can't be read: + +```typescript +import { KeyMint, InstallationIdError } from 'keymint'; + +try { + const hostId = KeyMint.getOrCreateInstallationId({ requirePersisted: true }); +} catch (err) { + if (!(err instanceof InstallationIdError)) throw err; + // tell the user the app can't write to err.path, or pass a writable storagePath +} +``` + +Both call forms work: `getOrCreateInstallationId(storagePath, { requirePersisted: true })` and `getOrCreateInstallationId({ storagePath, requirePersisted: true })`. In serverless environments (Vercel, Lambda), pass a writable `storagePath` such as `/tmp/keymint-installation-id`. + ## API Methods ### License Key Management diff --git a/package.json b/package.json index 8005a1b..c771abc 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "scripts": { "build": "tsc", "lint": "eslint . --ext .ts", - "test": "npm run build && npm run typecheck:tests && node --test tests/contracts.test.cjs tests/offline.test.cjs", + "test": "npm run build && npm run typecheck:tests && node --test tests/contracts.test.cjs tests/offline.test.cjs tests/identity.test.cjs", "typecheck:tests": "tsc -p tests/types", "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 c44fb45..01738d1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ import { execSync } from 'child_process'; import { createHash, randomUUID, createHmac, timingSafeEqual } from 'crypto'; import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; -import { join } from 'path'; +import { dirname, join } from 'path'; import { homedir } from 'os'; import axios, { AxiosInstance, AxiosError } from 'axios'; import { @@ -26,6 +26,53 @@ export * from './types'; export { verifyOfflineLicense, OfflineLicenseError } from './offline'; export type { OfflineVerifyOptions, OfflineLicense, OfflineLicenseErrorCode } from './offline'; +/** Options for {@link KeyMint.getOrCreateInstallationId}. */ +export interface InstallationIdOptions { + /** + * Throw {@link InstallationIdError} instead of returning an unsaved ID when + * the ID cannot be saved and read back. Default `false`. Recommended for + * desktop apps that activate devices. + */ + requirePersisted?: boolean; +} + +/** + * Thrown by {@link KeyMint.getOrCreateInstallationId} with + * `requirePersisted: true` when the installation ID cannot be saved to, + * or read back from, `path`. + */ +export class InstallationIdError extends Error { + /** The storage path that could not be used. */ + readonly path: string; + /** The underlying file system error, if any. */ + readonly cause?: unknown; + + constructor(message: string, path: string, cause?: unknown) { + super(message); + this.name = 'InstallationIdError'; + this.path = path; + this.cause = cause; + Object.setPrototypeOf(this, InstallationIdError.prototype); + } +} + +let unsavedInstallationIdWarned = false; + +function warnUnsavedInstallationId(filePath: string): void { + if (unsavedInstallationIdWarned) return; + unsavedInstallationIdWarned = true; + process.emitWarning( + `Keymint could not save the installation ID to ${filePath}. An unsaved ID was returned, ` + + 'so the next launch will get a different ID and use another activation slot. ' + + 'Pass a writable storagePath, or { requirePersisted: true } to throw instead.', + { type: 'KeymintInstallationIdWarning', code: 'KEYMINT_INSTALLATION_ID_NOT_SAVED' }, + ); +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + export class KeyMint { // ─── Identity Utilities ────────────────────────────────────────────── @@ -74,22 +121,55 @@ export class KeyMint { * This is the **recommended** value to pass as `hostId` when activating * a license key. * - * If the file cannot be written (e.g. a read-only filesystem such as - * Vercel or Lambda), the write error is swallowed and the freshly generated - * ID is returned without being saved, so the next call returns a different - * ID. Pass a writable `storagePath` in those environments. + * If the ID cannot be saved (read-only disk, sandbox, permissions), the + * default is to return the generated ID anyway and emit one + * `KeymintInstallationIdWarning` per process via `process.emitWarning`. + * That ID is not saved, so the next launch gets a different one and uses + * another activation slot. Desktop apps that activate devices should pass + * `{ requirePersisted: true }`, which throws {@link InstallationIdError} + * instead. In serverless environments (Vercel, Lambda), pass a writable + * `storagePath`. * * @param storagePath - Optional custom path for the persistence file. * Defaults to `~/.keymint/installation-id`. + * @param options - `requirePersisted` (default `false`): throw + * {@link InstallationIdError} when the ID cannot be saved and read back, + * or an existing file cannot be read. * @returns A SHA-256 hashed 64-character hex string. - * @throws If an existing file cannot be read (e.g. a permissions error). + * @throws {InstallationIdError} Only with `requirePersisted: true`. + * @throws If an existing file cannot be read (default mode). */ - static getOrCreateInstallationId(storagePath?: string): string { - const filePath = storagePath || join(homedir(), '.keymint', 'installation-id'); + static getOrCreateInstallationId(storagePath?: string, options?: InstallationIdOptions): string; + /** + * Same as above, with `storagePath` and `requirePersisted` in one options object. + * Example: `KeyMint.getOrCreateInstallationId({ requirePersisted: true })`. + */ + static getOrCreateInstallationId(options: InstallationIdOptions & { storagePath?: string }): string; + static getOrCreateInstallationId( + storagePathOrOptions?: string | (InstallationIdOptions & { storagePath?: string }), + options?: InstallationIdOptions, + ): string { + const opts = typeof storagePathOrOptions === 'object' && storagePathOrOptions !== null + ? storagePathOrOptions + : { ...options, storagePath: storagePathOrOptions }; + const requirePersisted = opts.requirePersisted === true; + const filePath = opts.storagePath || join(homedir(), '.keymint', 'installation-id'); // 1. If the file exists, trust it and return its contents if (existsSync(filePath)) { - const stored = readFileSync(filePath, 'utf8').trim(); + let stored: string; + try { + stored = readFileSync(filePath, 'utf8').trim(); + } catch (err) { + if (requirePersisted) { + throw new InstallationIdError( + `Could not read the Keymint installation ID from ${filePath}: ${errorMessage(err)}`, + filePath, + err, + ); + } + throw err; + } if (stored.length > 0) { return createHash('sha256').update(stored).digest('hex'); } @@ -100,15 +180,25 @@ export class KeyMint { const uuid = randomUUID(); const compositeId = `${uuid}:${hardwareAnchor}:${Date.now()}`; - // 3. Persist it (with fallback for read-only filesystems like Vercel/Lambda) + // 3. Persist it and read it back try { - const dir = filePath.substring(0, filePath.lastIndexOf('/')); + const dir = dirname(filePath); if (dir && !existsSync(dir)) { mkdirSync(dir, { recursive: true }); } writeFileSync(filePath, compositeId, 'utf8'); + if (readFileSync(filePath, 'utf8').trim() !== compositeId) { + throw new Error('the saved value did not match when read back'); + } } catch (err) { - // Silently swallow error and fall back to returning the generated ID in-memory + if (requirePersisted) { + throw new InstallationIdError( + `Could not save the Keymint installation ID to ${filePath}: ${errorMessage(err)}`, + filePath, + err, + ); + } + warnUnsavedInstallationId(filePath); } return createHash('sha256').update(compositeId).digest('hex'); diff --git a/tests/identity.test.cjs b/tests/identity.test.cjs new file mode 100644 index 0000000..ad49cfe --- /dev/null +++ b/tests/identity.test.cjs @@ -0,0 +1,116 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { createHash } = require('node:crypto'); +const { KeyMint, InstallationIdError } = require('../dist/index.js'); + +function tempDir(t) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'keymint-id-')); + t.after(() => { + try { fs.chmodSync(dir, 0o700); } catch { /* ignore */ } + fs.rmSync(dir, { recursive: true, force: true }); + }); + return dir; +} + +// The parent "directory" is a regular file, so mkdir and write fail on every +// platform, even when the tests run as root. +function unwritablePath(t) { + const blocker = path.join(tempDir(t), 'not-a-directory'); + fs.writeFileSync(blocker, 'x'); + return path.join(blocker, 'installation-id'); +} + +async function collectWarnings(run) { + const warnings = []; + const onWarning = warning => { + if (warning.name === 'KeymintInstallationIdWarning') warnings.push(warning); + }; + process.on('warning', onWarning); + try { + await run(); + await new Promise(resolve => setImmediate(resolve)); + } finally { + process.off('warning', onWarning); + } + return warnings; +} + +test('saves the ID and returns the same value next time', async t => { + const file = path.join(tempDir(t), 'nested', 'installation-id'); + let first, second; + const warnings = await collectWarnings(() => { + first = KeyMint.getOrCreateInstallationId(file); + second = KeyMint.getOrCreateInstallationId(file); + }); + assert.equal(warnings.length, 0); + assert.equal(first, second); + assert.equal(first, createHash('sha256').update(fs.readFileSync(file, 'utf8').trim()).digest('hex')); +}); + +test('strict mode saves normally, with either call form', t => { + const file = path.join(tempDir(t), 'installation-id'); + const first = KeyMint.getOrCreateInstallationId(file, { requirePersisted: true }); + assert.equal(KeyMint.getOrCreateInstallationId({ storagePath: file, requirePersisted: true }), first); + assert.equal(KeyMint.getOrCreateInstallationId({ storagePath: file }), first); +}); + +test('an existing readable file is reused', t => { + const file = path.join(tempDir(t), 'installation-id'); + fs.writeFileSync(file, 'existing-id\n'); + const expected = createHash('sha256').update('existing-id').digest('hex'); + assert.equal(KeyMint.getOrCreateInstallationId(file), expected); + assert.equal(KeyMint.getOrCreateInstallationId(file, { requirePersisted: true }), expected); + assert.equal(fs.readFileSync(file, 'utf8'), 'existing-id\n'); +}); + +test('strict mode throws InstallationIdError when the ID cannot be saved', async t => { + const file = unwritablePath(t); + const warnings = await collectWarnings(() => { + assert.throws( + () => KeyMint.getOrCreateInstallationId(file, { requirePersisted: true }), + err => err instanceof InstallationIdError + && err.name === 'InstallationIdError' + && err.path === file + && err.message.includes(file) + && err.cause !== undefined, + ); + }); + assert.equal(warnings.length, 0); +}); + +test('strict mode throws when the saved value does not read back', t => { + const file = path.join(tempDir(t), 'installation-id'); + const original = fs.writeFileSync; + fs.writeFileSync = (target, data, ...rest) => original(target, String(data).slice(0, -1), ...rest); + t.after(() => { fs.writeFileSync = original; }); + assert.throws(() => KeyMint.getOrCreateInstallationId(file, { requirePersisted: true }), InstallationIdError); +}); + +test('read-only directory: strict throws', { skip: process.platform === 'win32' || process.getuid?.() === 0 }, t => { + const dir = tempDir(t); + fs.chmodSync(dir, 0o500); + assert.throws( + () => KeyMint.getOrCreateInstallationId(path.join(dir, 'installation-id'), { requirePersisted: true }), + InstallationIdError, + ); +}); + +// Keep this the only test that falls back in default mode: the warning is +// emitted once per process, and node --test runs each file in its own process. +test('default mode returns an unsaved ID and warns once per process', async t => { + const file = unwritablePath(t); + let first, second; + const warnings = await collectWarnings(() => { + first = KeyMint.getOrCreateInstallationId(file); + second = KeyMint.getOrCreateInstallationId(file); + }); + assert.match(first, /^[0-9a-f]{64}$/); + assert.match(second, /^[0-9a-f]{64}$/); + assert.notEqual(first, second); // nothing was saved + assert.equal(warnings.length, 1); + assert.equal(warnings[0].code, 'KEYMINT_INSTALLATION_ID_NOT_SAVED'); + assert.ok(warnings[0].message.includes(file)); +}); diff --git a/tests/types/identity.check.ts b/tests/types/identity.check.ts new file mode 100644 index 0000000..5437cb7 --- /dev/null +++ b/tests/types/identity.check.ts @@ -0,0 +1,21 @@ +// Type-level checks, compiled by `npm run typecheck:tests` (nothing is emitted). +import { KeyMint } from '../../src'; +import type { InstallationIdError, InstallationIdOptions } from '../../src'; + +// getOrCreateInstallationId still returns a string; requirePersisted is opt-in. +export function installationIdCalls(): string[] { + const options: InstallationIdOptions = { requirePersisted: true }; + return [ + KeyMint.getOrCreateInstallationId(), + KeyMint.getOrCreateInstallationId('/data/installation-id'), + KeyMint.getOrCreateInstallationId('/data/installation-id', options), + KeyMint.getOrCreateInstallationId({ requirePersisted: true }), + KeyMint.getOrCreateInstallationId({ storagePath: '/data/installation-id', requirePersisted: false }), + // @ts-expect-error requirePersisted is a boolean + KeyMint.getOrCreateInstallationId({ requirePersisted: 'yes' }), + ]; +} + +export function errorPath(err: InstallationIdError): string { + return err.path; +}