diff --git a/README.md b/README.md index 517ec7d..6c6eedc 100644 --- a/README.md +++ b/README.md @@ -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, …). | diff --git a/doc/api-reference.md b/doc/api-reference.md index d49e702..e045544 100644 --- a/doc/api-reference.md +++ b/doc/api-reference.md @@ -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. | diff --git a/doc/security-guide.md b/doc/security-guide.md index 4f572ac..75f7d73 100644 --- a/doc/security-guide.md +++ b/doc/security-guide.md @@ -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/ +/ 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 diff --git a/doc/troubleshooting.md b/doc/troubleshooting.md index 2ac32e1..fee8eda 100644 --- a/doc/troubleshooting.md +++ b/doc/troubleshooting.md @@ -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({ diff --git a/src/core/SecureCompletionClient.ts b/src/core/SecureCompletionClient.ts index 6af5b8e..2c61c98 100644 --- a/src/core/SecureCompletionClient.ts +++ b/src/core/SecureCompletionClient.ts @@ -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 { 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 }; - 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 }; + 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, }); } diff --git a/src/types/client.ts b/src/types/client.ts index a5ea25b..1c78e61 100644 --- a/src/types/client.ts +++ b/src/types/client.ts @@ -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, diff --git a/tests/unit/key_management.test.ts b/tests/unit/key_management.test.ts new file mode 100644 index 0000000..6f1f701 --- /dev/null +++ b/tests/unit/key_management.test.ts @@ -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 { + await (client as unknown as { ensureKeys: () => Promise }).ensureKeys(); +} + +function publicKeyOf(client: SecureCompletionClient): Promise { + return (client as unknown as { + keyManager: { getPublicKeyPEM: () => Promise }; + }).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 }, '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 }).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 }).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(); + }); +});