import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { HostConfig } from "../types.js";
import { SSHConnectionPoolImpl } from "./ssh-pool.js";
describe("SSH Private Key Caching Security", () => {
let pool: SSHConnectionPoolImpl;
let tempKeyPath: string;
const testKeyContent =
"-----BEGIN OPENSSH PRIVATE KEY-----\ntest-key-content\n-----END OPENSSH PRIVATE KEY-----";
beforeEach(async () => {
pool = new SSHConnectionPoolImpl({
enableHealthChecks: false,
});
// Create a temporary key file for testing
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ssh-key-test-"));
tempKeyPath = path.join(tmpDir, "test_key");
await fs.writeFile(tempKeyPath, testKeyContent, { mode: 0o600 });
});
afterEach(async () => {
await pool.closeAll();
// Clean up temp file
if (tempKeyPath) {
try {
await fs.unlink(tempKeyPath);
await fs.rmdir(path.dirname(tempKeyPath));
} catch {
// Ignore cleanup errors
}
}
});
describe("key cache cleanup on closeAll", () => {
it("should clear cached SSH keys when closeAll is called", async () => {
const host: HostConfig = {
name: "test-host",
host: "192.168.1.100",
port: 22,
protocol: "ssh",
sshKeyPath: tempKeyPath,
sshUser: "testuser",
};
// First connection attempt will cache the key
try {
await pool.getConnection(host);
} catch {
// Connection will fail (no real SSH server), but key should be cached
}
// Verify key cache is populated via internal access for testing
interface PoolInternal {
keyCache: Map<string, { key: Buffer; cachedAt: number }>;
}
const poolInternal = pool as unknown as PoolInternal;
expect(poolInternal.keyCache.size).toBeGreaterThan(0);
expect(poolInternal.keyCache.has(tempKeyPath)).toBe(true);
// Close all connections - this should clear the cache
await pool.closeAll();
// Verify key cache was cleared after closeAll
expect(poolInternal.keyCache.size).toBe(0);
expect(poolInternal.keyCache.has(tempKeyPath)).toBe(false);
});
});
describe("security: key cache should not persist indefinitely (CWE-316)", () => {
it("should expire cached keys after TTL", async () => {
vi.useFakeTimers();
const host: HostConfig = {
name: "test-host",
host: "192.168.1.100",
port: 22,
protocol: "ssh",
sshKeyPath: tempKeyPath,
sshUser: "testuser",
};
// First connection attempt will cache the key
try {
await pool.getConnection(host);
} catch {
// Connection will fail, but key should be cached
}
interface PoolInternal {
keyCache: Map<string, { key: Buffer; cachedAt: number }>;
getPrivateKey(keyPath: string): Promise<string>;
}
const poolInternal = pool as unknown as PoolInternal;
expect(poolInternal.keyCache.has(tempKeyPath)).toBe(true);
// Advance past the TTL (5 minutes)
vi.advanceTimersByTime(6 * 60 * 1000);
// Next access should detect TTL expiry and re-read
try {
await pool.getConnection(host);
} catch {
// Connection will fail, but key should be re-cached with new timestamp
}
// Key should still be in cache (re-read), but with a fresh timestamp
expect(poolInternal.keyCache.has(tempKeyPath)).toBe(true);
const entry = poolInternal.keyCache.get(tempKeyPath);
expect(entry).toBeDefined();
vi.useRealTimers();
});
});
describe("memory security (CWE-316)", () => {
it("should store keys as Buffer and zero memory on cleanup", async () => {
const host: HostConfig = {
name: "test-host",
host: "192.168.1.100",
port: 22,
protocol: "ssh",
sshKeyPath: tempKeyPath,
sshUser: "testuser",
};
// Cache the key
try {
await pool.getConnection(host);
} catch {
// Connection will fail, but key should be cached
}
interface PoolInternal {
keyCache: Map<string, { key: Buffer; cachedAt: number }>;
}
const poolInternal = pool as unknown as PoolInternal;
const entry = poolInternal.keyCache.get(tempKeyPath);
expect(entry).toBeDefined();
// Verify it's stored as a Buffer
expect(Buffer.isBuffer(entry!.key)).toBe(true);
// Save a reference to verify zeroing
const keyBuffer = entry!.key;
const originalContent = Buffer.from(keyBuffer); // copy for comparison
// Key should contain the test content
expect(keyBuffer.toString("utf-8")).toContain("test-key-content");
// closeAll should zero the buffer
await pool.closeAll();
// Buffer should be zeroed (filled with 0x00)
const allZeros = keyBuffer.every((byte) => byte === 0);
expect(allZeros).toBe(true);
// Verify it was actually different before zeroing
expect(originalContent.every((byte) => byte === 0)).toBe(false);
});
});
});