feat!: make RSA keys ephemeral by default
Some checks failed
NYX Security Scan / nyx-scan (pull_request) Failing after 5m51s
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:
parent
2495f1e6e8
commit
987acf8816
7 changed files with 260 additions and 39 deletions
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue