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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 && 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"
Expand Down
114 changes: 102 additions & 12 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 ──────────────────────────────────────────────
Expand Down Expand Up @@ -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');
}
Expand All @@ -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');
Expand Down
116 changes: 116 additions & 0 deletions tests/identity.test.cjs
Original file line number Diff line number Diff line change
@@ -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));
});
21 changes: 21 additions & 0 deletions tests/types/identity.check.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading