feat!: make RSA keys ephemeral by default
Some checks failed
NYX Security Scan / nyx-scan (pull_request) Failing after 5m51s

BREAKING CHANGE: keyDir now defaults to null (ephemeral) instead of
'client_keys'. Clients that relied on keys persisting across restarts
must now pass keyDir explicitly.

The Python SDK defaults to key_dir=None: a key pair is generated in
memory for the session and never written to disk. The JS port defaulted
to 'client_keys' and always persisted, so merely constructing a client
wrote an RSA private key into the working directory. That is a weaker
default than the client it ports, and one users never asked for.

  - keyDir?: string | null, defaulting to undefined. null and undefined
    both mean ephemeral, matching Python's None.
  - Persistent mode is unchanged when keyDir is set: load the existing
    pair from that directory, otherwise generate and save one there.
  - Browsers are always ephemeral; they have no filesystem.

Key rotation follows the same rule. It previously hardcoded
'client_keys' as its fallback directory, so an ephemeral client would
have started writing private keys to disk on the first rotation tick.
Rotated keys are now persisted only where keyDir or keyRotationDir is
explicitly configured.

Tests assert the intent (that saveKeys is never called) rather than
probing the filesystem, since a leftover client_keys/ from the old
default would otherwise make them pass or fail for the wrong reason.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Alpha Nerd 2026-07-19 11:19:03 +02:00
parent 2495f1e6e8
commit 987acf8816
Signed by: alpha-nerd
SSH key fingerprint: SHA256:QkkAgVoYi9TQ0UKPkiKSfnerZy2h4qhi3SVPXJmBN+M
7 changed files with 260 additions and 39 deletions

View file

@ -77,7 +77,7 @@ Full documentation is in the [`doc/`](doc/) directory:
### Key Management
- **Automatic**: Keys are generated on first use and saved to `keyDir` (default: `client_keys/`). Existing keys are reloaded on subsequent runs. Node.js only.
- **Ephemeral by default**: A fresh key pair is generated in memory on first use and never written to disk. Set `keyDir` to persist and reuse keys across runs (Node.js only); existing keys in that directory are reloaded automatically.
- **Password protection**: Optional AES-encrypted private key files (minimum 8 characters).
- **Secure permissions**: Private key files saved at `0600` (owner-only).
- **Auto-rotation**: Keys rotate every 24 hours by default (configurable via `keyRotationInterval`).
@ -230,9 +230,9 @@ new SecureChatCompletion(config?: ChatCompletionConfig)
| `secureMemory` | `boolean` | `true` | Zero sensitive buffers immediately after use. |
| `timeout` | `number` | `60000` | Request timeout in milliseconds. |
| `debug` | `boolean` | `false` | Print verbose logging to the console. |
| `keyDir` | `string` | `'client_keys'` | Directory to load/save RSA keys on startup. |
| `keyDir` | `string \| null` | `null` | Directory to load/save RSA keys on startup. Omit for ephemeral in-memory keys that are never written to disk. Node.js only. |
| `keyRotationInterval` | `number` | `86400000` | Auto-rotate keys every N ms. `0` disables rotation. |
| `keyRotationDir` | `string` | `'client_keys'` | Directory for rotated key files. Node.js only. |
| `keyRotationDir` | `string` | `keyDir` | Directory for rotated key files. Rotated keys are only persisted when `keyDir` or `keyRotationDir` is set. Node.js only. |
| `keyRotationPassword` | `string` | `undefined` | Password for encrypted rotated key files. |
| `maxRetries` | `number` | `2` | Extra retry attempts on 429/5xx/network errors. Exponential backoff (1 s, 2 s, …). |

View file

@ -21,9 +21,9 @@ new SecureChatCompletion(config?: ChatCompletionConfig)
| `secureMemory` | `boolean` | `true` | Enable immediate zeroing of sensitive buffers after use. |
| `timeout` | `number` | `60000` | Request timeout in milliseconds. |
| `debug` | `boolean` | `false` | Print verbose logging to the console. |
| `keyDir` | `string` | `'client_keys'` | Directory to load/save RSA keys on startup. If the directory contains an existing key pair it is loaded; otherwise a new pair is generated and saved there. Node.js only. |
| `keyDir` | `string \| null` | `null` | Directory to load/save RSA keys on startup. If the directory contains an existing key pair it is loaded; otherwise a new pair is generated and saved there. Omit (or pass `null`) for ephemeral keys generated in memory for this session only and never written to disk — this is the default, matching the Python SDK. Node.js only; browsers are always ephemeral. |
| `keyRotationInterval` | `number` | `86400000` (24 h) | Auto-rotate RSA keys every N milliseconds. Set to`0` to disable. |
| `keyRotationDir` | `string` | `'client_keys'` | Directory where rotated key files are saved. Node.js only. |
| `keyRotationDir` | `string` | value of `keyDir` | Directory where rotated key files are saved. When neither this nor `keyDir` is set, rotated keys stay in memory. Node.js only. |
| `keyRotationPassword` | `string` | `undefined` | Password used to encrypt rotated key files. |
| `maxRetries` | `number` | `2` | Maximum extra attempts on retryable errors (429, 500, 502, 503, 504, network errors). Uses exponential backoff (1 s, 2 s, …). Set to`0` to disable retries. |

View file

@ -90,14 +90,27 @@ JavaScript's `delete` and variable reassignment do not zero the underlying memor
### Default Behaviour
Keys are automatically generated on first use and saved to `client_keys/` (Node.js). On subsequent runs the saved keys are reloaded automatically.
Keys are **ephemeral by default**: a fresh pair is generated in memory on first use, lives only for the lifetime of the client, and is never written to disk. Nothing to protect at rest, nothing to rotate out of a directory, no key material surviving the process. This matches the Python SDK's `key_dir=None` default.
To reuse a key pair across runs, set `keyDir` explicitly (Node.js only):
```typescript
const client = new SecureChatCompletion({
baseUrl: 'https://api.nomyo.ai',
keyDir: '/var/lib/myapp/nomyo-keys',
});
```
The directory is created on first use and reloaded on subsequent runs:
```
client_keys/
<keyDir>/
private_key.pem # permissions 0600 (owner-only)
public_key.pem # permissions 0644
```
Persisting keys is a deliberate trade-off: it buys session continuity at the cost of a long-lived private key on disk. Prefer the ephemeral default unless you specifically need key reuse.
### Configure the Key Directory
```javascript
@ -154,7 +167,7 @@ const client2 = new SecureChatCompletion({
### File Permissions
Private key files are saved with `0600` permissions (owner read/write only) on Unix-like systems. Add `client_keys/` and `*.pem` to your `.gitignore` — both are already included if you use this package's default `.gitignore`.
This applies only when you opt into persistence via `keyDir`; ephemeral keys never reach the filesystem. Private key files are saved with `0600` permissions (owner read/write only) on Unix-like systems. Add your `keyDir` and `*.pem` to your `.gitignore``client_keys/` and `*.pem` are already included if you use this package's default `.gitignore`.
---
@ -210,9 +223,10 @@ const client = new SecureChatCompletion({
- [ ] Always use HTTPS (`allowHttp` is `false` by default)
- [ ] Load API key from environment variable, not hardcoded
- [ ] Enable `secureMemory: true` (default)
- [ ] Use password-protected key files (`keyRotationPassword`)
- [ ] Store keys outside the project directory and outside version control
- [ ] Add `client_keys/` and `*.pem` to `.gitignore`
- [ ] Prefer the ephemeral key default; only set `keyDir` if you genuinely need key reuse across runs
- [ ] If persisting keys: use password-protected key files (`keyRotationPassword`)
- [ ] If persisting keys: store them outside the project directory and outside version control
- [ ] If persisting keys: add your `keyDir` and `*.pem` to `.gitignore`
- [ ] Call `client.dispose()` when the client is no longer needed
- [ ] Consider the native addon if swap-file exposure is unacceptable

View file

@ -63,7 +63,7 @@ const client = new SecureChatCompletion({
### `Error: Failed to load keys: no such file or directory`
The `keyDir` directory or the PEM files inside it don't exist. On first run the library generates and saves a new key pair automatically. If you specified a custom `keyDir`, make sure the directory is writable:
The `keyDir` directory or the PEM files inside it don't exist. This only applies when you opt into persistence — by default keys are ephemeral and no directory is touched at all. On first run with a `keyDir` set, the library generates and saves a new key pair there automatically, so make sure the directory is writable:
```javascript
const client = new SecureChatCompletion({

View file

@ -74,7 +74,8 @@ export class SecureCompletionClient {
private readonly keyRotationDir?: string;
private readonly keyRotationPassword?: string;
private readonly maxRetries: number;
private readonly keyDir: string;
/** Undefined means ephemeral: keys live in memory only and are never written to disk. */
private readonly keyDir?: string;
private _isHttps: boolean = true;
/**
@ -98,7 +99,7 @@ export class SecureCompletionClient {
keyRotationDir,
keyRotationPassword,
maxRetries = 2,
keyDir = 'client_keys',
keyDir,
attestationPolicy,
quoteVerifier,
} = config;
@ -109,7 +110,8 @@ export class SecureCompletionClient {
this.keyRotationDir = keyRotationDir;
this.keyRotationPassword = keyRotationPassword;
this.maxRetries = maxRetries;
this.keyDir = keyDir;
// null and undefined both mean ephemeral (Python's key_dir=None default)
this.keyDir = keyDir ?? undefined;
this.keySize = keySize;
this.allowHttp = allowHttp;
this.secureMemory = secureMemory;
@ -203,10 +205,17 @@ export class SecureCompletionClient {
if (this.disposed) return;
if (this.debugMode) console.log('Key rotation: generating new key pair...');
try {
// Persist rotated keys only where a directory was configured. In
// ephemeral mode (the default) rotation stays in memory, so rotation
// never silently starts writing private keys to disk.
const rotationDir = typeof window === 'undefined'
? (this.keyRotationDir ?? this.keyDir)
: undefined;
await this.keyManager.rotateKeys({
keySize: this.keySize,
saveToFile: typeof window === 'undefined',
keyDir: this.keyRotationDir ?? 'client_keys',
saveToFile: rotationDir !== undefined,
...(rotationDir !== undefined && { keyDir: rotationDir }),
password: this.keyRotationPassword,
});
if (this.debugMode) console.log('Key rotation: complete');
@ -271,28 +280,35 @@ export class SecureCompletionClient {
private async _doEnsureKeys(): Promise<void> {
if (this.keyManager.hasKeys()) return;
// Try to load keys from the configured directory (Node.js only)
if (typeof window === 'undefined') {
try {
const fs = require('fs').promises as { access: (p: string) => Promise<void> };
const path = require('path') as { join: (...p: string[]) => string };
// Ephemeral mode (the default, matching Python's key_dir=None): the key
// pair exists only for this session and never touches disk. Also the only
// possible mode in browsers, which have no filesystem.
if (this.keyDir === undefined || typeof window !== 'undefined') {
if (this.debugMode) console.log('Generating ephemeral in-memory key pair (not persisted)');
await this.generateKeys();
return;
}
const privateKeyPath = path.join(this.keyDir, 'private_key.pem');
const publicKeyPath = path.join(this.keyDir, 'public_key.pem');
// Persistent mode: reuse the key pair in keyDir, or create and save one there.
try {
const fs = require('fs').promises as { access: (p: string) => Promise<void> };
const path = require('path') as { join: (...p: string[]) => string };
await fs.access(privateKeyPath);
await fs.access(publicKeyPath);
const privateKeyPath = path.join(this.keyDir, 'private_key.pem');
const publicKeyPath = path.join(this.keyDir, 'public_key.pem');
await this.loadKeys(privateKeyPath, publicKeyPath);
if (this.debugMode) console.log(`Loaded existing keys from ${this.keyDir}/`);
return;
} catch (_error) {
if (this.debugMode) console.log(`No existing keys found in ${this.keyDir}/, generating new keys...`);
}
await fs.access(privateKeyPath);
await fs.access(publicKeyPath);
await this.loadKeys(privateKeyPath, publicKeyPath);
if (this.debugMode) console.log(`Loaded existing keys from ${this.keyDir}/`);
return;
} catch (_error) {
if (this.debugMode) console.log(`No existing keys found in ${this.keyDir}/, generating new keys...`);
}
await this.generateKeys({
saveToFile: typeof window === 'undefined',
saveToFile: true,
keyDir: this.keyDir,
});
}

View file

@ -57,10 +57,15 @@ export interface ClientConfig extends AttestationConfig {
/**
* Directory to load/save RSA keys on startup.
* If the directory contains an existing key pair it is loaded; otherwise a
* new pair is generated and saved there. Default: 'client_keys'.
* Matches the Python SDK's `key_dir` constructor parameter.
* new pair is generated and saved there.
*
* Omit (or pass null) for ephemeral keys: a fresh pair is generated in
* memory for this session only and never written to disk. This is the
* default, matching the Python SDK's `key_dir=None`.
*
* Always ephemeral in browsers, which have no filesystem.
*/
keyDir?: string;
keyDir?: string | null;
/**
* Maximum number of retries on retryable errors (429, 500, 502, 503, 504,
@ -123,10 +128,14 @@ export interface ChatCompletionConfig extends AttestationConfig {
* Directory to load/save RSA keys on startup.
* If the directory contains an existing key pair it is loaded; otherwise a
* new pair is generated and saved there.
* Omit (or set to undefined) to use the default 'client_keys/' directory.
* Matches the Python SDK's `key_dir` constructor parameter.
*
* Omit (or pass null) for ephemeral keys: a fresh pair is generated in
* memory for this session only and never written to disk. This is the
* default, matching the Python SDK's `key_dir=None`.
*
* Always ephemeral in browsers, which have no filesystem.
*/
keyDir?: string;
keyDir?: string | null;
/**
* Maximum number of retries on retryable errors (429, 500, 502, 503, 504,

View file

@ -0,0 +1,182 @@
/**
* Unit tests for key persistence vs. ephemeral key handling.
*
* Mirrors the Python SDK's `key_dir` semantics: omitting it (the default)
* generates a key pair in memory for this session only and never writes to
* disk; supplying it reuses or creates a pair in that directory.
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { SecureCompletionClient } from '../../src/core/SecureCompletionClient';
/** Drive key initialisation without any network traffic. */
async function initKeys(client: SecureCompletionClient): Promise<void> {
await (client as unknown as { ensureKeys: () => Promise<void> }).ensureKeys();
}
function publicKeyOf(client: SecureCompletionClient): Promise<string> {
return (client as unknown as {
keyManager: { getPublicKeyPEM: () => Promise<string> };
}).keyManager.getPublicKeyPEM();
}
/**
* Watch for any attempt to write keys to disk.
*
* Asserting on the filesystem alone is unreliable here a stale `client_keys/`
* from before ephemeral keys became the default would make such a check pass or
* fail for the wrong reason. This asserts the intent directly.
*/
function watchSaves(client: SecureCompletionClient): jest.SpyInstance {
const keyManager = (client as unknown as { keyManager: object }).keyManager;
return jest.spyOn(keyManager as { saveKeys: (...args: never[]) => Promise<void> }, 'saveKeys');
}
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nomyo-keys-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe('ephemeral keys (default)', () => {
test('writes nothing to disk when no keyDir is configured', async () => {
const cwdBefore = fs.readdirSync(process.cwd());
const client = new SecureCompletionClient({
routerUrl: 'https://router.test',
keySize: 2048,
keyRotationInterval: 0,
});
const saveSpy = watchSaves(client);
await initKeys(client);
expect(saveSpy).not.toHaveBeenCalled();
// And no new directory materialised in the working directory
expect(fs.readdirSync(process.cwd())).toEqual(cwdBefore);
client.dispose();
});
test('each client gets its own key pair', async () => {
const a = new SecureCompletionClient({
routerUrl: 'https://router.test', keySize: 2048, keyRotationInterval: 0,
});
const b = new SecureCompletionClient({
routerUrl: 'https://router.test', keySize: 2048, keyRotationInterval: 0,
});
await initKeys(a);
await initKeys(b);
expect(await publicKeyOf(a)).not.toBe(await publicKeyOf(b));
a.dispose();
b.dispose();
});
test('keyDir: null is treated as ephemeral', async () => {
const client = new SecureCompletionClient({
routerUrl: 'https://router.test',
keySize: 2048,
keyRotationInterval: 0,
keyDir: null,
});
await initKeys(client);
expect(fs.readdirSync(tmpDir)).toEqual([]);
client.dispose();
});
test('key rotation stays in memory', async () => {
const client = new SecureCompletionClient({
routerUrl: 'https://router.test',
keySize: 2048,
keyRotationInterval: 0,
});
await initKeys(client);
const before = await publicKeyOf(client);
const saveSpy = watchSaves(client);
await (client as unknown as { rotateKeys: () => Promise<void> }).rotateKeys();
// Rotated to a genuinely new pair, still with nothing written to disk
expect(await publicKeyOf(client)).not.toBe(before);
expect(saveSpy).not.toHaveBeenCalled();
client.dispose();
});
});
describe('persistent keys (explicit keyDir)', () => {
test('generates and saves a key pair on first use', async () => {
const keyDir = path.join(tmpDir, 'keys');
const client = new SecureCompletionClient({
routerUrl: 'https://router.test',
keySize: 2048,
keyRotationInterval: 0,
keyDir,
});
await initKeys(client);
expect(fs.existsSync(path.join(keyDir, 'private_key.pem'))).toBe(true);
expect(fs.existsSync(path.join(keyDir, 'public_key.pem'))).toBe(true);
client.dispose();
});
test('private key is written owner-only (0600)', async () => {
const keyDir = path.join(tmpDir, 'keys');
const client = new SecureCompletionClient({
routerUrl: 'https://router.test',
keySize: 2048,
keyRotationInterval: 0,
keyDir,
});
await initKeys(client);
const mode = fs.statSync(path.join(keyDir, 'private_key.pem')).mode & 0o777;
expect(mode).toBe(0o600);
client.dispose();
});
test('reuses the existing key pair across clients', async () => {
const keyDir = path.join(tmpDir, 'keys');
const config = {
routerUrl: 'https://router.test',
keySize: 2048 as const,
keyRotationInterval: 0,
keyDir,
};
const first = new SecureCompletionClient(config);
await initKeys(first);
const firstKey = await publicKeyOf(first);
first.dispose();
const second = new SecureCompletionClient(config);
await initKeys(second);
expect(await publicKeyOf(second)).toBe(firstKey);
second.dispose();
});
test('rotation persists into the configured directory', async () => {
const keyDir = path.join(tmpDir, 'keys');
const client = new SecureCompletionClient({
routerUrl: 'https://router.test',
keySize: 2048,
keyRotationInterval: 0,
keyDir,
});
await initKeys(client);
const before = fs.readFileSync(path.join(keyDir, 'public_key.pem'), 'utf-8');
await (client as unknown as { rotateKeys: () => Promise<void> }).rotateKeys();
const after = fs.readFileSync(path.join(keyDir, 'public_key.pem'), 'utf-8');
expect(after).not.toBe(before);
expect(after.trim()).toBe((await publicKeyOf(client)).trim());
client.dispose();
});
});